Configuring Tailwind as a Design System

callumflack

Callum Flack

Posted on September 19, 2024

Configuring Tailwind as a Design System

For design systems, consistency and comprehension are everything. A good design system ensures consistency of implementation through the configuration of code that implements it. It needs to be:

  • easy to comprehend without foregoing the nuance that good design requires;
  • scalable and maintainable without compromising consistency.

Using my default stack of React with Tailwind, I'll show you how setting your own defaults for typography, colour and spacing is not just the starting point for differentiating your app's look and feel. More importantly, it drastically cuts down the code we have to write and maintain, which reduces the mental load of implementing styles in a systematic, consistent and error-free way.

I'll start with a major criticism that I see all the time, and then breakdown a series of configuration steps that I use to solve it.

Ease of use does not equate to ease of knowledge

Tailwind makes it easy for developers to write styles, which is great for rapid prototyping. But that ease doesn't guarantee good design or a scalable, maintainable design system.

Defaults and zero-config tools like Tailwind are the infrastructure pace layer that create more time for building. But if you're scaling an app that uses a design system to differentiate itself, you can't rely solely on "free as in lunch" out-of-the-box configs.

If you run with the default Tailwind config and push style management to the application of classes on components, the result is often a mess of hard-to-reason-about classes spread across components, masquerading as a design system.

Image description

Above is a prime example. It is almost illegible and takes signficant time to understand, let alone manipulate. Attempts to do so are highly likely to lead to duplication and error, spiralling away from design consistency throughout the app.

It is easy to smush your design classes into a single className. But there is no ease of knowledge in doing so.

Configure your system for ease of knowledge

Ease of use comes with trade-offs. Using someone else's standard means relying on their knowhow. This can be beneficial, but it can also be a trap. Let's take a step back and think about what the basics of a design system consist of:

  • typography
  • colour
  • spacing
  • responsiveness (which includes color mode)

In the context of React with Tailwind, these and many other design system elements are set in the Tailwind config, which we can customise.

{/* prettier-ignore */}

const config = {
  theme: {
    fontSize: { /* ... */ },
    colors: { /* ... */ },
    spacing: { /* ... */ },
  },
};
Enter fullscreen mode Exit fullscreen mode

Typographic defaults

Have you ever struggled to remember the correct letter-spacing for your small text? What if you could set it once and forget about it?

We can set leading (line-height) and tracking (letter-spacing) as parameters for each font size tuple directly in tailwind.config. This means we don't need to set leading or tracking when we use a font-size class. No need to remember (or fail to look up) what the letter-spacing of small text is.

fontSize: {
  small: [
    "13px",
    { lineHeight: 1.5, letterSpacing: "0.015em" },
  ],
  base: [
    "16px",
    { lineHeight: 1.5, letterSpacing: 0 },
  ],
}
Enter fullscreen mode Exit fullscreen mode

Using text-small now sets font-size, line-height and letter-spacing. Enclosing the core typographic tuple together in one class centralises the implementation of these values into the config instead of across a codebase. A huge win for maintainability.

/* 13px/1.5 with 0.015em letter-spacing */
<div className="text-small" />
Enter fullscreen mode Exit fullscreen mode

Color defaults

We can use CSS variables to set responsive colours under :root and html.dark scopes. This means we write and manage one class, such as bg-canvas, instead of two, such as bg-gray-100 dark:bg-gray-800.

@import "@radix-ui/colors/gray.css";
@import "@radix-ui/colors/gray-dark.css";

:root {
  --color-gray-base: var(--gray-1);
  --color-gray-bg: var(--gray-3);
  --color-gray-line: var(--gray-4);
  --color-gray-border: var(--gray-5);
  --color-gray-solid: var(--gray-10);
  --color-gray-fill: var(--gray-12);
}
Enter fullscreen mode Exit fullscreen mode

Because I'm using Radix Colors here, I don't need to set the .dark scope as that's already done for me. If you don't like the Radix colors, you can customise them, use another library or write your own.

Then set the CSS variables in the Tailwind config.

colors: {
  canvas: "var(--color-gray-base)",
  background: "var(--color-gray-bg)",
  line: "var(--color-gray-line)",
  border: "var(--color-gray-border)",
  solid: "var(--color-gray-solid)",
  fill: "var(--color-gray-fill-contrast)",
}
Enter fullscreen mode Exit fullscreen mode

Using bg-canvas now sets the appropriate color in light or dark mode. Removing this duplication across a codebase centralises color management to our config instead of spreading it across the implementation of classes on components. A huge win for cognitiion and maintainability.

/* sets --gray-1 as #fcfcfc on :root or #111111 on html.dark */
<div className="bg-canvas" />
Enter fullscreen mode Exit fullscreen mode

Semantic naming

I advocate semantic names for colours and font-sizes because semantic naming is a forcing function that ties meaning to use. Doing so removes implementation guess work and reduces error.

I've seen countless projects where inconsistent gray-50, gray-100 or gray-200 are all used for backgrounds. This is easily solved by defining a color called background.

In the same way, it is easier to remember the names for dark and light text colors when they are called fill and solid. It's harder and more error-prone when they're called gray-900 and gray-600 because then you have to remember specifically that it wasn't gray-950 and gray-500, or gray-800 and gray-700.

But naming things—and agreeing on naming—is hard. In the spirit of zero-config, I suggest taking Radix Color's backgrounds, borders, solids & fills paradigm. Or this palette semantics.

And once you've set this in tailwind.config, Typescript will jog your memory at your fingertips with autocomplete.

Avoid namespace clashes

If you're extending a Tailwind theme and not writing your own, don't use a scale key that's already been used. You may inadvertently overwrite a class that you need to use.

You'll note in the previous colour config example that I set the --color-gray-base var to canvas, not base. If I used base then using this color scale as a text colour (text-base) would clash with the default font-size base value, which is also text-base.

This isn't a downfall of customising the Tailwind config, it's a legacy of its theme naming: setting font-size or color classes in Tailwind both use text-*.1

Spacing defaults

We can also use CSS variables to set spacings.

:root {
  --height-nav: 80px;
  --height-tab: 54px;
  --space-inset: 20px;
  --container-text-px: 660px;
  --container-hero-px: 1000px;
}
Enter fullscreen mode Exit fullscreen mode
spacing: {
  em: "1em", /* relate icon size to parent font-size */
  nav: "var(--height-nav)",
  inset: "var(--space-inset)",
  text: "var(--container-text)",
  hero: "var(--container-hero)",
}
Enter fullscreen mode Exit fullscreen mode

One could argue this is over-engineering. Except that when it comes time to compute complex interactive layouts like sticky headers, scroll margins and so on, this upfront configuration work makes it straight forward and error-free, to the pixel.

<div className="top-[calc(theme(spacing.nav)+theme(spacing.tab))]">
  <div className="scroll-mt-[calc(theme(spacing.nav)+theme(spacing.tab))]">
    /* ... */
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

Note again the use of semantic naming makes it easy to remember and use.

Augmenting your Tailwind config

We have now configured typography, colour and spacing tokens in a manner that is easy to understand and maintain in a single, centralised place. And we don't need to wrire as many classes to implement the system. Winning. And there's further steps we can take to reduce this implementation overhead.

Clamp() your classes

What if I told you there's a way to completely avoid writing text-lg lg:text-xl xl:text-2xl p-2 md:p-4 lg:p-8 everywhere?

We can avoid setting responsive font-size classes by using clamp as a a font-size value in tailwind.config. Here's the simple clamp function I use.

fontSize: {
  title: [
    /* clamp(17px, 14.1429px + 0.5714vw, 21px) */
    generateClampSize(500, 1200, 17, 21),
    { lineHeight: 1.5, letterSpacing: "-0.015em" },
  ];
}
Enter fullscreen mode Exit fullscreen mode

So instead of writing text-lg lg:text-xl xl:text-2xl we can just write text-title. Once again, by hoisting font-size responsiveness into a clamp value, we avoid the "implement classes" pitfall again, saving mental effort, errors and debugging time.

Keep in mind, this means we've moved from text-lg lg:text-xl xl:text-2xl leading-none tracking-wide to text-title by properly configuring Tailwind. Winning!

/* 17px at 500px, 21px at 1200, fluidly calculated inbetween */
/* …with default line-height and letter-spacing also specified */
<h2 className="text-title">
  Heading copy
</h2>
Enter fullscreen mode Exit fullscreen mode

We can also do this for spacing. When extending a theme, I prefix these keys with d for "dynamic" to differentiate it from the default spacing scale.

spacing: {
  /* lower value is 2/3 of upper value */
  d4: generateClampSize(500, 1200, 10.5, 16),
  d8: generateClampSize(500, 1200, 21, 32),
  d16: generateClampSize(500, 1200, 43, 64),
  d24: generateClampSize(500, 1200, 64, 96),
  d64: generateClampSize(500, 1200, 171, 256),
}
Enter fullscreen mode Exit fullscreen mode

This allows us to write py-d24 instead of py-16 md:py-20 lg:py-24. This alleviates the weight of holding a range of website versions for each media-query in our minds. Instead it encourages us to picture fluidly responsive layouts where measurements don't matter as much as consistent relationships.

<main className="pt-d24 pb-d64 space-y-w8">
  <header className="container max-w-hero space-y-1">
    /* ... */
  </header>
  <article className="container space-y-2">
    /* ... */
  </article>
</main>
Enter fullscreen mode Exit fullscreen mode

Summary

Well-crafted UI is your last defense against the coming slopwave of careless AI apps. Here's how customizing Tailwind can save you time and headaches so you can focus on the irrational amount of care it takes to build UI that works in the blink of an eye:

  • Use tailwind.config to its full potential. Centralize and group your design tokens and avoid the "implement classes everywhere" trap.
  • Use clamp() for fluid typography and spacing.
  • Set color variables on :root and .dark for effortless dark mode.
  • Name colors and spacing semantically: background beats gray-100 any day.
  • Relate icons to text size with size-em.

Yes, there's an upfront time cost. But it pays off in spades: less code, fewer errors, greater design consistency, and a team that actually understands the system.

Next up: We'll explore how to use Class Variance Authority to create a bulletproof styling API with semantic props drawn from Tailwind. Stay tuned.


  1. This is also why I dislike using tailwind-merge to remove duplicate Tailwind classes in JSX. More often than not, I find it removing a text-color in favour of a text-fontSize when both are needed. I'm surprised more developers don't raise this issue. 

💖 💪 🙅 🚩
callumflack
Callum Flack

Posted on September 19, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related