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:
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>
)
});
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>
)
})
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
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>
// Card
<div
className={
classNames(
'Concorde-Card',
classes.root,
className
)
}
ref={ref}
style={customStyles}
{...rest}
/>
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;
}
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
}
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;
}
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
}
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>
)
}
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
}
}
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:
- Start with design tokens: They are the bedrock of a consistent and scalable design system.
- Embrace headless primitives: Don't reinvent the wheel for everything, but leverage existing, well-tested solutions for speed and robustness.
- Make TypeScript non-negotiable: The safety and developer experience benefits are immeasurable.
- Testing: A combination of unit, integration, and automated accessibility testing is crucial for a reliable library.
- Build with composition in mind: Create simple, flexible blocks that can be assembled into complex UIs, promoting reusability and maintainability.
- Document thoroughly: A living, tested, and accessible documentation hub aligns everyone and accelerates development.
- 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
- Concorde Elements GitHub Repository
- Design Tokens Documentation
- Storybook
- HTML Structure Documentation
- Custom CSS Documentation
Cover Photo by Ryan Quintal on Unsplash
Top comments (0)