DEV Community

Cover image for Building a Scalable React Component Library: Lessons From Concorde Elements
Adeola Adeyemo for epilot

Posted on

Building a Scalable React Component Library: Lessons From Concorde Elements

Introduction

We recently embarked on the complete redesign of our Journeys, aiming for a modern, state-of-the-art look - Project Concorde. This wasn't just a UI overhaul; we sought the flexibility for future modifications, knowing that simply patching our existing UI, heavily reliant on Material UI, wouldn't suffice. We needed to build a new foundation from the ground up.

The full Project Concorde story is larger, but in this article, we'll dive into the story of @epilot/concorde-elements, the new component library born from that need, and how we built a system that not only powers our new interface, but also empowers our development team.

Why did we need a new component library?

Our existing component library, @epilot/journey-elements, was based on Material UI. While it served its purpose, our goal was to reduce our reliance on Material UI to gain several crucial benefits:

  • Reduce performance overhead from Material UI's style generation in favour of CSS modules
  • React version constraints tied to MUI releases.
  • Styling limitations blocking modern CSS features.
  • Remove the use of the Material UI theme object in saved custom Designs

The image below shows the overhead to create custom designs:

Showing the MUI overhead

Why not use existing design systems?

We found that most off-the-shelf design systems are quite opinionated with their styling and theming. Our philosophy was that it's easier to replace a single unit than an entire factory. Our primary goal was to keep our new system simple and extensible.

The Challenge

The path forward was not without its hurdles:

  • We needed to build 37+ components with various potential variants.
  • All Journey blocks had to be migrated to the new design, and we anticipated new complexities during integration due to component usage.
  • We had to ensure that custom themes built with the previous design system worked seamlessly with the new one.

Phases of Developments

1: Pursuit of Purity

In the initial phase, our approach was to create every component from scratch. We desired control over every pixel and line of code. Our first significant task was migrating the Product Tile to enable a new "Recommended Product" feature. We began with the basics.

Our early Button component perfectly illustrates this "purity" approach—simple, direct, and completely self-contained.

/* An early Button.tsx */
import classes from './Button.module.scss';
import type { ButtonProps } from './types.ts';
// ...

export const Button = forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
  // ...
  return (
    <button
      className={classNames('Concorde-Button', classes.root, ...)}
      {...props}
    >
      {/* ... */}
    </button>
  )
});
Enter fullscreen mode Exit fullscreen mode

Next, we successfully built our Link, Card, TextField, StepperInput, Image, ImageStepper, MobileStepper, and the ThemeProvider. With these foundational components, we successfully released the "Recommended Product" feature, receiving excellent feedback.

Our next milestone involved migrating the Date field and adding a Time select, which led to the DatePicker component. The design for this component had unique custom requirements that made building it entirely from scratch impractical. After some research, we opted to extend an existing library, React DatePicker, with custom functionalities to suit our use cases.

The resulting DatePicker involved creating new sections like the Footer and Time Select, and replacing the TextField and Header. While functional, the process was cumbersome, and the result felt more like a series of patches than a cohesive part of our system. This experience was our wake-up call: our "from-scratch" purity, and even our one-off extension approach, was simply not scalable.

2: The Pivot - 'Headless UI & Purity' Hybrid

This realization prompted a strategic shift. Speed became as crucial as purity. This quest led us to a breakthrough: headless components. These were libraries that provided the complex logic, state management, and accessibility of common widgets, but shipped with absolutely no styles. This was our "aha!" moment.

After further research, we settled on two incredible libraries: Radix UI Primitives and MUI Base (which recently became Base UI). They offered us the best of both worlds. We could now build our components by composing these primitives and applying our own distinct design system on top.

Consider our Autocomplete component, for instance, it leverages a hook from MUI Base for its core logic, while we provide the entire UI.

// A simplified look at our Autocomplete component
import { useAutocomplete } from '@mui/base/AutocompleteUnstyled'
import { forwardRef } from 'react'

import { Input, Menu, MenuItem } from '..';
// ...

export const Autocomplete = forwardRef(function Autocomplete(props, ref) {
  // ...
  const {
    getRootProps,
    getInputProps,
    getListboxProps,
    getOptionProps,
    groupedOptions
    // ...
  } = useAutocomplete({ ...props })

  return (
    <div {...getRootProps(other)} ref={ref}>
      <Input {...getInputProps()} />
      {groupedOptions.length > 0 ? (
        <Menu {...getListboxProps()}>
          {(groupedOptions as any[]).map((option, index) => (
            <MenuItem {...getOptionProps({ option, index })}>{option.label}</li>
          ))}
        </Menu>
      ) : null}
    </div>
  )
})
Enter fullscreen mode Exit fullscreen mode

We were no longer reinventing the wheel for every single component. This allowed us to focus our efforts on what truly made our library unique: our design and the developer experience. We only created components from scratch when absolutely necessary.

This hybrid model significantly accelerated the development of the remaining components.

Key Principles

Throughout the creation of our component library, we adhered to several core principles that ensured our team worked in unison. These principles were internally documented in our contribution guidelines and are outlined below.

A Consistent Component Structure

To keep our codebase organized and predictable, we established a standard folder structure for every component. For example,

/_ src/components/Button/
  ├── Button.tsx            // Logic
  ├── Button.module.scss    // Styles
  ├── Button.test.tsx       // Tests
  ├── types.ts              // Type definitions
  └── index.ts              // Exports
Enter fullscreen mode Exit fullscreen mode

This simple convention meant that any developer could easily navigate to any component and immediately locate its logic, styles, and type definitions with reducing collaboration overhead.

// Button

...
<Component
   aria-disabled={isDisabled || variant === 'disabled'}
   className={classNames(
     'Concorde-Button',
     classes.root,
     variant && classes[`variant-${variant}`],
     variant === 'primary' && 'Concorde-Button__Primary',
     ...
     className
   )}
   style={customStyles}
   ...
 >
  ...
</Component>
Enter fullscreen mode Exit fullscreen mode
// Card

<div
   className={
     classNames(
       'Concorde-Card', 
       classes.root, 
       className
     )
   }
   ref={ref}
   style={customStyles}
   {...rest}
/>
Enter fullscreen mode Exit fullscreen mode

Notice the use of the Concorde prefix in the static HTML classes. This served as a foundational element for easy custom styling, a topic not covered in detail here.

Design Tokens & Theming

The next pillar was unifying our design system. We created a comprehensive set of global design tokens using CSS variables for every aspect of our UI: colors, spacing, typography, transitions, shape and more. This became the language of our design system.

By coding colors, typography and dimensions as CSS custom properties, we guaranteed every component would be visually consistent, utilizing the same set of values. We also enabled these values to be extended and customized externally as local variables, preventing hard-coded styles and ensuring maximum flexibility.

:root {
  --concorde-primary-color: #0070f3;
  --concorde-secondary-color: #ff7e1b;
  --concorde-font-family: 'Proxima-Nova', sans-serif;
  --concorde-spacing: 0.25rem;
}
Enter fullscreen mode Exit fullscreen mode

Now, all our components can simply use color: var(--concorde-primary-color) or margin: var(--concorde-spacing). This setup dramatically simplified theming. For example, toggling the typography tokens automatically affects all text on the screen.

Beyond the tokens used internally, we also exposed custom tokens for each component. These external tokens provide powerful ways to extend a component's functionalities.

For example, the Button component has the following custom tokens:

const customColors: ButtonCSSProperties = {
    '--concorde-button-label-color': color,
    '--concorde-button-background-color': backgroundColor,
    '--concorde-button-hover-bg-color': hoverBgColor,
    '--concorde-button-active-bg-color': activeBgColor,
    '--concorde-button-gap': gap ? `${gap}px` : undefined
  }
Enter fullscreen mode Exit fullscreen mode

As an example, the CSS styles below will specifically modify the Button and Card:

:root {
  /* Button styles */
  --concorde-button-label-color: #ffffff;
  --concorde-button-background-color: #ff7e1b;
  --concorde-button-gap: 0.5rem;

  /* Card styles */
  --concorde-card-background-color: #e34590;
}
Enter fullscreen mode Exit fullscreen mode

Note the consistent use of the concorde prefix for the token naming for consistency and scoping.

All tokens (default and custom tokens) are thoroughly documented in Storybook and our developer documentation for clarity.

TypeScript: Our Shield and Guide

From day one, we committed to writing everything in TypeScript. More than just providing type safety, our type files became a form of documentation. We commented our types extensively, enabling any developer using a component to understand the purpose of each prop right from their IDE.

// An excerpt from our Autocomplete types.ts
export interface AutocompleteProps<
  // ...
>extends UseAutocompleteProps<T, Multiple, DisableClearable, FreeSolo> {
  /**
   * The label content.
   */
  label?: React.ReactNode
  /**
   * If `true`, the component is disabled.
   * @default false
   */
  disabled?: boolean
  // ... and so on
}
Enter fullscreen mode Exit fullscreen mode

Composition: Building Blocks for Complex UI

A fundamental principle guiding our development was composition. Our base components were designed to be flexible building blocks. By combining them, we could construct more complex and specialized UIs without adding bloat to the core library. This allowed us to build sophisticated features by assembling simple, well-tested parts - a true "change one, change all" approach.

A simple Input could be composed to PatternInput, IbanInput, NumberInput and more complex components like a ProductTile could look be structured as follows:

// A conceptual ProductTile built with composition
import { Card, Image, Typography, Button } from '@epilot/concorde-elements'
import styles from './ProductTile.module.scss'

export const ProductTile = ({ product }) => {
  return (
    <Card className={styles.tile}>
      <Image src={product.imageUrl} alt={product.name} />
        <Typography as="h4">{product.name}</Typography>
        <Typography>{product.description}</Typography>
        <Button variant="primary" label="Add to Cart" />
    </Card>
  )
}
Enter fullscreen mode Exit fullscreen mode

Developer Experience

Vite

Vite powered our local development with a lightning-fast dev server and HMR, greatly benefiting from our monorepo setup

Storybook: The Living Documentation

How could we ensure quality and consistency across our components? The answer was Storybook. It became far more than just a component gallery; it became the living, breathing heart of our project. For every component, we wrote stories that showcased all its variants and states.

// A story from Input.stories.tsx
import type { Meta, StoryObj } from '@storybook/react'
import { Input } from './Input'

const meta: Meta<typeof Input> = {
  title: 'Components/Input',
  component: Input
  // ...
}
export default meta

export const WithLabel: Story = {
  args: {
    label: 'Email Address',
    placeholder: 'Enter your email...'
  }
}

export const Disabled: Story = {
  args: {
    ...WithLabel.args,
    disabled: true
  }
}
Enter fullscreen mode Exit fullscreen mode

Critically, every story also served as an accessibility test. We integrated the @storybook/addon-a11y addon, which runs automated accessibility checks on every story against WCAG standards. This proactive approach allowed us to catch issues with color contrast, ARIA attributes, and keyboard navigation right within our development environment.

Testing & Accessibility

To build components that last, they must be reliable. Alongside the real-time accessibility checks in Storybook, we built a robust testing foundation. We used Vitest as our test runner and React Testing Library to write unit and integration tests for every component. We also used vitest-axe and React Testing Library for more intricate accessibility checks.

These tests were not just about preventing regressions, they were about enforcing correctness. We tested component logic, ensuring that each part behaved exactly as expected under various conditions. This combination of automated accessibility checks and functional testing was crucial. It gave us the confidence to refactor, add features, and scale the library, knowing that our foundation of quality would hold strong.

These automated tests run in our CI to catch regressions early.

The Payoff: Scalability in Action

The true test of concorde-elements came as we started integrating it in our main application, specifically in the concorde-renderers package. The work of setting up tokens, using primitives, and embedding quality through testing and documentation paid off.

Developing new features for Project Concorde transformed from a chore into a delight. Need a Modal? Pull in the component. Need a complex form field? Compose it with our TextField, Autocomplete, and Button components. They all looked consistent and behaved predictably.

This became the foundation for more interesting features:

  • Custom CSS: custom styling for resulting Journeys
  • Consistent design for Custom Journey Apps using the published library

Conclusion

Building a component library is a journey. Ours taught us that a little upfront systematization goes a long way. The initial purity of building "from scratch" gave way to the pragmatic wisdom of building on top of a solid, accessible foundation.

This yielded a few key principles we now live by:

  1. Start with design tokens: They are the bedrock of a consistent and scalable design system.
  2. Embrace headless primitives: Don't reinvent the wheel for everything, but leverage existing, well-tested solutions for speed and robustness.
  3. Make TypeScript non-negotiable: The safety and developer experience benefits are immeasurable.
  4. Testing: A combination of unit, integration, and automated accessibility testing is crucial for a reliable library.
  5. Build with composition in mind: Create simple, flexible blocks that can be assembled into complex UIs, promoting reusability and maintainability.
  6. Document thoroughly: A living, tested, and accessible documentation hub aligns everyone and accelerates development.
  7. Be adaptable: The perfect plan rarely survives contact with reality. Be ready to pivot and embrace better ideas.

The @epilot/concorde-elements library is more than just a collection of React components. It's a testament to our team's journey, a foundation for our product's future, and a system that helps us build better, faster, and more consistently. We hope our story can help guide you on your own path to building a scalable and resilient component library.

Resources

Cover Photo by Ryan Quintal on Unsplash

Top comments (0)