My first impression of tailwind was 🤮, css in html, not this again. What about separation of concerns! When I looked at examples they just looked like some sort of class soup. The first example has to be scrolled because the lines are so long! Utility functions are a nice idea I'd been toying with adopting, but surely this went to far?
I had looked into other css frameworks but it was easier to just roll my own than customize a utility framework so I didn't. I'd been toying with a theme management system / variable generator for apps, so using someone else's variables did not make sense. For a while I just used css with the variables my package produced to keep a consistent design.
Tailwind just seemed like another utility based css library that wouldn't benefit me very much.
But after seeing it praised again and again, I had to try it. The docs are not wrong about this.
I tried it on a complicated component library to really battle test it and I finally understood what all the praise was about.
It's not just the points you hear all the time. There were also some things that aren't obvious on first glance that I really liked:
- It's very easy to customize and make custom plugins.
- You can quickly use custom values when you need them (e.g.
border-[calc(...)]
). - Many utility functions do more than one thing and make it easy to do common things that tend to bloat css.
- Predictable application and overriding of styles (assuming the use of something like tailwind-merge).
It's hard to believe, but these all add up to make the class soup actually far exceed my previous scss in simplicity, readability, and maintainability. The link above shows this to some extent but I did not even see this until I actually decided to try tailwind. It really should be one of the first things you see.
Also you don't have to put it all in one line! Why is it like that in so many examples, 😭?
You can still write very readable styles if you want. And once you get a hang for the basic utility classes (this does not take as long as you'd think), the intent of the styles is far far clearer.
Here's my own example. Where before in a my component library might have css that looked something like this (and yes, I know the css could be simplified, but in exchange for verbosity elsewhere1):
.component {
padding: 0 var(--padding-s);
background: var(--color-bg-el) ;
&.border {
border: var(--border-width) solid var(--color-border);
&:focus { border-color: var(--color-border-focus) ;}
}
&:focus { color: var(--color-text-focus) ; }
&.disabled { background: var(--color-bg-disabled); }
&.disabled:focus { color: var(--color-text-disabled);}
&.border.disabled:focus { border-color: var(--color-border-focus) ;}
}
.wraper {
.component.disabled.border & {
text-decoration-line: line-through;
}
}
with html (vue) that looked like this:
<div
:class="classes"
>
<div class="wrapper"/>
</div>
Where classes
was a computed red in setup, often abstracted away to a function for common states:
const classes = computed(() => ({
disabled: props.disabled,
border: props.border
...
}))
Separation of concerns, right...
Now with tailwind, after a bit of experimenting and the help of tailwind merge I can have html (vue) that looks like this. Same number of css rules.
<div
:class="twMerge(`
p-3
bg-neutral-50
focus:text-blue-500
`,
border && `
border
border-neutral-900
focus:border-blue-500
`,
disabled && `
bg-neutral-300
focus:text-neutral-500
`,
border && disabled && `
border-neutral-500
`)"
>
<div :class="disabled && `line-through`"><div>
</div>
And that's it.
There is no more jumping to the js or the css to look things up.
All styles will apply predictably. It's clear what's happening, which classes are being applied when. With the help of tailwind merge we can rely on order and use variables like in the example to avoid specificity issues.*.
Just a breath of fresh air.
Even with one style per line for readability I have managed to shrink the file size of ALL the components I converted.
I only now have two sections to worry about now, the logic, and the presentation. I've come to understand that presentation is invariably linked to state, and separating the css actually always makes things more painful. The creator of tailwind actually mentions this in the blog post where he made the arguments for tailwind on it's release, and I had read this before, but it never sunk in how painful this was until I wasn't experiencing the pain anymore.
I was actually surprised how much faster I could style things (~3x) even when I had to have the tailwind docs pinned to lookup stuff and my tailwind autocomplete was not working well2.
I've come to appreciate how easy it can do things that used to take multiple lines.
Take for example truncate
which does:
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
Or gradients! If there's one thing I hate, it's writing css gradients. But in tailwind it's super simple, even with complex stops and everything:
<div class="bg-gradient-to-r from-white to-black"/>
<div class="
bg-gradient-to-r
from-white from-10%
via-blue-500 via-20%
to-black to-50%
"/>
* Tailwind Merge
But, tailwind merge is a MUST for complex applications and component libraries to avoid specificity issues. The reason why is that while tailwind cannot escape the css selector specificity problem completely, in fact we loose a bit of control.
See in regular css, if I have two selectors with equal specificity, the last one will win (note that this helps avoid the mess of having many variants and states):
.input {
&.border:focus {...}
&.disabled:focus {...}
}
In tailwind, you DO NOT have control over the order. The order of the classes is irrelevant, BUT the order tailwind declares them in the stylesheets is and this cannot be controlled (since a single rule might be used in many components).
If you do this, depending on what colors you picked, the style might or might not apply on disabled:
<div
:class="
border && `
border
border-red-500` +
disabled && `
border-neutral-500
`"
>
</div>
Another thing I've seen done is using data attributes which looks cleaner and will seem to work at first, but this is because the data attributes are adding specificity to the selector, +1 for each data attribute.
Here's a specificity calculator you can use to see this.
Tailwind data attribute variants create the following selectors: {ESCAPED_NAME}:[data-border="true"]:[...]
you can input .class:[data]:[data]
to quickly test, it would be the same thing.
<div
:data-border="border"
:data-disabled="disabled"
class="
data-[border=true]:border
data-[border=true]:border-red-500
data-[border=true]:data-[disabled]:border-neutral-500
">
</div>
You can easily run into the following problem, where these have equal specificity (2):
<button
:data-primary="primary"
class="
data-[primary=true]:text-white
focus:text=blue-200
">
</button>
Or worse, here the text will never change on focus when the button is primary and has a border because that has a specificity of 3.
<button
:data-primary="primary"
:data-border="border"
class="
data-[border=true]:data-[primary=true]:text-white
focus:text-blue-200
">
</button>
At this point you must pick a solution. You can:
- Add an important
focus:!text-blue-200
which can make it harder for the end user of components to override the rule. - Add
data-[border=true]:data-[primary=true]:focus:text-blue-200
, but apart from creating hard to read css, this will create a lot of unneeded class soup in the html since the button would contain all styles for all variants even if you only end up using the one of them. - Use tailwind merge to bypass the problem.
If you pick tailwind-merge though, it is still a good idea to leave the data attributes, at least on the root of the components if you care about users being able to theme the app with css.
<button
:class="twMerge(`
p-3
bg-neutral-50
focus:text-blue-500
`,
border && `
border
border-neutral-900
focus:border-blue-500
`,
disabled && `
bg-neutral-300
focus:text-neutral-500
`,
border && disabled && `
border-neutral-500
`,
$attrs.class
)"
v-bind="{...$attrs, class: undefined }"
>
<div :class="disabled && `line-through`"><div>
</button>
The Downsides
There are, of course, always some downsides.
Inconsistent Naming
After some use, this is the one thing that really bugs me. You have py-*
for top+bottom padding but gap-y-*
for gaps? This happens in a few other places with units as well. You have spacing that goes down to -0.5
but opacity which in css is 0-1
goes from 0-100
???
I can understand not always using the same scales (e.g. sm
, md
, xl
vs 0-*
) but for some of the odder names, because the auto complete does not work based on the css property name, it can be very hard to find the utility you want. I have to keep the docs opened and pinned for actual searching. But I guess that could be considered more the autocomplete's fault, and not tailwind.
Missing Utilities
Some css properties that are now pretty usable, do not exist yet. For example, text-shadow does not exist because which default styles to add has not been decided yet.
Which fair, but I really wish they would at least they were provided as experimental properties or something instead of having to rely on other's plugins.
Also some which should really exist, don't, such as grid with auto-fill
and auto-fit
.
Hard to use Existing Tailwind Variables in Custom Utilities
I've written a few custom utilities for gradients, but couldn't for example, re-use the to/from-* gradient utilities and the css variables they create
because I can't seperate the part I need (the color). For example, from-black from-0%
will create:
-tw-gradient-from-position: 0%;
--tw-gradient-from: #000 var(--tw-gradient-from-position);
// which are then used like this:
--tw-gradient-stops: var(--tw-gradient-from);
I can use the position, but not the color, there is no --tw-gradient-from-color
, so I have to create my own which can get messy.
Dynamic values
The stylesheet is compiled at build time (so that unused styles can be removed), so this means you can't use dynamic styles. This will not work. You have to use an inline style tag or a script tag.
<div :class="`p-[${customValue}]`">
</div>
Conclusion
I've been using it now for a few months and I can never go back to the old way.
Have you given it a try? Did you like it or still hate it?
-
I know, I could apply all the classes to the wrapper as well or use some BEM naming scheme and simplify the css, but then in complex elements, they need to be attached/created for every element which might only use a single state class, which probably isn't an issue performance wise if the classes aren't changing every second, but just irks me. And conditionally giving them the needed classes is beyond painful so I often stuck with this. ↩
-
I had trouble getting it to work with vscode + vue. Also you must type the name exactly and this is hard given the inconsistent naming. ↩
Top comments (12)
Thanks for sharing your experience! That's one of the few pro-tailwind posts obviously written by someone who actually knows CSS. Helpful to mention the specificity vs. order issue. I have been using XSLT where you must never assume a specific execution order, forcing developers to write descriptive code ready to be split for parallel execution. But many developers are used to imperative code, even those who claim to follow a functional, object-oriented or whatever superior coding paradigm.
The only advantages that tailwind had in my opinion were both related to collaboration in a team. Even with people willing and able to write clean and modular styles, it's easy to make an unpredictable mess of possible side effects in vanilla CSS. And if there are team members who can't or don't care to code CSS properly, or who advocate to put CSS in JavaScript strings in their styled components, I'd rather have everybody use functional class names in their HTML / JSX. Using JS to style a UI feels like giving up most of CSS' advantages for no good reason.
Inconsistent naming and tooling: vanilla CSS is full of inconsistencies and so is every language that exceeds a strictly crafted domain context. And even then, projects tend to become inconsistent. Vanilla CSS, SCSS, and PostCSS have good stylelint support, but I'm not completely happy about stylelint's recommended rules and its false positives and negatives.
Vanilla custom properties (CSS variables) have some limitations independent of the frameworks that uses them, and gradients have had a very tricky syntax from the start. If that's your greatest issue when extending tailwind, you're a lucky dev!
I use tailwind on a couple of work projects and a couple of pet astro/next projects.
I do this because (for work) it's a requirement and (for pet projects) it's helping me learn.
I've been doing this for over a year, yet I remain steadfast: Tailwind is a blight on the developer landscape. Nothing it does could not be done better without utility classes and div soup!
I'm curious as to how you do your css that you find it better. How do you manage components with lots of complex state, etc?
I still "title" divs usually because otherwise it's hard to find things.
Wherever I have a choice, I don't use styles as part of the component. Coupling styles with components means if I do something like move a component from one area of the site to a sidebar or something, then I have to update that component, whereas if I styled the semantic elements correctly in the first place, I wouldn't need to.
For instance, if an
h2
in the sidebar looks like such-and-such, then that's how I want it to look regardless of what component it's in.Why would I want to duplicate styles for every component? Or to update every single template if we wanted to make a change for holiday branding or something?
Sorry, I'm a bit confused, but I think you're saying you style all the base elements in one place correct, like styling all buttons, a, h1, h2, etc? And then you just don't style components (as in groups of those base elements)? Not sure how exactly the last would work, or do you just do basic layout styling?
I still do some base styles depending on the project (like the h2 example you mention), but when I talk about complex components with many states, I'm talking about wrappers around base elements, Like a button, with all the hover/disabled/error/etc states, can be a surprisingly complex amount of css.
I don't have this duplicate style issue you mention, if I find myself copy pasting the exact tailwind classes too much, I either convert it to a utility class for that project, or create a component... This is quite rare though, as I find usually I want a similar style to something I already wrote, but not exactly the same. But if I do have a component and I need a version to look different, I can just pass down tailwind classes that will override the base styles quite easily.
If you convert the same bunch of styles to their own utility class, you're doing CSS, and Tailwind has no benefit at that point. At a stretch, you could say it's because of mixins, but Sass has done those for many years without requiring you to pollute the HTML.
I'm not sure what you mean avout needing wrappers and lots of different states. Semantically, we already have attributes for active and inactive states, and CSS already includes pseudoelements and hover and focus states. Tailwind is reinventing the wheel here.
I don't have a lot of utility classes, I only do very few, like the base styling, but I might not want ALL x (let's say links) styled like the base, so I like having a base link utility class I can add/remove. Otherwise yes, using tailwind just with apply is pointless.
Regarding wrappers, like I mentioned in the post, I switched my component library to tailwind, so for example, I have a vue component for a button. It needs base styles (hover, focus, disabled, etc) and more state styles (primary, secondary, error, etc), AND dark mode styles.
That is a lot of styles that must all be written in the right order with the right specificity. CSS makes this very hard because if I add .primary or data-primary to indicate an additional state, when I use that selector chained with others it increases specificity. Maybe I should have clarified more but the specificity problem applies to CSS way more, but unlike tailwind, there's no escaping it. Also I used Sass before this and even with mixins, etc, it was a mess because at the end of the day a selector needs to be written.
I used to think the same about html pollution, but I can't deny the code I get (see example at the end) is much more readable, maintainable, and shorter to me. I'm always open to better solutions, but one hasn't come along yet.
Great article Alan!
my 2 cents, i find
classnames
(utility micro package) super useful for these kind of statements:the
boolean && modifier
and theboolean ? a : b
are easier to read IMOI mentioned this in the tailwind-merge part which is what I use to do this.
Having a quick look at the library, I would unfortunately strongly recommend NOT using it because it only merges via exact property name. So if you do
c("border-white", "border-transparent")
you will get both borders and the end result is up to the order of the tailwind style-sheet which you cannot control.Tailwind-merge (there are other alternatives if you like) will merge according to tailwind properties. So the example would become
border-transparent
since the last will win. This allows easy correct overriding of styles, especially in regards to padding and related utility classes.I disagree im afraid. The utility I do want is to merge class names with no further logic. Keep which rules apply depending on others to the programmer … well to the client really XD but I prefer this package to care to syntactic sugar , nothing else
Ah, okay, so long as you're aware and it works for you.
I really enjoy writing CSS styles and tailwind taking it away from me. So - no. xd