Skip to content

Blog · Jun 4th, 2024 · 16 min read

Luke Bennett avatar Luke Bennett avatar

Luke Bennett

Building a Multi Brand Design System with Tailwind: Tips, Tricks and Tradeoffs

I recently wrapped up a project to help build a multi-brand design system. This article explores why we chose Tailwind CSS for styling and discusses the pros and cons of using it in this context.

Why we chose Tailwind

As a consultant, one of my goals when helping clients build any product is to pick a set of tools that suit its specific needs. These need to fit the applications they’ll be used in and match the skills of the people who will maintain them after I leave. In this project, we aimed to create a design system that supports multiple brands for a team with developers of all skill levels.

After carefully considering several different styling solutions, we decided on Tailwind for a few key reasons:

Lowering the barrier to entry

For styling design systems, I usually reach for Emotion. It’s powerful, flexible, and battle-tested, allowing me to define CSS styles directly in the same file as my JSX. This co-location of markup, functionality, and styles also fits really well with React’s component model.

Still, Emotion on its own doesn’t really help when it comes to using design tokens, which is something you use a lot in a design system! Developers need to create their own system for using these tokens. Usually, you need to call a hook to get the theme out of context and map the token to the CSS property every time. Tailwind makes this a lot easier because you pull in your design tokens in the Tailwind config file and do the mapping upfront. Then you have classes made available to you with the tokens baked in. There’s also no runtime cost for this, which is another win.

Tailwind also simplifies complex tasks like responsive styles and state-based styling with its variants syntax. In other systems, writing what would otherwise be the same styles in different contexts can lead to a lot of extra code. With Tailwind, you can style states like :hover, :focus, :active, and :disabled, as well as responsive states, and more with a simple prefix to your class.

Customising app-specific components

Applications and design systems typically move at very different paces. With a design system, everything is slower and more deliberate. You’re working on components that will be used in multiple applications by different teams, considering factors like API design, accessibility, and bundle size (just to name a few). On the other hand, application development usually moves much faster, focusing on validating ideas and testing with users to ensure functionality and user satisfaction.

By using Tailwind, we have a great API for styling components consistently with the design system since most of Tailwind’s classes are based on your design tokens. This gives application developers the freedom to use other libraries that provide the functionality they need without being blocked by the design system team’s pace. This is crucial in the early stages of rolling out the design system when there aren’t as many components available.

This flexibility lets teams validate their ideas and get user feedback much earlier in the process. Once these custom components have proven effective, they can be refined, standardised, and incorporated back into the design system. This ensures that the design system evolves with real-world use cases, making it more robust and versatile while still supporting rapid prototyping in the browser.

Simplifying the technology stack

The company we were consulting for maintained an ecosystem of multiple websites and apps, using a variety of front-end technologies. Since they were already moving towards Tailwind elsewhere, consolidation on this choice for the design system was an added benefit.

Using Tailwind for styling in the design system reduced the need for extensive documentation. For example, if a developer had previous experience using Tailwind on one of the marketing sites, they would find it easy to adapt to styling components in the design system. This familiarity ensures they can quickly become productive and feel more at home in a new codebase.

Challenges of adopting Tailwind in a design system

Problem: Full class string must appear in markup

With CSS-in-JS libraries like Emotion, the CSS is generated at runtime, allowing for dynamic styles. You can use JavaScript libraries to do things like tint or shade colours, create complex responsive grid systems, and more.

Tailwind’s JIT compiler works differently. It scans all your template files for Tailwind classes to generate the required CSS, meaning the full class string needs to be present in the markup. Without it, your classes won’t produce the corresponding CSS.

To accommodate this limitation, you usually end up creating some pretty gnarly functions or lookup objects. In some cases, such as responsive props for layout components, there are just too many possible combinations for this to be a viable option.

Solution: Use classes instead of props for styling components

The solution is to do what we used to do — just use classes. This simplifies a lot of components since it offloads some of the work to the users. It doesn’t really degrade the developer experience either; it’s still fast and intuitive to style. If anything, it’s probably faster!

Problem: Loss of type safety and autocomplete

TypeScript provides valuable type safety and autocomplete, helping catch errors early and making refactoring much safer. With Tailwind, you lose some of these benefits. This can make it easier to overlook issues like typos in class names or classes that become invalid after making changes to your Tailwind config.

Solution (sort of): IntelliSense plugin

The IntelliSense plugin for VS Code helps mitigate this. It provides autocomplete, syntax highlighting, and linting for Tailwind classes. This means you can catch typos, see what each class does, and even get helpful hover previews showing the exact CSS that will be generated. While it doesn’t offer full type safety, it makes working with Tailwind much more manageable.

Problem: Overriding styles

With CSS-in-JS solutions like Emotion, overriding styles is easy because you’re working with objects and key/value pairs. To change the style, you can simply change the value of the CSS property. With Tailwind, you’re working with strings instead of objects, so you can’t just change a value directly. You might try using a different class that targets the same property, but there’s no guarantee the last class you apply will win, since specificity is based on the order of the classes in the stylesheet (which we don’t really control) and not the markup. Even if we did control the order of the classes, the point of Tailwind is that utility classes are as reusable as possible. Fixing a specificity issue in one place might break it in another, as you’re likely using the same class in multiple places.

Solution: Tailwind Merge

To handle style overrides in Tailwind, we use Tailwind Merge. This tool understands every Tailwind class and the properties they target. It checks for conflicts among the classes you provide and ensures that the correct styles are applied by removing conflicting classes and keeping the last one. This way, you can confidently override styles without manually managing conflicts.

Problem: One-off styles can’t be authored in the markup like your regular Tailwind classes

Every project has a few “one-off” styling exceptions that need special treatment. In earlier versions of Tailwind, handling these exceptions meant writing regular CSS elsewhere in your codebase, so your styles weren’t necessarily co-located with your markup anymore. Creating custom styles wasn’t easy and often required stepping outside the framework.

Solution: Arbitrary variants and arbitrary properties

Tailwind introduced arbitrary variants and arbitrary properties to address this issue. These features allow you to write custom styles directly in your markup using special syntax. This keeps your styles within the component, maintains the utility-first approach, and provides a flexible way to handle those unique styling needs without breaking the flow.

Even though the syntax might look a bit ugly, its distinctiveness is actually a benefit. It makes these classes easy to find and remove or replace if needed. By co-locating styles with your markup, you ensure that these custom styles can’t “leak out” of the component, keeping everything tidy and manageable.

Tailwind’s Evolution and Comparisons

As Tailwind has evolved, its utility has expanded across more projects. This evolution is similar to how GraphQL changed data querying in development.

Tailwind is like GraphQL for CSS

The JIT compiler and arbitrary values added in Tailwind 3 have broadened its practical use. As my boss Jed Watson aptly put it, “Tailwind is like GraphQL for CSS.”

In GraphQL, you have a schema, and in Tailwind, you have design tokens. GraphQL has a syntax to query for data, and in Tailwind, you query the JIT compiler for CSS by writing a class it recognises. Just like in GraphQL, where you never over-fetch data, in Tailwind, you only generate the CSS you need. This makes your styles more efficient and your development process more streamlined.

Getting the most out of Tailwind in your design system

Create a preset

Tailwind offers presets, which are pre-customised Tailwind configs. Using a preset simplifies setup and maintenance, as there’s less to change when updating core styles. By making the preset a function, you can pass in design tokens, meaning one preset can support multiple brands. This also cuts down on setup time and boilerplate code.

For example, here’s what the popular Tailwind-based component library shadcn/ui generates when you initialise a new project:

// tailwind.config.ts
import type { Config } from 'tailwindcss';

export default {
  darkMode: ['class'],
  content: [
    './src/**/*.{js,ts,jsx,tsx}',
    './node_modules/@acme-ds/core/dist/**/*.{js,ts,jsx,tsx}',
  ],
  prefix: '',
  theme: {
    container: {
      center: true,
      padding: '2rem',
      screens: {
        '2xl': '1400px',
      },
    },
    extend: {
      colors: {
        border: 'hsl(var(--border))',
        input: 'hsl(var(--input))',
        ring: 'hsl(var(--ring))',
        background: 'hsl(var(--background))',
        foreground: 'hsl(var(--foreground))',
        primary: {
          DEFAULT: 'hsl(var(--primary))',
          foreground: 'hsl(var(--primary-foreground))',
        },
        secondary: {
          DEFAULT: 'hsl(var(--secondary))',
          foreground: 'hsl(var(--secondary-foreground))',
        },
        destructive: {
          DEFAULT: 'hsl(var(--destructive))',
          foreground: 'hsl(var(--destructive-foreground))',
        },
        muted: {
          DEFAULT: 'hsl(var(--muted))',
          foreground: 'hsl(var(--muted-foreground))',
        },
        accent: {
          DEFAULT: 'hsl(var(--accent))',
          foreground: 'hsl(var(--accent-foreground))',
        },
        popover: {
          DEFAULT: 'hsl(var(--popover))',
          foreground: 'hsl(var(--popover-foreground))',
        },
        card: {
          DEFAULT: 'hsl(var(--card))',
          foreground: 'hsl(var(--card-foreground))',
        },
      },
      borderRadius: {
        lg: 'var(--radius)',
        md: 'calc(var(--radius) - 2px)',
        sm: 'calc(var(--radius) - 4px)',
      },
      keyframes: {
        'accordion-down': {
          from: { height: '0' },
          to: { height: 'var(--radix-accordion-content-height)' },
        },
        'accordion-up': {
          from: { height: 'var(--radix-accordion-content-height)' },
          to: { height: '0' },
        },
      },
      animation: {
        'accordion-down': 'accordion-down 0.2s ease-out',
        'accordion-up': 'accordion-up 0.2s ease-out',
      },
    },
  },
  plugins: [require('tailwindcss-animate')],
} satisfies Config;
/* tailwind.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  :root {
    --background: 0 0% 100%;
    --foreground: 222.2 84% 4.9%;

    --card: 0 0% 100%;
    --card-foreground: 222.2 84% 4.9%;

    --popover: 0 0% 100%;
    --popover-foreground: 222.2 84% 4.9%;

    --primary: 222.2 47.4% 11.2%;
    --primary-foreground: 210 40% 98%;

    --secondary: 210 40% 96.1%;
    --secondary-foreground: 222.2 47.4% 11.2%;

    --muted: 210 40% 96.1%;
    --muted-foreground: 215.4 16.3% 46.9%;

    --accent: 210 40% 96.1%;
    --accent-foreground: 222.2 47.4% 11.2%;

    --destructive: 0 84.2% 60.2%;
    --destructive-foreground: 210 40% 98%;

    --border: 214.3 31.8% 91.4%;
    --input: 214.3 31.8% 91.4%;
    --ring: 222.2 84% 4.9%;

    --radius: 0.5rem;
  }

  .dark {
    --background: 222.2 84% 4.9%;
    --foreground: 210 40% 98%;

    --card: 222.2 84% 4.9%;
    --card-foreground: 210 40% 98%;

    --popover: 222.2 84% 4.9%;
    --popover-foreground: 210 40% 98%;

    --primary: 210 40% 98%;
    --primary-foreground: 222.2 47.4% 11.2%;

    --secondary: 217.2 32.6% 17.5%;
    --secondary-foreground: 210 40% 98%;

    --muted: 217.2 32.6% 17.5%;
    --muted-foreground: 215 20.2% 65.1%;

    --accent: 217.2 32.6% 17.5%;
    --accent-foreground: 210 40% 98%;

    --destructive: 0 62.8% 30.6%;
    --destructive-foreground: 210 40% 98%;

    --border: 217.2 32.6% 17.5%;
    --input: 217.2 32.6% 17.5%;
    --ring: 212.7 26.8% 83.9%;
  }
}

@layer base {
  * {
    @apply border-border;
  }
  body {
    @apply bg-background text-foreground;
  }
}

Compared to just this with our preset:

// tailwind.config.ts
import { tokens } from '@acme-ds/brands/my-brand';
import { createPreset } from '@acme-ds/core/tailwind';
import { type Config } from 'tailwindcss';

export default {
  content: [
    './node_modules/@acme-ds/core/src/**/*.{js,ts,jsx,tsx}',
    './src/**/*.{js,ts,jsx,tsx}',
  ],
  presets: [createPreset(tokens)],
} satisfies Config;
/* tailwind.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

Customising Tailwind’s default theme

Tailwind’s default theme is extensive but not overly opinionated. This is great for websites and small apps where getting started quickly is important, but for a design system, you’ll likely have strong opinions on things like colours and typography, so turning off many of Tailwind’s defaults is probably something you’ll want to do.

While Tailwind lets you disable built-in plugins using the corePlugins key, it’s best to avoid this. We wanted developers already familiar with Tailwind to use our design system without having to learn a bunch of new conventions, so keeping the same prefixes was important. Also, turning off core plugins means losing features like arbitrary variants.

Instead, the best thing to do is to remove the defaults from the theme object. If you create your preset function, you can even add flags to turn features on or off. This is useful if you want to incrementally add the design system to an app that’s already using Tailwind.

export function createPreset(
  tokens: Tokens,
  {
    shouldExcludeBaseStyles = false,
    shouldUseTailwindBorderRadius = false,
    shouldUseTailwindBoxShadows = false,
    shouldUseTailwindColors = false,
    shouldUseTailwindFontSize = false,
    shouldUseTailwindFontWeight = false,
  }: CreatePresetOptions = {}
): Config {
  return {
    content: [],
    theme: {
      ...defaultTheme,
      boxShadow: shouldUseTailwindBoxShadows ? defaultTheme.boxShadow : {},
      boxShadowColor: shouldUseTailwindBoxShadows ? defaultTheme.boxShadowColor : {},
      fontSize: shouldUseTailwindFontSize ? defaultTheme.fontSize : {},
      fontWeight: shouldUseTailwindFontWeight ? defaultTheme.fontWeight : {},
      ...getBorderRadiusThemeConfig(tokens, { shouldUseTailwindBorderRadius }),
      ...getColorThemeConfig({ shouldUseTailwindColors }),
    },
    plugins: [
      // ...
    ],
  };
}

Making the most of custom plugins

Tailwind plugins are a great way to add new styles. Don’t be afraid to reach for them when you need them. You even get IntelliSense for plugins you create, so the developer experience is still excellent.

Tailwind styles are split into base, component, and utility layers. In Tailwind, the base layer is for resets, global styles, and custom properties. The component layer targets multiple CSS properties, and the utility layer (with its higher specificity) overrides properties from the previous layers. Tailwind’s plugin function gives you a callback for adding styles to a layer, e.g. addComponents for the component layer.

Here are some examples of useful plugins we created:

Typography plugin

We created a typography plugin where we kept the familiar text- prefix, turned off Tailwind’s defaults, and set up styles for font-family, font-size, font-weight, line-height, and letter-spacing together, for both regular text and headings. This gave us a robust set of styles and reduced the need for a <Text> component.

export function createTypographyPlugin(typography: GlobalTokens["typography"]) {
  return plugin(({ addComponents, addUtilities }) => {
    const headingUtilities = Object.fromEntries(
      Object.entries(typography.heading).map(([key, value]) => [
        `.text-heading-${key}`,
        {
          fontFamily: [
            typography.fontFamily.heading,
            typography.fontFamily.fallback,
          ].join(", "),
          fontSize: value.fontSize,
          fontWeight: value.fontWeight,
          lineHeight: value.lineHeight,
          letterSpacing: value.letterSpacing,
        },
      ])
    );

    const bodyUtilities = Object.fromEntries(
      Object.entries(typography.body).map(([key, value]) => [
        `.text-body-${key}`,
        {
          fontFamily: [
            typography.fontFamily.body,
            typography.fontFamily.fallback,
          ].join(", "),
          fontSize: value.fontSize,
          fontWeight: value.fontWeight,
          lineHeight: value.lineHeight,
          letterSpacing: value.letterSpacing,
        },
      ])
    );

    addComponents({
      ...headingUtilities,
      ...bodyUtilities,
    });

    addUtilities({
      ".font-regular": {
        fontWeight: typography.fontWeight.regular,
      },
      ".font-strong": {
        fontWeight: typography.fontWeight.strong,
      },
      ".font-stronger": {
        fontWeight: typography.fontWeight.stronger,
      },
    });
  });
}

Tip: Use custom properties for font weights to style them differently between brands. For example, you might want bold headings in one brand and lighter headings in another.

Colour plugin

Next, we developed a colour plugin to manage colour schemes. By using custom properties for our colours, users don’t need to write any extra code to support switching between light and dark modes. We use both a prefers-color-scheme media query and .light/.dark classes so you can let users of your app manually set their preferred colour scheme or have it automatically switch based on system settings.

export function createColorPlugin(
  colorSchemes: Tokens['colorSchemes'],
  { shouldExcludeBaseStyles }: { shouldExcludeBaseStyles: boolean }
) {
  return plugin(({ addBase }) => {
    if (!shouldExcludeBaseStyles) {
      const colors = getColors(colorSchemes);
      addBase({
        ':root': {
          colorScheme: 'light dark',
          ...colors.light,
        },
        '@media (prefers-color-scheme: dark)': {
          ':root': {
            colorScheme: 'dark',
            ...colors.dark,
          },
        },
        '.light': {
          colorScheme: 'light',
          ...colors.light,
        },
        '.dark': {
          colorScheme: 'dark',
          ...colors.dark,
        },
      });
    }
  });
}

Tip: Set a color-scheme along with dark/light mode classes and media queries so browser chrome elements like scrollbars and native selects match your design.

Focus ring plugin

We also created a focus ring plugin to ensure consistent focus styles across our components. This is a component class since it targets lots of CSS properties. It handles different states and improves accessibility by providing clear visual feedback for focusable elements.

export const focusRingPlugin = plugin(({ addComponents, theme }) => {
  addComponents({
    '.focus-ring': {
      outline: 'none',
      '--tw-ring-color': theme('borderColor.focus'),
      '--tw-ring-offset-color': theme('backgroundColor.neutral'),
      '--tw-ring-offset-width': theme('ringWidth.1'),
      '--tw-ring-offset-shadow':
        '0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)',
      '--tw-ring-shadow':
        '0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color)',
      'box-shadow':
        'var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 rgb(0 0 0 / 0))',
      '@media (-ms-high-contrast: active), (forced-colors: active)': {
        'outline-color': theme('outlineColor.transparent'),
        'outline-offset': theme('outlineOffset.2'),
        'outline-style': 'solid',
        'outline-width': theme('outlineWidth.2'),
      },
    },

    '.no-focus-ring': {
      outline: 'revert',
      'box-shadow': 'revert',
      '@media (-ms-high-contrast: active), (forced-colors: active)': {
        'outline-color': 'revert',
        'outline-offset': 'revert',
        'outline-style': 'revert',
        'outline-width': 'revert',
      },
    },
  });
});

Tip: Creating the .focus-ring class with Tailwind’s plugin system means that it works for all valid Tailwind variants, e.g., using it with has-[:focus]: for a radio card.

Should you pick Tailwind for your design system?

Like anything in web development, the answer is “it depends.” The decision to use Tailwind should be based on your project’s specific needs and your team’s preferences and capabilities.

Reasons not to choose Tailwind

  • Lower type safety is a deal-breaker ❌
  • Long strings of classes make you feel physically ill 🤮
  • You’re aiming for a design system that enforces strict uniformity 📏
  • The black magic that Tailwind Merge must be doing to know what every class does terrifies you 🧙
  • You prefer using props over juggling classes 🎛

Reasons to choose Tailwind

  • Great documentation and a vast amount of online resources 📚
  • Fastest developer experience for building custom UI ⚡
  • Variants make it easy to reuse tokens 🔄
  • Arbitrary variants provide an escape hatch for one-off styles that don’t leak outside of your components 🛡
  • No runtime cost 💸 (unless you’re using Tailwind Merge)

Additional resources

When we started building this design system, there weren’t many projects using Tailwind for this kind of work that we could reference. That’s not the case anymore. If you’re considering building a design system using Tailwind, I’d highly recommend checking out the following:

Conclusion

Overall, using Tailwind on this project was a great experience, and I think it’s a solid choice for almost any application. It’s also proving to be a dominant force in the CSS framework space, and I’m confident it will remain that way for some time to come. With version 4 just around the corner, it’s only going to get better!

That said, alternatives like Emotion remain strong contenders. There are also a ton of great new libraries popping up all the time. Some promising options worth exploring include StyleX, Panda CSS, Tokenami, CSS-Hooks, and pigment-css. Each of these libraries brings its own unique features and philosophies to the table, potentially addressing specific pain points or aligning better with your team’s preferences.

If you’re considering using Tailwind for your design system, I hope this article has given you some valuable insights and tips to help you make the most of it.

Services discussed

Technology discussed

  • Emotion,
  • Tailwind,
  • TypeScript,
  • CSS
A photo of Jed Watson & Boris Bozic together

We’d love to work with you

Have a chat with one of our co-founders, Jed or Boris, about how Thinkmill can support your organisation’s software ambitions.

Contact us