Tailwind and the Cascade
What is Tailwinds relationship to Cascading Style Sheets and inline styles? What is it for and why do we use it?
Tailwind seems to be everywhere now. Along with React, it’s become part of the mainline industry standard for styling components. If we look at Tailwind – and styling more generally – from first principles, what can we learn about where and when it makes sense to use it as a tool?
Let’s look at three ways of writing CSS.
Markup
Here’s the markup for a button component from a comapanies design system component library:
<button >
<span></span>
<span
data-testid="button-children"
aria-hidden="false">
<span></span>
<span
data-testid="button-children"
aria-hidden="false">
Button
</span>
</span>
</button> Some questions here around:
- What are the empty spans for?
- Why is the button text nested in a span?
But let’s just assume that there are Good Reasons™ for this markup! Now how do we make it look good? Let’s focus only on the button node itself for the sake of terse sample code.
In each of our following examples, the style tag only needs to appear once on each page the component is used on, where the rest of the markup appears every time the component is used.
Tailwind
Tailwind’s approach is as follows:
- Create a configuration document that manages all your design tokens, changing arbitrary sizing, scales, and colors into a set of coherent and semantic design tokens.
- Generate a set of one-property CSS classes that correspond to the design tokens, as well as general property:value pairs.
- Stick lots of those classes on the DOM nodes.
At the end of the day it looks like this:
<style>
.disabled:bg-disabled-strong:disabled {}
.disabled:text-on-disabled-strong:disabled {}
.disabled:shadow-none:disabled {}
.disabled:cursor-not-allowed:disabled {}
.flex {}
.flex-row {}
.justify-center {}
.items-center {}
.text-center {}
.relative {}
.no-underline {}
.font-body {}
.font-semibold {}
.cursor-pointer {}
.rounded-full {}
.whitespace-nowrap {}
.opacity-100 {}
.duration-300 {}
.ease-in-out {}
.focus-visible:outline:focus-visible {}
.focus-visible:outline-4:focus-visible {}
.focus-visible:outline-offset-2:focus-visible {}
.focus-visible:outline-interactive-focused:focus-visible {}
.px-6 {}
.py-3 {}
.text-87.5 {}
.line-height-100 {}
.bg-button-primary {}
.text-on-primary {}
.hover:bg-button-primary-hover:hover {}
.active:bg-button-primary-activ:activee {}
</style>
<button
type="button"
class="
disabled:bg-disabled-strong
disabled:text-on-disabled-strong
disabled:shadow-none
disabled:cursor-not-allowed
flex
flex-row
justify-center
items-center
text-center
relative
no-underline
font-body
font-semibold
cursor-pointer
rounded-full
whitespace-nowrap
opacity-100
duration-300
ease-in-out
focus-visible:outline
focus-visible:outline-4
focus-visible:outline-offset-2
focus-visible:outline-interactive-focused
px-6
py-3
text-87.5
line-height-100
bg-button-primary
text-on-primary
hover:bg-button-primary-hover
active:bg-button-primary-active">
Button
</button> Inline Styles
Tailwind is basically inline styles. The same button with inline styles would look like this:
<style>
button:hover {
background-color: var(--button-hover-bg) !important;
}
button:focus-visible {
outline-style: var(--button-focus-visible-outline-style);
outline-width: var(--button-focus-visible-outline-width);
outline-offset: var(--button-focus-visible-outline-offset);
outline-color: var(--button-focus-visible-outline-color);
}
button:disabled {
cursor: var(--button-disabled-cursor) !important;
background-color: var(--button-disabled-bg) !important;
color: var(--button-disabled-color) !important;
box-shadow: var(--button-disabled-box-shadow) !important;
}
button:active {
background-color: var(--button-active-bg) !important;
}
</style>
<button
type="button"
style="
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
text-align: center;
position: relative;
text-decoration: none;
font-family: var(--font-family-body);
font-weight: var(--font-semibold);
cursor: pointer;
border-radius: var(--radius-full);
white-space: nowrap;
opacity: 1;
transition-duration: 300ms;
transition-timing-function: var(--transition-ease-in-out);
padding-block: var(--spacing-3);
padding-inline: var(--spacing-6);
font-size: var(--font-size-87-5);
line-height: var(--line-height-100);
background-color: var(--color-button-primary-bg);
color: var(--color-on-primary);
--button-hover-bg: var(--color-button-primary-bg-hover);
--button-active-bg: var(--color-button-primary-bg-active);
--button-disabled-bg: var(--color-disabled-strong);
--button-disabled-color: var(--color-on-disabled-strong);
--button-disabled-box-shadow: none;
--button-disabled-cursor: not-allowed;
--button-focus-visible-outline-style: solid;
--button-focus-visible-outline-width: 4px;
--button-focus-visible-outline-offset: 2px;
--button-focus-visible-outline-color: var(--color-interactive-focused);
">
Button
</button> Cascade Styles
Also known as “regular old CSS the way my pappy and his pappy before him wrote it”. To keep things conceptually aligned with the other two examples, we’re putting all of the properties into a fairly precise selector. In practice, unless the use case of “we want raw, un-styled buttons” is necessary we can send some of the properties up the cascade to apply to all buttons or several button variants, not just this one button variant.
<style type="text/css">
button[data-variant="default"] {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
text-align: center;
position: relative;
text-decoration: none;
font-family: var(--font-family-body);
font-weight: var(--font-semibold);
cursor: pointer;
border-radius: var(--radius-full);
white-space: nowrap;
opacity: 1;
transition-duration: 300ms;
transition-timing-function: var(--transition-ease-in-out);
padding-block: var(--spacing-3);
padding-inline: var(--spacing-6);
font-size: var(--font-size-87-5);
line-height: var(--line-height-100);
background-color: var(--color-button-primary-bg);
color: var(--color-on-primary);
}
button[data-variant="default"]:hover {
background-color: var(--color-button-primary-bg-hover);
}
button[data-variant="default"]:focus-visible {
outline-style: solid;
outline-width: 4px;
outline-offset: 2px;
outline-color: var(--color-interactive-focused);
}
button[data-variant="default"]:active {
background-color: var(--color-button-primary-bg-active);
}
button[data-variant="default"]:disabled {
cursor: not-allowed;
background-color: var(--color-disabled-strong);
color: var(--color-on-disabled-strong);
box-shadow: none;
}
</style>
<button
data-variant="default">
Button
</button> Analysis
Why Did We Stop Inlining Styles?
Inline styles have a couple of very key problems that led to the cascade selecting mode of writing CSS.
- They are very, very precise. An inline style cannot be overwritten from another stylesheet without using the
!importanttag on those property statements. This added a lot of complexity when mixing selector styles with inline styles, as the mental model of “what rule applies when” gets more complex with more exceptions. Abandoning inline styles allowed us to consolidate our mental model of specificity into a more linear cascade. - They create design drift. When making any changes to the properties inline, you have to find every instance of that property across the entire project and update them. If you miss one, you start to introduce inconsistencies and drift in your design language.
- Concerns all together. Doing styling directly in markup blends two rather different modes of thinking and intents and goals. We’re thinking of content right along side presentation. Separating concerns means that when we think of content, we just think of the markup we need to express the what of the component. When we think presentation, we can focus on the way it’s rendered. A component can just declare its purpose, and elsewhere we can decide how to best present that purpose.
- They are very verbose. Declaring the same style over and over and over again for every single identical component has a real cost, and that cost is the amount of data sent over the wire. Back when we started moving away from inline styles in 1999 and 2000, this was a big driver of that.
How Tailwind Improves on Inline Styles
Tailwind has some advantages over inline styles, to be sure. A big one is that element state selectors can’t be done inline. You can see where the inline components have to declare a property in the parent stylesheet, and then set the value inline. This means the inline styles still need to route some properties through a style selector delivered in a stylesheet.
Tailwind keeps the exact same paradigm, but routes every property through a style selector delivered in a stylesheet. This means there’s a single paradigm for managing styles. This also addresses the first point about overly-precise selectors – the Tailwind stylesheet is thoughtfully arranged to provide a good default specificity for each rule, and allows for regular cascading. It’s easy to over-ride a tailwind style, although the nature of atomic classes means it’s unlikely you’ll need to.
This act of routing every possible property through a style selector in a stylesheet does add additional complexity to Tailwind – now you need to tree shake your CSS. The default Tailwind stylesheet can be enormous, basically delivering all possible CSS to the client. Without the additional step of paring your stylesheets down to only deliver the classes that actually appear in the DOM, the solution is significantly worse than inline styles. Tailwind fails to improve on the 4th problem of inline styles, and in some cases can make bundle size significantly worse.
Tailwind fails to address the issue of design drift that inline styling introduces. As a utility-first - that is, exception first – framework this is by design. Tailwind is intended to allow for highly granular variations across similar components, giving the developer a surface area for changing any given attribute. This means that there’s by design no goal of linking the concept of disabled:bg-disabled-strong disabled:text-on-disabled-strong disabled:shadow-none disabled:cursor-not-allowed flex flex-row justify-center items-center text-center relative no-underline font-body font-semibold cursor-pointerrounded-full whitespace-nowrap opacity-100 duration-300 ease-in-out focus-visible:outline focus-visible:outline-4 focus-visible:outline-offset-2 focus-visible:outline-interactive-focused px-6 py-3 text-87.5 line-height-100 bg-button-primary text-on-primary hover:bg-button-primary-hover active:bg-button-primary-active to the concepts of sdefault regular button. This means we shouldn’t be surprised when we start seeing buttons that look mostly like regular default buttons, with with different padding or line height or text colors. If this is good or bad is a matter of your use case and goals, but it’s certainly no different than inline styles. The component model of development somewhat mitigates this – if we can write a <Button> component in our templating framework of choice, that component will get the same markup every time. But then what advantage is Tailwind bringing us within then scope of the component? More on this later.
Tailwind also remains largely the same as inline styles when it comes to separation of concerns. Projects that use tailwind tend not to worry so much about separation of concerns, because tailwind projects are often also written with big JavaScript frameworks. This means everything is handled in a .js file. Content, presentation, business logic, data, schema – a single component co-locates it all. Simply put, tailwind is simply not worried about this.
Why Write Cascading Style Sheets?
As we saw above, we moved to cascading style sheets over inline styles in order to create a system for defining and maintaining cohesive and universal style defaults in an efficient way. CSS improves over inline styles by 1. Creating polite styles that are there by default but easy to override, 2. Collecting sets of styles together into conceptual bundles to make them easier to maintain, 3. Get out of the way of the content of the markup, letting HTML be about structure and functional communication, and 4. do all this while reducing bundle sizes and improving performance. A well-written set of stylesheets is remarkably effective at providing coherent, cohesive, and resilient designs with very little, very maintainable code.
The key problem with this approach is predicated on an axiom tho – that we have coherent conceptual integrity when it comes to bundling a set of styles into something we call a regular default button. Buttons are an easy example, but things can get very complicated very quickly in terms of of naming what parts of our design are what and why. Then there’s a balance required in when to apply those concepts to new patterns versus separate them. This is classic DRY, abstraction layer, and naming things challenges that arise in any programing language. Doing this well, within the specific context of CSS and Design, is difficult. It requires relying on and listening to specialists in what we now call the “front-of-the-front-end”.
Why did we move away from Regular Old CSS?
Without being maintained by these specialist programmers, lots and projects stylesheets devolved into chaos, tangled structures, and bizarre repetitions and bloat. Paul Pederson records this real-world example of a .css file pulled from a production environment:
.role-stat h1,
.role-stat h2,
.role-stat p,
.role-stat a,
.role-su h1,
.role-su h2,
.role-su p,
.role-su a {
color: #FFF;
}
.features-1 h1,
.features-1 h2,
.features-1 p,
.features-2 h1,
.features-2 h2,
.features-2 p,
.features-3 h1,
.features-3 h2,
.features-3 p {
color: #FFF;
}
.features-1 a,
.features-2 a,
.features-3 a {
color: #FFF;
text-decoration: underline;
}
.feature-4 h1,
.feature-4 h2,
.feature-4 p {
color: #FFF;
}
.feature-4 a,
.feature-4 a,
.feature-4 a {
color: #FFF;
text-decoration: underline;
} This … fails to deliver on the promise of css in every way. Asking software engineers who, as Paul says, “earned and use a computer science degree” to become specialized front-of-the-front engineers is going to have the same result as asking a front-of-the-front engineer to write embedded application code in C. It might work at the end of the day, but it’s gonna be slow going to get there, ugly once you do, and hard to maintain going forward. Spaghetti code JavaScript happened for the same reason – it’s easy and straightforward to bodge and copy-paste your way into the behavior you need, going step by reasonable step down the road to a codebase from hell.
The answer to the above blob of clearly absurd CSS is … simple atomic utility classes. Replace all that junk with two rules, text-white and text-underline. The markup needs a little extra presentation code, but it’s way better than whatever is happening above.
Tailwind is an answer to the same question that CSS-in-JS was an answer too – How Do We CSS At Scale? But what we forget is that the original pitch for CSS-in-JS what was meant by “Scale”:
… a codebase with hundreds of developers that are committing code everyday, and where most of them are not front-end developers.
Emphasis mine. Is this your project? Maybe it is! More likely tho, it isn’t. The code above is taken from a project that is closer to this, but even the team responsible for this project was perhaps two or three dozen engineers at most, tho it is absolutely true and very few of them where front-end engineers.
The Component Model & The Design System
So we have a clear problem at hand: we have projects where we are expecting back-end and javascript engineers to assemble and produce web front-ends. They are very, very good at many of the things this requires, and not very good at other things. Enter the component model and the design system.
Both of these tools allow us to create what is basically an SDK for our application user experience and visual design language. Building large complex applications from discrete and composable components lets us decompose the complexity of what were trying to do, and rely on functional and rock-solid elements that do what we need them to do. When we need a modal workflow, we can use our modal component and stop thinking about all the little questions and points of care that a modal needs to be really effective. Someone who’s great at that work does that work once, and the entire team gets to benefit from it. This is good!
The design system becomes the structure and framework which supports these components, and crucially, all the new bits that we need to write that don’t have preexisting components we can use. The design system means that the whole team can just write the functional business logic they need, and trust that it will be cohesive and coherent with the rest of the system. The design system becomes a default offering that is easy to adjust and grow upon and beyond.
What’s more, a design system tends to be built and maintained by exactly the “front-of-the-front” engineer who can write effective and powerful CSS, usually working closely with a dedicated designer. Often they are designers themselves. Tailwind is not helping this team where they need it. This team could be just as if not more effective writing inline styles on their system components. This team is able to use the advantages that cascading stylesheets provide over inline styles, as this team is has goals that the cascading stylesheets are aiming to accomplish.
In short, a Design System with a Component Library is trying to solve the same problems that Tailwind is trying to solve – how to create an SDK for non-specialist engineers to build web applications with.
While using Tailwind utility classes to define the style of your design system components works fine, it’s also avoiding the benefits of both tailwind and css. It’s just inline styles with additional tooling needs.
Por que no le dos?
Here’s the thing we saw above in our “y tho” bad CSS example – utility classes are an important and useful part of any design system. Allowing utility classes to over-ride default component styles is really helpful and really effective.
Tailwind is really good at generating these utility classes. The configuration documentation is good, sensible, and useful. Using build-time tools to generate these classes from a configuration document is much easier and more reliable and maintainable than writing them all by hand – this is one of the primary use cases behind SASS for example. Because it’s a utility-only framework, it’s really good at generating utility classes.
So why not use them? Design tokens are useful, css variables are useful, and having a build system that turns configuration into code built around these things is useful. Tailwind creates useful utility classes and custom property code. We can use these utilities as part of design systems, and allow them to be applied to component code. This is good and helpful, and we’ll all like avoiding the tarpit tangle of crap CSS.
But we can also write our default, cascading, coherent CSS for our components. As we saw above, using utility-only classes inside our component code doesn’t bring us any advantages over writing inline styles. Instead, we can use regular CSS to define our components, aligned with the conceptual integrity we already have in place from developing a component library in the first place. This components can accept utility class over-rides, meaning that our SDK surface area is identical for any consumer of the components.
This combination approach approach is how Andy Bell writes CSS, and he’s a huge advocate for front-of-the-front dedicated CSS to maximize the benefit of stylesheets.
With a dedicated design system team, a component model architecture, and tailwind configuration to utility structures, it’s a straightforward path to start bundling styles together into discrete concepts. One of the big benefits of this approach, when seen from the perspective of our goal of providing an SDK for engineers to build with, is a radically simpler and more coherent API layer to access our design language. There’s less to memorize, and less chance of design drift. We can lay a solid foundation for the application, keep it maintainable, and make it easier to write as well as more efficient and performant by supplementing Tailwind with default, regular defaults for common design patterns and elements. A pile of utility classes defining font face, size, weight, color, line height, and letter spacing can be replaced with a single class for the idea of “primary-header”, and automatically applied to any <h1>, while still allowing for — but not requiring — fine grained adjustments.