Moving an existing site to Tailwind

I migrated my entire site from Styled Components to Tailwind.

When I first heard about Tailwind I wrote it off as "inline styles with extra steps". I was happy with Styled Components, or Sass if I wasn't using React.

Then I had to use it for a project at work, and now I'm a Tailwind convert. It's way easier for prototyping on personal projects, and it seems easier for maintaining larger projects with a team of devs and a lot of components, although there's still time for that to all fall apart!

So what do you do if you've found a new shiny toy, but your existing personal site doesn't use said toy? You completely refactor your entire site over a couple of evenings, obviously!

Hybrid styling approach

While migrating, it was fairly easy to have both Tailwind and styled components running at the same time. This meant I could replace things bit by bit without the whole site exploding. Most things just involved replacing a styled.div call with a <div> witha few classes.

There were a few things that weren't just a straight swap though. I'll briefly go over some of the more complicated issues I hit, and the solutions I came up with.

Radix UI components

I use Radix UI for a lot of my interactive components, because making accessible components is hard and I don't want to screw it up. I wasn't sure how to style these with Tailwind, but newer versions of Radix have an asChild prop that merges the component with whatever is nested within it. So I could just use that and add classes as normal.

// @file: tsx
// The new Tailwindified element. const InlineToggle = ({ children, pressed, onClick }: Props) => ( <Toggle.Root asChild onClick={onClick} defaultPressed={pressed}> <button type="button" className={clsx( 'inline-block text-left cursor-pointer -mx-1 -my-0.5 px-1 py-0.5', 'bg-accent-3 text-gray-12 font-light leading-snug', 'focus:bg-accent-4 hover:bg-accent-4' )} > {children} </button> </Toggle.Root> );

Radix UI also relies on data-attributes for styling, which Tailwind doesn't handle well out of the box. It's possible to support it fairly easily with a plugin, but for now I've just added a helper class that applies styles. For example, this is the .collapsible class that I use for Hearthstone deck lists.

// @file: css
@layer components { .collapsible[data-state='closed'] { @apply animate-collapse overflow-hidden; } .collapsible[data-state='open'] { @apply animate-expand overflow-hidden; } }

Markdown elements

This one is a bit of a pain. With Styled Components I could just create a styled.pre element, and nest the styling for all the various tokens inside of it, but I can't with Tailwind. Again, I've ended up with a helper .codeblock class that then applies styles to all the nested elements.

This is also how I've handled general styling of the Markdown elements, with a .prose class that sets things like heading heights, paragraph margins and so on. I did try the tailwind/typography plugin for this, but I ended up overriding the default styles for a lot of things, so just made my own.

Dynamic style values

Applying styled based on props in React components is a fairly common pattern. With Styled Componets I could either add a class based on the props, or pass the props directly into the styled element and work with them there. Tailwind is all precompiled, so I'm using clsx() to easily combine classes based on props.

With the new JIT compiler, Tailwind does allow for arbitrary values, like .w-[13px], but they're still compiled and purged at build time, so classes like .w-[${width}] won't work. I've ended up working around this in a couple of ways:

// @file: tsx
// Using clsx() to combine classes based on props. <div className={clsx('flex', showVertical ? 'flex-row' : 'flex-col')} > <div>An Item</div> <div>Another Item</div> </div> // Using data attributes for content in pseudo elements. <div data-definition={value} className=".before:content-[data-definition]"> Hello </div> // Using straight inline styles for other values, like this progress bar. <div className="h-4 w-full"> <span className="sr-only">Progress: {progress}%</span> <div className="h-full w-0" style={{ width: `${progress}%`}} /> </div>

Most of the time, the limitations here weren't an issue. I found I could refactor the markup to work within the constraints of Tailwind, and half the time the result was actually nicer than what I'd cobbled together with styled components.

Animations and keyframes

This isn't really a fair comparison, as styled components already has it's own way of handling animation keyframes, just like Tailwind does. Basically I had to define my new animations in my tailwind.config.js file, and then use the .animation-[name] class to apply them.

// @file: js
extend: { keyframes: { tempo: { '0%': { width: '5rem', height: '5rem', }, '100%': { width: '3rem', height: '3rem', }, }, }, animation: { tempo: 'tempo var(--tempo-duration) cubic-bezier(0, 1, 1, 1) infinite alternate', }, },

This is the animation I use for my Spotify component when a preview is playing. The only other change I had to make was to pass the animation duration as a --tempo-duration variable instead of including it directly in the style as I did with styled components.

My customisable theme accents and dark mode

This was the biggest pain, but ended up being much better than what I had before. I'm using Radix colours for my theme accents, for the same reason I'm using Radix UI for my components: a11y is hard and I don't want to screw it up. With Styled Components I just imported the colours from Radix, and then wrote a loop to set some CSS variables for each of the colours.

I can't do anything dynamic in the CSS or config for Tailwind, so I've instead had to create a custom plugin to add the colours as css variables, and expose those variables as colours to Tailwind.

I'm not going to paste the whole plugin here, but the meat of it is below. Basically I'm setting CSS variables for each shade of each colour I'm using, and then exposing those same values to Tailwind. On top of that I'm also setting a --accent-${shade} variable under a data attribute, which I use for the user selected theme.

// @file: js
// tailwind/colors.js addBase({ // Add the CSS variables for each accent shade. [`[data-theme="${color}"]`]: { ...accents, } // Add the default "light" CSS variables. ':root': { ...lightStyles }, // Add the "dark" CSS variables under a [data-mode="dark"] attribute. ':root[data-mode="dark"]': { ...darkStyles }, // Add the "dark" CSS variables again, under a media query. '@media (prefers-color-scheme: dark)': { ':root[data-mode="browser"]': { ...darkStyles }, }, }); // tailwind.config.js module.exports = { // ... plugins: [ require('./tailwind/colors')({ defaultColor: 'grass', colors: ['gray', 'red', 'violet', 'indigo', 'grass', 'amber'], }), ], };

So I'm not currently using Tailwind's built in dark mode support. Doing it this way I don't need to manually specify .dark:text-white type classes, .text-gray-12 will always be the right colour. In light mode it's pretty much black, and in dark mode it's pretty much white.

And the accent classes are always the right accent based on the user's theme choice. This tailwind approach also means I get classes like .text-violet-11 and .bg-amber-4, which wasn't possible in my old setup, and anything unused gets purged, so no worries about a massive CSS file.

Try it out! The colours will adapt to the light or dark mode.

I had something similar in my Styled Components setup, but it was all custom. The plugin approach means I get to take advantage of Tailwind's pipeline, and can do things like pass just the colours I want to use as an option, instead of hardcoding them all in the logic.

But why?

At the time, it just seemed like a good way to get into the more complicated parts of Tailwind, and it turns out I was right. I've had to dig pretty deep into how Tailwind works, like custom plugins.

In hindsight there was also a performance benefit as well! It turns out that a single CSS file containing just the Tailwind selectors I'm using (plus my custom additions) is smaller than the server side render styled components, plus the overhead of the JS library.

First load JS sizes of a selection of pages before and after moving to Tailwind

+ First Load JS shared80.2kB78.5kB
├ .../framework.js42kB42kB
├ .../main.js33.1kB33.1kB
├ .../pages/_app.js4.28kB2.56kB
├ .../webpack.js770B770B
└ .../cssN/A6.88kB

My total CSS size adds ~7kB, but the first load JS on each page is around 15-20kB smaller. So on top of the actual size saving, that's ~20kB of JS that isn't being executed by the browser. That's a win-win as far as I can see.