Back

Best practices for structuring a custom design system

Dec 24th, 2024

Building a design system for your company is one of the most effective ways to ensure consistency across your products. In fact, most serious projects today have a design system in place. A well-structured design system not only streamlines design and development but also helps create a cohesive user experience.

In this article, we’ll walk through some best practices for structuring your company's design system to ensure it's maintainable, scalable, and it's not leaking external libraries APIs/concepts.

What is a design system?

Before diving into best practices, let’s define what a design system is. Simply put, a design system is a collection of reusable components, governed by clear standards, that can be combined to build consistent user interfaces. It acts as a single source of truth, bringing together all the elements necessary to design, develop, and maintain a product.

This guide is particularly useful for teams building design systems that rely on a UI library, as the most common mistake is allowing external library APIs or concepts to bleed into the design system, creating unnecessary complexity and confusion.

Best practices for building design systems

Let's dive into the most important best practices that will help you structure a sustainable and effective design system.

1. Create a single import point for your consumers

What this means: Provide consumers with a single entry point for importing design system components, rather than having them reference multiple sources (for instance, importing from both your custom library and the underlying third-party library).

// ❌ Don't allow consumers to import components from multiple sources
import { TextField } from '@your-company/design-system';
import { Button } from '@mui/material';
// ✅ Restrict them to only import from your Design System's package
import { Button, TextField } from '@your-company/design-system';

Benfits:

  • Simplicity: Consumers don’t need to remember where to import components from, making it easier to use the design system.
  • Flexibility: If the underlying UI library undergoes a breaking change, you can manage it internally within your design system without affecting consumers. This means consumers can continue using the same API, even if you switch libraries or update underlying components.
  • Centralization Documentation: Having all components in one location, makes consistently documenting them much easier. While maintaining documentation might seem like extra work, it will ultimately save time and reduce confusion in the long run.

2. Build a custom theme using CSS variables

What this means: Create a custom theme by defining design tokens (e.g. colors, typography, spacing) as CSS variables. Instruct your consumers to apply these variables at the top of their app to ensure consistency. Most of the modern UI libraries nowadays allows you customize their theme using a ThemeProvider, or similar API.

If you are building custom theme from scratch, you can define your theme using CSS variables globally like this:

styles.css
:root {
color-scheme: light dark;
--color-primary: #1745c2;
--color-danger: #c10000;
--color-gray-100: rgba(1, 5, 20, 0.05);
--color-gray-200: rgba(2, 5, 19, 0.08);
--color-gray-300: rgba(2, 5, 18, 0.17);
--color-gray-400: rgba(2, 5, 18, 0.38);
--color-gray-500: rgba(2, 5, 17, 0.5);
--color-gray-600: rgba(2, 5, 16, 0.67);
--color-gray-700: rgba(3, 5, 15, 0.77);
--color-gray-800: rgba(3, 6, 13, 0.85);
--color-gray-900: rgba(3, 6, 13, 0.9);
@media (prefers-color-scheme: dark) {
--color-primary: rgb(95, 149, 255);
--color-danger: rgb(255, 154, 134);
--color-gray-100: rgba(38, 41, 47, 0.65);
--color-gray-200: rgba(45, 48, 55, 0.8);
--color-gray-300: rgba(55, 59, 65, 0.8);
--color-gray-400: rgba(87, 91, 99, 0.8);
--color-gray-500: rgba(135, 140, 150, 0.8);
--color-gray-600: rgba(191, 196, 207, 0.8);
--color-gray-700: rgba(222, 229, 241, 0.8);
--color-gray-800: rgba(222, 229, 241, 0.8);
--color-gray-800: rgba(227, 232, 242, 0.85);
--color-gray-900: rgba(236, 238, 244, 0.9);
}
}

Or, if you are using a UI library, you can use the library's API for it. For example, using Material UI, it would look like this:

import { createTheme, ThemeProvider } from '@mui/material/styles';
import { orage } from '@mui/material/colors';
const theme = createTheme({
cssVariables: true,
/* other theme overrides */
});
function App() {
return <ThemeProvider theme={theme}>...</ThemeProvider>;
}

This will generate automatically a global stylesheet defining all CSS theme variables, allowing you to reference them in your style overrides.

:root {
--mui-palette-primary-main: #1976d2;
/* ...other variables */
}

Benefits:

  • Consistency: By referencing CSS variables in your components instead of hardcoding values, you ensure a uniform design throughout your app.
  • Centralized control: With all global design values in one place, you can easily manage and update the system’s colors, typography, and spacing without having to modify individual components.
  • Theme flexibility: A token-based system enables you to easily switch themes. You can create multiple themes (e.g., light and dark mode) simply by changing the theme tokens.

Caution: Be careful not to overdo it with customizations in the theme layer. The goal is to define global design tokens, not to override component-specific styles, which we'll cover in the next section.

3. Wrap components to build custom ones

What this means: When you need to customize components—whether it's modifying their APIs or altering their styles—wrap the underlaying library's component. This approach keeps your design system flexible and modular.

@your-company/design-system/Button.tsx
import cs from 'classnames';
import { Button as MUIButton } from '@mui/material';
// ✅ Use CSS modules to localize the styles to this component
import styles from './Button.module.css';
export default function Button(props) {
// 💡 Process the props before passing them to the underlying component
return <MUIButton {...props} className={cs(styles.Root, props.className)} />;
});

Benefits:

  • Clean API: By wrapping the components, you can restrict or extend the API of underlying components while maintaining a clean and consistent API for your consumers.
  • CSS bundle size: You can collocate your component's related styles by using CSS in JS or CSS modules. The main benefit here is that these styles will not be added globally (even when the components are not used on a page), which will improve your app's bundle size.
  • Library independence: By wrapping the components, you can switch or upgrade underlying libraries without breaking your consumers' experience.

Conclusion

In summary, the best way to structure your custom design system is start with creating a centralized package/directory that exports all necessary components. Some components might simply be re-exports from an underlying UI library, while others could be a wrapper components with altered APIs or styles.

Next, define your global overrides using CSS tokens, and clearly document how to inject them.

Lastly, ensure your design system documentation includes best practices, usage guidelines, and API references to make it as user-friendly as possible.

By following these best practices, you’ll be able to create a design system that’s scalable, maintainable, and flexible enough to grow with your product’s needs.

I hope you found this guide helpful. If you have any questions or want to dive deeper into any of the topics discussed, feel free to reach out to me on social media or via email.

Happy building and Merry Xmax! 🎄