DEV Community

Cover image for Use Tailwind CSS prefixes for shared design system components
Kevin Yank for Culture Amp

Posted on • Originally published at kevinyank.com

Use Tailwind CSS prefixes for shared design system components

Photo by Khamkéo Vilaysing on Unsplash

Surprisingly little has been written about how to use Tailwind with design systems or shared components, when both those components and the app consuming them are styled with Tailwind. Tailwind's prefix option is specifically designed to allow for this, but it's a somewhat ugly solution that we did our best to avoid at Culture Amp until recently. Here's everything we learned, and why we're ultimately embracing prefix.

A very brief intro to Tailwind

Tailwind CSS (Tailwind for short) is a not-uncontroversial choice of tool for projects where hand-crafting CSS selectors that apply to semantically meaningful patterns of HTML elements is not beneficial. We have decided to use it at Culture Amp, the reasons for which deserve an article of their own. At the risk of oversimplifying: if the structure of your UI matches the structure of your codebase (i.e. visual blocks tend to correspond to software components rather than sections of a document), you might save time and effort by applying styles to your elements directly in those components, rather than by writing CSS selectors that are tightly coupled to those components' implementation. Again, there's a lot more to say here, and I'll try to write it up soon. Let me know if you're keen to read it.

Fundamentally, Tailwind scans your application's source code for class names like this:

<div class="m-0"></div>
Enter fullscreen mode Exit fullscreen mode

…and generates a stylesheet that contains only the necessary styles to match the classes you've used:

.m-0 {
  margin: 0px;
}
Enter fullscreen mode Exit fullscreen mode

While at first glance this looks like it would have all the downsides of inline styles (the style attribute), Tailwind has designed a remarkably robust language of class names that can cover just about any selector, property, or value you need. Inline styles can't do pseudo-class selectors or media queries, but Tailwind can:

<div class="dark:hover:bg-sky-500/25"></div>
Enter fullscreen mode Exit fullscreen mode
@media (prefers-color-scheme: dark) {
  .dark\:hover\:bg-sky-500\/25:hover {
    background-color: rgb(14 165 233 / 0.25);
  }
}
Enter fullscreen mode Exit fullscreen mode

In a project that's a good fit for Tailwind (see above), this virtually eliminates the need to write CSS code that would mostly contain selectors that match a single element – an unnecessary abstraction.

Styling shared components

For the purposes of this article, shared components are user interface elements that are used in more than one web application. In Culture Amp's case this includes the React components in our Kaizen design system.

When you want to use Tailwind to style both an application's non-shared components, and the shared components that application uses, you have a decision to make: Will you publish your shared components with compiled CSS, or do you expect the application's build to run the Tailwind compiler over the shared components' source code to generate those styles as well?

For many years, Culture Amp took the second option, and distributed shared components without compiled CSS. This meant that every app that consumed shared components needed to include the necessary CSS build tooling – at that time CSS Modules and node-sass – with a compatible version and configuration. This was relatively easy to set up, but over time proved difficult to maintain. When node-sass was deprecated in favour of (the much faster but slightly incompatible) Dart Sass, this demanded a difficult lock-step migration across all those codebases, which we have yet to achieve. And as new applications have switched to Tailwind for their own styles, they've had to continue to maintain those old build tools in parallel for the shared components' styles.

To avoid this coupling between the source code of shared components and the build tooling of the applications that consume them, we now want to go the other way: have our shared components build their styles in their own build pipelines and publish components with plain CSS. That way, the CSS build tooling of our applications can stay decoupled from the CSS build tooling of our component libraries: a Sass-styled application can consume a Tailwind-styled component without having to run Tailwind, and a Tailwind-styled application can consume a Sass-styled component without having to run Sass.

But what happens when a Tailwind-styled application consumes Tailwind-styled components? How do the Tailwind-generated styles of shared components co-exist with the Tailwind-generated styles of the application?

The naive approach

At first, it would seem that it's safe to combine two Tailwind-built stylesheets into one, if slightly inefficient. Let's say your component uses m-0:

<div class="m-0">This is a shared component</div>
Enter fullscreen mode Exit fullscreen mode
.m-0 {
  margin: 0px;
}
Enter fullscreen mode Exit fullscreen mode

And then your application also uses m-0:

<div class="m-0">This is my application.</div>
Enter fullscreen mode Exit fullscreen mode
.m-0 {
  margin: 0px;
}
Enter fullscreen mode Exit fullscreen mode

When you use the shared component in your application, you get something like this:

<div class="m-0">This is my application.</div>
<div class="m-0">This is a shared component</div>
Enter fullscreen mode Exit fullscreen mode
/* shared component styles */
.m-0 {
  margin: 0px;
}
/* application styles */
.m-0 {
  margin: 0px;
}
Enter fullscreen mode Exit fullscreen mode

.m-0 is defined twice in your stylesheet, which is the slightly wasteful part, but the two rules apply the exact same styles, so everything still works.

While the duplicate CSS output is unfortunate, Tailwind-generated stylesheets tend to be incredibly small compared to hand-coded CSS, so this bloat is likely negligible. Almost every element in the document that needs its margin set to zero has that done with this one (duplicated) CSS rule, rather than traditional CSS where many, many different rules might specify margin: 0px separately. If you really wanted to avoid this duplicate output, a PostCSS plug-in like postcss-discard-duplicates could do this for you.

The small problem: Configuration coupling

A much bigger downside than the output duplication, however, is the coupling this creates between your Tailwind configurations. If someday you upgrade or alter the configuration of Tailwind in either project (your shared components or your application), and the two outputs do not match, one set of styles will overwrite the other.

/* shared component styles */
.bg-sky-500 {
  background-color: #0ea5e9; /* new value */
}
/* application styles */
.bg-sky-500 {
  background-color: #87cefa; /* old value */
}
Enter fullscreen mode Exit fullscreen mode

In this example, because our combined CSS output includes the shared components' CSS first and the application's CSS second, the application styles "win" (because the CSS cascade applies rules of equal specificity in source code order).

Adding to the confusion is the fact that Tailwind, remember, only generates styles for class names that you use, so if your application uses some of the classes that your shared components use but not others, you can end up with an unholy mix of the two configurations applied to your application:

/* shared component styles */
.bg-red-500 {
  background-color: #ef4444; /* new value */
}
.bg-sky-500 {
  background-color: #0ea5e9; /* new value */
}
/* application styles */
.bg-sky-500 {
  background-color: #87cefa; /* old value */
}
Enter fullscreen mode Exit fullscreen mode

In the above example, your shared components will display with the old value for bg-sky-500, but the new value for bg-red-500, because that second background colour isn't used in the application's source code!

You can of course flip the order in which your two stylesheets are combined, putting the application's styles first:

/* application styles */
.bg-sky-500 {
  background-color: #87cefa; /* old value */
}
/* shared component styles */
.bg-red-500 {
  background-color: #ef4444; /* new value */
}
.bg-sky-500 {
  background-color: #0ea5e9; /* new value */
}
Enter fullscreen mode Exit fullscreen mode

…but this merely reverses the problem: now your application is being unexpectedly re-styled by the Tailwind configuration used to build your shared components' styles, except for any classes that are used only in your application, which will remain styled by the old configuration.

In short, if you want your styles to be applied consistently, you need to avoid breaking changes to your Tailwind configuration, or update the two packages in lock-step. This by itself may not be a dealbreaker if you expect your Tailwind configuration to be relatively stable. Tailwind itself is pretty careful about releasing breaking changes, so it's unlikely that having slightly different Tailwind versions in the shared components and the application will cause problems most of the time.

But it's hard to ignore the fact that we've ended up back in a situation where our applications are forced to match their build configuration to that assumed by our shared components.

The big problem: Tailwind depends on source order

There's actually a more subtle (and fatal) issue lurking here: Tailwind is designed with the assumption that it controls the source order of the rules it generates.

Let's consider again our zero-margin div:

<div class="m-0"></div>
Enter fullscreen mode Exit fullscreen mode
.m-0 {
  margin: 0px;
}
Enter fullscreen mode Exit fullscreen mode

margin is a shorthand property, which means it has the effect of setting margin-block-start (top), margin-inline-end (right), margin-block-end (bottom), and margin-inline-start (left).

In traditional CSS, you can follow a shorthand property like this with any specific properties you want to override, like adding a left margin:

.bottom-margin-only {
  margin: 0px;
  margin-inline-start: 1rem;
}
Enter fullscreen mode Exit fullscreen mode

The order of the property declarations in the above is significant: if you swapped them so that margin-block-end was set first, the margin declaration would override it.

In Tailwind, you can likewise override a shorthand class like m-0 with a specific class like ms-4:

<div class="m-0 ms-4"></div>
Enter fullscreen mode Exit fullscreen mode
.m-0 {
  margin: 0px;
}
.ms-4 {
  margin-inline-start: 1rem;
}
Enter fullscreen mode Exit fullscreen mode

But the critical thing to observe here is that the order of the class names in the HTML is not significant: only the order of the generated CSS rules is.

If we swapped the order of the class names in the HTML:

<div class="m-0 ms-4"></div>
Enter fullscreen mode Exit fullscreen mode

…the left margin would still override the m-0, because Tailwind still generates the two CSS rules in the order that ensures the more fine-grained style (the left margin) is applied after the more coarse-grained style (the margin on all four sides):

.m-0 {
  margin: 0px;
}
.ms-4 {
  margin-inline-start: 1rem;
}
Enter fullscreen mode Exit fullscreen mode

Now, take this observation that the source order of rules generated by Tailwind is a critical feature of its design, and combine that with the issue we identified above of shared component styles clobbering application styles (or vice versa):

<div class="m-0">This is my application.</div>
<div class="m-0 ms-4">This is a shared component</div>
Enter fullscreen mode Exit fullscreen mode
/* shared component styles */
.m-0 {
  margin: 0px;
}
.ms-4 {
  margin-inline-start: 1rem;
}
/* application styles */
.m-0 {
  margin: 0px; /* ❌ overides .ms-4 */
}
Enter fullscreen mode Exit fullscreen mode

In this example, our shared components use m-0 and ms-4, but our application only uses m-0. The application's m-0 rule will override the value of margin-inline-start set by the ms-4 rule, and break the shared component's left margin!

Once again, you can solve this specific instance by swapping the order of the two generated stylesheets, but you'll just end up with the opposite problem: shared component styles interfering with application styles, in this example when the margin is on an application element.

<div class="m-0 ms-4">This is my application.</div>
<div class="m-0">This is a shared component</div>
Enter fullscreen mode Exit fullscreen mode
/* application styles */
.m-0 {
  margin: 0px;
}
.ms-4 {
  margin-inline-start: 1rem;
}
/* shared component styles */
.m-0 {
  margin: 0px; /* ❌ overides .ms-4 */
}
Enter fullscreen mode Exit fullscreen mode

If you're at all tempted to try doing without shorthand styles (e.g. avoid m-0 in favour of mt-0 me-0 mb-0 ms-0), Tailwind also depends on source order for modifiers like media queries:

<div class="m-0 md:m-4">This is my application</div>
<div class="m-0">This is a shared component</div>
Enter fullscreen mode Exit fullscreen mode
/* application styles */
.m-0 {
  margin: 0px;
}
@media (min-width: 768px) {
  .md\:m-4 {
    margin: 1rem;
  }
}
/* shared component styles */
.m-0 {
  margin: 0px; /* ❌ overides .md:m-4 */
}
Enter fullscreen mode Exit fullscreen mode

Here we're saying the first div should have a margin of zero by default, but on medium-sized screens or larger it should have a 1rem margin. These two rules have equal specificity (one class selector), so the fact that the media query style overrides the default style depends on source order. A second .m-0 rule at the end of the CSS from the shared component clobbers this margin.

Modifiers (dark:, hover:, etc.) are a core feature of Tailwind's styling language; there's no avoiding them, and as we've seen, they are broken by combining multiple Tailwind builds in a single CSS stylesheet.

How do we fix this?

Seductive non-solutions

There are a few approaches to solving this that seem like they could work nicely until you think them through. We'll look at each briefly to explain why they don't work.

!important

Out of the box, Tailwind lets you generate styles with !important that override competing styles in the cascade. class="!ms-4", for example, will output this:

.\!ms-4 {
  margin-inline-start: 1rem !important;
}
Enter fullscreen mode Exit fullscreen mode

First of all, !important is a very sharp knife, and is best avoided. For example, it interferes with inline styles applied to elements by JavaScript. But even if we ignored everything that's bad about !important, it doesn't actually solve the problem!

<div class="m-0">This is my application.</div>
<div class="m-0 !ms-4">This is a shared component</div>
Enter fullscreen mode Exit fullscreen mode
/* shared component styles */
.m-0 {
  margin: 0px;
}
.\!ms-4 {
  margin-inline-start: 1rem !important;
}
/* application styles */
.m-0 {
  margin: 0px;
}
Enter fullscreen mode Exit fullscreen mode

Yes, this prevents the application's m-0 rule from overriding the component's ms-4 rule. But to do this we had to modify our shared component with knowledge of the application that would be consuming it, which is an unhealthy coupling to internal implementation details that would be far from practical to maintain in a real-world ecosystem of shared components and applications.

What if we made all our component styles important? Tailwind even offers a configuration option for this:

<div class="m-0">This is my application.</div>
<div class="m-0 ms-4">This is a shared component</div>
Enter fullscreen mode Exit fullscreen mode
/* shared component styles */
.m-0 {
  margin: 0px !important;
}
.ms-4 {
  margin-inline-start: 1rem !important;
}
/* application styles */
.m-0 {
  margin: 0px;
}
Enter fullscreen mode Exit fullscreen mode

Well, this is just equivalent to putting our shared component styles at the end of the stylesheet: as already described above, the shared component styles will end up clobbering application styles in different circumstances:

<div class="m-0 ms-4">This is my application.</div>
<div class="m-0">This is a shared component</div>
Enter fullscreen mode Exit fullscreen mode
/* shared component styles */
.m-0 {
  margin: 0px !important; /* ❌ overrides .ms-4 */
}
/* application styles */
.m-0 {
  margin: 0px;
}
.ms-4 {
  margin-inline-start: 1rem;
}
Enter fullscreen mode Exit fullscreen mode

Boost specificity with important

Tailwind's important configuration option also lets you specify a selector for a top-level container element (like body or #app) to boost the specificity of the selectors it generates beyond the usual one-class specificity.

<div class="m-0">This is my application.</div>
<div class="m-0 ms-4">This is a shared component</div>
Enter fullscreen mode Exit fullscreen mode
/* shared component styles */
body :is(.m-0) {
  margin: 0px;
}
body :is(.ms-4) {
  margin-inline-start: 1rem;
}
/* application styles */
.m-0 {
  margin: 0px;
}
Enter fullscreen mode Exit fullscreen mode

Here, setting important to 'body' again appears to fix the problem because the shared component styles now consistently override the application styles. But just like with !important above, this only has the same effect as putting the component styles at the end of the stylesheet: shared component styles will clobber application styles instead.

<div class="m-0 ms-4">This is my application.</div>
<div class="m-0">This is a shared component</div>
Enter fullscreen mode Exit fullscreen mode
/* shared component styles */
body :is(.m-0) {
  margin: 0px; /* ❌ overrides .ms-4 */
}
/* application styles */
.m-0 {
  margin: 0px;
}
.ms-4 {
  margin-inline-start: 1rem;
}
Enter fullscreen mode Exit fullscreen mode

CSS cascade layers

A relatively new but now widely-supported addition to browsers, the @layer CSS at-rule lets you create groups of CSS rules and control the order in which they are applied to the page. So you could specify that your shared component styles be applied after your application styles:

<div class="m-0">This is my application.</div>
<div class="m-0 ms-4">This is a shared component</div>
Enter fullscreen mode Exit fullscreen mode
@layer application-styles, component-styles;

@layer component-styles {
  .m-0 {
    margin: 0px;
  }
  .ms-4 {
    margin-inline-start: 1rem;
  }
}

@layer application-styles {
  .m-0 {
    margin: 0px;
  }
}
Enter fullscreen mode Exit fullscreen mode

But once again – you guessed it – this is effectively just like moving the component styles to the bottom of the stylesheet: it creates the opposite problem of component styles interfering with application styles.

Discard duplicates

Remember at the start of this article when we noted that our shared components and our application could generate the same CSS rule, and that this would bloat our CSS output unnecessarily? I mentioned postcss-discard-duplicates could get rid of those duplicate rules.

<div class="m-0">This is my application.</div>
<div class="m-0 ms-4">This is a shared component</div>
Enter fullscreen mode Exit fullscreen mode
/* shared component styles */
.m-0 {
  margin: 0px;
}
.ms-4 {
  margin-inline-start: 1rem;
}
/* application styles */
Enter fullscreen mode Exit fullscreen mode

This looks promising, because the problematic style has entirely disappeared. But there is still a source order issue here, because the style that survives is the style that appears first in the source order, and that isn't always right.

With a slightly tweaked example where the duplicate style is the fine-grained style (ms-4), we run into the same problem:

<div class="m-2 ms-4">This is my application.</div>
<div class="m-0 ms-4">This is a shared component</div>
Enter fullscreen mode Exit fullscreen mode
/* shared component styles */
.m-0 {
  margin: 0px;
}
.ms-4 {
  margin-inline-start: 1rem;
}
/* application styles */
.m-2 {
  margin: 0.5rem; /* ❌ overrides .ms-4 */
}
Enter fullscreen mode Exit fullscreen mode

An opportunity: intelligent Tailwind output merging

The fundamental problem we face is that two Tailwind builds that use the same global namespace for their class names will inevitably interfere with each other because Tailwind is unable to place the generated CSS rules in the source order required for them the override each other in the correct sequence.

But what if we could?

It's not as if the correct order for the rules is mysterious. It's implemented in the Tailwind compiler. Tools like prettier-plugin-tailwindcss, which automatically sorts the class names in your HTML code to match the order in which Tailwind generates them in your CSS output, use a public API in Tailwind to get this order.

So what if we wrote a PostCSS plug-in that would take the output of two Tailwind builds and merge them together, removing duplicate styles and sorting the remaining styles into the correct order?

This seems like it could work, might even be a relatively straightforward project, and could even be worth Tailwind considering as a core feature. I've started a discussion about this on the Tailwind CSS GitHub project.

Even if we implemented this, we would still need to accept that our two Tailwind builds would need to have compatible versions and configurations for their merged outputs to work reliably.

The actual solution: prefix

As I mentioned in the previous section, the fundamental problem we face here is that Tailwind generates styles in a global namespace, and that two Tailwind builds therefore conflict in that namespace.

What if we could give our Tailwind builds separate namespaces? Our shared components would receive styles only from their generated CSS output, and our application elements would receive styles only from the application's Tailwind build output.

This is what Tailwind's prefix option is for. It lets you specify a short string of characters that you add to the start of all your Tailwind class names, to distinguish them from styles they need to coexist with (which in this case is another Tailwind-generated stylesheet).

For example, we could configure our Kaizen component library with a Tailwind prefix of kz-, and get this:

<div class="m-0">This is my application.</div>
<div class="kz-m-0 kz-ms-4">This is a shared component</div>
Enter fullscreen mode Exit fullscreen mode
/* shared component styles */
.kz-m-0 {
  margin: 0px;
}
.kz-ms-4 {
  margin-inline-start: 1rem;
}
/* application styles */
.m-0 {
  margin: 0px;
}
Enter fullscreen mode Exit fullscreen mode

The two elements' styles are completely separate in the CSS, because they use two completely different namespaces! You could swap the order of the two stylesheets, and it would make no difference. (We put our application styles last, because our design system components allow you to pass in class names to override their built-in styles, so those application class names need to come last in the stylesheet.)

This is such a clean and complete solution to this problem that you're probably wondering why this isn't a much shorter article.

The thing is, for those who have become used to Tailwind, the extreme terseness of its class names is one of its best features. It vastly reduces the number of keystrokes required for most styling tasks. Strings like m-0 become burned into your muscle memory, something you can type almost without thinking.

So if you tell an engineer who loves Tailwind that they need to remember to add three extra characters (kz-) to the start of all their class names when they're working in a library, and that different libraries will have different prefixes, you're probably going to ruin their day. And someone will have to make the very unpopular decision of who gets to have the default (unprefixed) namespace: your application codebases or your design system's component library. Avoiding this pain is why we tried everything else we could first.

But this really is the cleanest solution to the problem of enabling different Tailwind builds/versions/configurations to coexist in a single web page, and at Culture Amp we've only just learned to accept that.

Top comments (0)