In the last years Tailwind has become an incontournable tool to manage the styling of website. It lets you stylise your components without creating any css stylesheet by using some utilities classes. Some love it, some hate it. The general critic is that it complexifies your html templates by creating a mess of classes.

I am more optimistic about Tailwind, because if you follow some basic rules to organise your classnames you end up with a very fast way to create your layout and interfaces without leaving your template.

But as a creative front end developer I aim to develop high visual websites. I like to polish animations, and create complex but nice layouts which is probably the first aim of Tailwind. After some time using the tool I’ve developed some techniques to solve my problems and to merge the best of the template utilities classes and classic css stylesheet.

Here are 12 tips that I use in every project to help me manage my styling with Tailwind.

1 – Create a custom theme

It is definitely the most obvious but powerful tips here.

Even if Tailwind defaults can handle a large scope of projects, you should definitely create your own theme that will follow your styling style. It will help you to spare a lot of time on repetitive styles, and more important to stay consistent across the app.

I like to use the barrel pattern to organise my tailwind theme files. I create folders and subfolders containing my custom files and I import each one in an index.css. It helps me to stay organized and to keep control on what I import.

I group files by type (variant / utility / component) or property (colors/ spacing / timing-functions …). I then import everything in the main index.css and import this file in my app.css.

project/
├── app.css
└── theme/
    ├── variants/
    │   ├── index.css
    │   ├── variant1.css
    │   └── variant2.css
    ├── utilities/
    │   ├── index.css
    │   ├── utility1.css
    │   └── ...
    ├── colors/
    │   └── index.css
    ├── spacing/
    │   └── index.css
    ├── typography/
    │   └── index.css
    └── ...

Here is my main index.css in which I import each part of my theme.

/* Root index.css */

/* Base styles */
@import './theme/base.css';

/* Define values that theme can retrieve */
@import './const.css';

@import './colors/';
@import './sizing/';
@import './zIndex.css';
@import './breakpoints/';

/* Easing */
@import './timing-functions/';

/* Animations */
@import './animations/';

/* Utilities */
@import './utility/';

/* Variants */
@import './variants/';

/* Custom components */
@import './theme/components/';

And finally my app.css in which I import my theme, and I can eventually import other third-party stylesheet (like the lenis one that I use very often).

/* app.css */
@import "tailwindcss" theme(static);

@source "../views/";
@source "../../app/";

@import './theme/index.css';

2 – Handle typography with ease

One thing that is the most important in a website that aims to be creative is the typography. The font in one hand and the typescale in the other. I manage this in a typography folder in which I have a fontface.css file containing my font imports and a typescale.css containing my font sizes and spacings.

/* fontface.css */
@font-face {
  font-family: 'MyFont';
  src: 
  url('path/of/the/font/MyFont.woff2') format('woff2'),
  url('path/of/the/font/MyFont.woff2') format('woff2');

  font-display: swap;
  font-style: normal;
}

@theme {
  --font-title: MyFont, 'serif';
}

In my typescale depending on the scope of the project I declare more or less different text scales. I always separate text sizes and usage in different variables. This way, the tweaking is easy and naming stays consistent across time.

I also create utilities to define either the size and line-height of the element.

If for some reason the size of a title needs to be changed, the property name text-title and the utility typo-title will stay the same.

/* typescale.css */
@theme {
  --text-xs: 1.8rem;
  --text-sm: 2rem;
  --text-md: 3rem;
  --text-lg: 4rem;

  --text-title: 5rem;
}

@utility typo-title {
  font-size: var(--text-title);
  line-height: 1.2; /* could be controlled by a token but rarelly reused */
  /*
    font-style
    font-weight
    font-variant
    letter-spacing
    ...
    */
}

@utility typo-body {
  font-size: 3rem;
  line-height: 1.5;
}

3 – Setup you color theme

You probably already do it as it is a core feature of websites, and default colors are quite boring.

I like to manage colors in a token style. It means I define my base colors then I define the usage of them. For example I can have a red-100: #, and a red-400: #. But I won’t use them like that.

I need to define a usage property for them, for example danger-main: var(–red-100) or secondary-background-col: var(–red-400).

This way things keep well organised and easy to tweak. I try also to keep things simple and not to overoptimise my color theme, a SaaS style website will need a lot of these configurations to stay consistent across the huge amount of states, a creative portfolio less.

This way I stay well organised, I don’t get messy when color change, I just need to add or tweak a base color, and set or update a usage property and everything stays updated.

I like to keep my default color as the 400 value.

@theme {
  /* Remove tailwind defaults */
  --color-*: initial;
	
	/* Grayscale colors */
  --color-dark: oklch(30.29% 0.015 250);
  --color-light: oklch(90.29% 0.015 250);
  --color-black: oklch(0 0 201);


  /* family of color */
  --color-red: oklch(0.6 0.3601 21.18);
  --color-red-100: oklch(0.7588 0.1505 21.18);
  --color-red-200: oklch(0.6882 0.2047 21.18);
  --color-red-300: oklch(0.6618 0.222 21.18);
  --color-red-400: var(--color-red);
  --color-red-500: oklch(0.5529 0.222 21.18);
  --color-red-600: oklch(0.3971 0.1579 21.18);
  --color-red-700: oklch(0.3118 0.1283 21.18);
  --color-red-800: oklch(0.1647 0.0617 21.18);

  --orange: oklch(0.7529 0.1751 47.65);
  --blue: oklch(0.1825 0.0567 260.47);
  
  ...
  
  /* any use case of color should be named,
  then if primary change the code stays consistent */
  --color-primary: var(--color-orange);
  --color-secondary: var(--color-blue);

  /* ui use and state colors */
  --color-danger: var(--color-red-400);
  --color-danger-hover: var(--color-red-300);
	
	/* context colors, mainly for texts */
	--color-on-dark: var(--color-light);
  --color-on-light: var(--color-dark);
}

And to use it

<div class="bg-light text-on-light">My dark text on light background</div>

<div class="bg-warning hover:bg-warning-hover">Danger !</div>

4 – Setup some default sizing

Even if default Tailwind gives some interesting basis for sizing/spacing, projects could need to have their custom ones. To stay consistent across your app or website you should keep it in a single source of truth. Let’s say that you or your designer decide that the common spacing for the website is 12px or Xrem or so. You could use the value m-3, or p-3 or so everywhere.

2 weeks later after a client review or any common but unwanted event in project development you need to change this default spacing to 10px you are stuck, you need to change all your utilities.

To avoid that abstract it to custom utilities. You can define general spacing, that can be accessed via all the common utilities (m-, p-, w-, h- …) but you can be also more specific if you know that this value is needed only on height or margin…

@theme {
  --spacing-px: 1px;
  --spacing-sm: --spacing(2);
  --spacing-md: --spacing(4);

  /* Contextual spacing */
  --spacing-navbar: 50px;
  --spacing-navbarX2: calc(var(--spacing-navbar) * 2);
  --spacing-sidebar: 100px;
}
<!-- Usage of the spacing-navbar utility to adjust
the padding size to the height of the navbar -->
<main class="pt-navbarX2">
	<nav class="navbar fixed top-0 left-0 w-full h-navbar">
		...
	</nav>
<main>

5 – Embrace responsive design

When it comes to responsive design different approaches can be used, like fluid styling or feature specific media queries. But in the vast majority of cases you will need main breakpoints. The default ones are not bad as they follow industry standards but I like to add some to handle correctly some larger screens. Depending on your project specific target you could add more.

@theme {
  --breakpoint-hd: 90rem; /* 1440px */
  --breakpoint-fullHd: 120rem; /* 1920px */
  --breakpoint-4k: 240rem; /* 3840px */
}

Use container Queries

Container queries are a new way to control the responsivity of your layouts. It makes possible to tweak child element properties depending on the size of the parent. So you will be able to design elements that are more versatile, and that adjust in their context regardless of the size of the viewport.

<div class="@container">
  <!-- ... -->
  <div class="flex flex-row @sm:flex-col">
    <!-- ... -->
  </div>
</div>

One more time Tailwind provides some useful defaults but don’t forget to customize them to match your needs and to micro-tune responsive design.

@theme {
  --container-8xl: 96rem;
  
  /* Define the breaking point of your main containers */
  --container-sidebar-sm: 120px;
  --container-sidebar-md: 220px;
  --container-sidebar-xl: 420px;
}
<div class="@container">
  <!-- ... -->
  <div class="flex flex-col @sidebar-sm:flex-row">
    <!-- ... -->
  </div>
</div>

7 – Lock your z-index

Like for your sizing it could be a great idea to define and lock your z-index values to avoid some random z-[552] values in your code. You need to define the layers of your website and stick to these values for your containers.

Then inside a container you will be free to use the z-100/200/300 … as you wish to order your elements. It keeps things clear and easy to understand.

@theme {
  --z-index-over: 9999;
  --z-index-loader: 1000;
  --z-index-modal: 900;
  --z-index-hud: 600;
  --z-index-main: 500;
  --z-index-gl: 100;
}

8 – Define your grid

You definitely need one or several layout grids for your project. A layout grid helps you to stay consistent with the alignment and spacing of your components or elements. The css grid property can be hard to use easily.

As my default grid I like to have a 24 columns grid. It gives me almost all the distribution possible. Like 50 / 50, 25 / 25 / 25 / 25, 33 / 33 / 33 …

Here is my snippet for the grid

@layer components {
  .grid-main {
    display: grid;
    grid-template-columns: [full-start] 0 [c-start] repeat(24, [c] 1fr) [c-end] 0 [full-end];
    gap: --spacing(2);
    grid-auto-rows: max-content,
  }
}
<div class="grid-main"> // Main grid
	<div class="col-[full]">Full width element</div>
	<div class="col-[c]">Full width with gutter</div>
	<div class="col-[c_3/span_c_6]">Start col 3, 6 col width</div>
</div>

With this configuration it is easy to create subgrid, in case you have to group child content, but keeping alignements corrects

@utility subgrid-* {
  --cols-nb: --value(integer);

  display: grid;
  gap: --spacing(2);
  grid-auto-rows: max-content;
  grid-template-columns: [c-start] repeat(var(--cols-nb), [c] 1fr) [c-end]; 
}
/* With subgrids I can easyly handle child layouts that keeps perfect alignement and same gaps with parent */
<div class="grid-main">
	<div class="col-[c_4/span_c_4] subgrid-4">
		<div>Element 1</div>
		<div>Element 2</div>
		<div>Element 3</div>
		<div>Element 4</div>
	</div>
</div>

9 – Animation components

If mastered correctly Tailwind should not need to have a lot of component wise logic. But sometimes it helps to separate some styling and use it with a class like “normal” css. Generally I use components for complex animation which can be hard to maintain with utility classes, but easy to control with the addition/deletion of a class.

Example of a component to handle the text animation of an element (more precisely the elements with the class char that we obtain when we split the text using splittingJs or GSAP splitText). We animate them when we decide the app is ready or scrolled inview or when scroll is passed. I can easily control how I trigger animations like that by adding or removing a class.

  @layer components {
	  .text-anim-intro {
	    .char {
	      --animation-duration: 1.2s;
	      --easing: var(--ease-out-expo);
	
	      transform: translateY(100%);
	
	      transition: transform var(--animation-duration) calc(var(--char) * 0.001s)
	        var(--easing);
	    }
	
	    &.ready {
	      .char {
	        transform: translateY(0);
	      }
	    }
	    
	    &.inView{
	      .char {
	        transform: translateY(0);
	      }
	    }
	
	    &.passed {
	      .char {
		      --animation-duration: .8s;
		      --easing: var(--ease-in-expo);
		      
	        transform: translateY(-100%);
	      }
	    }
	  }
  }

10 – Customize your timing function

The default in Tailwind lets you have some general timing functions like ease-in, ease-in-out, ease-out. It could be useful for some simple animations, or some transitions. But in the scope of creative development you need to have more possibilities than these ones.

I like to have a library of easing that I can try quickly.

@theme {
  --ease-in-sin: cubic-bezier(0.12, 0, 0.39, 0),
  --ease-out-sin: cubic-bezier(0.61, 1, 0.88, 1);
  --ease-in-out-sin: cubic-bezier(0.37, 0, 0.63, 1);

  /* power2 */
  --ease-in-quad: cubic-bezier(0.11, 0, 0.5, 0);
  --ease-out-quad: cubic-bezier(0.5, 1, 0.89, 1);
  --ease-in-out-quad: cubic-bezier(0.45, 0, 0.55, 1);

  /* power3 */
  --ease-in-cubic: cubic-bezier(0.32, 0, 0.67, 0);
  --ease-out-cubic: cubic-bezier(0.33, 1, 0.68, 1);
  --ease-in-out-cubic: cubic-bezier(0.65, 0, 0.35, 1);

  /* power4 */
  --ease-in-quart: cubic-bezier(0.5, 0, 0.75, 0);
  --ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
  --ease-in-out-quart: cubic-bezier(0.76, 0, 0.24, 1);

  /* power5 */
  --ease-in-quint: cubic-bezier(0.64, 0, 0.78, 0);
  --ease-out-quint: cubic-bezier(0.22, 1, 0.36, 1);
  --ease-in-out-quint: cubic-bezier(0.83, 0, 0.17, 1);

  /* expo */
  --ease-in-expo: cubic-bezier(0.95, 0.05, 0.795, 0.035);
  --ease-out-expo: cubic-bezier(0.19, 1, 0.22, 1);
  --ease-in-out-expo: cubic-bezier(0.87, 0, 0.13, 1);

  /* circular */
  --ease-in-circ: cubic-bezier(0.55, 0, 1, 0.45);
  --ease-out-circ: cubic-bezier(0, 0.55, 0.45, 1);
  --ease-in-out-circ: cubic-bezier(0.85, 0, 0.15, 1);

  /* back (overkill) */
  --ease-in-back: cubic-bezier(0.36, 0, 0.66, -0.56);
  --ease-out-back: cubic-bezier(0.34, 1.56, 0.64, 1);
  --ease-in-out-back: cubic-bezier(0.68, -0.6, 0.32, 1.6);
}

And in particular situations you may need a way to tweak very specially the easing function. So as referenced in the documentation you can do something like that.

class="ease-[cubic-bezier(0.2,0.4,0.6,0.9)]"

It is ugly but declarative. And if you need the same twice, declare a new entry in the easing list.

11 – Master svg recoloring

Svg are a performant way to introduce illustrations to concepts. But, in my experience designers like to tweak colors of them often. To avoid exporting n-versions of the same svg you need to perform svg recoloration, for example on user interaction, on background color change, on different context.

With this snippet you can recolor them on the fly, avoiding to have different versions of the same svg. So tweaking is easy and it follows your theme configuration.

What it does is to check if your svg has a fill or stroke properties which is not transparent and redefine them in case. The two variable definition is to handle as well defined colors and custom brackets ones.

@utility svg-color-* {
  --col : --value(--color-*);
  --col : --value([color]);

  [fill]:not([fill="none"]):not([fill="transparent"]) {
    fill: var(--col);
  }
  [stroke]:not([stroke="none"]):not([stroke="transparent"]){
    stroke: var(--col);
  }
}

12 – Per project utilities/function

Some projects, and in general the majority, will need to reuse some graphical components or style across the pages. So it could be a great idea to carve some tools to reuse it with ease on these different places. For example, if applicable, it can be a good idea to create a gradient utility, with defined color stops. Or a border style, or a type effect or anything.

Try to anticipate the fact that you will need it anywhere else. This way you will also be able to quickly propose variation to your designer.

13 – Customize your variants

I use these variants in the majority of my projects. The first one is controlled by my javascript scroll/intersection observer engine, I use the second one to trigger animations when I want.

The three others help me to control the comportement of hover, depending on the device. I overwrite the default hover to only apply if the user device can handle it, avoiding unwanted effect on click on mobile (until hover is available on touch screens 🤣).

Add as many variants as you need to create your effects.

@custom-variant inView {
  &.inView {
    @slot;
  }
}

@custom-variant ready {
  &.ready {
    @slot;
  }
}

@custom-variant hover {
  @media (hover: hover) {
    &:hover;
  }
   {
    @slot;
  }
}

@custom-variant hashover {
  @media (hover: hover) {
    @slot;
  }
}

@custom-variant nohover {
  @media (hover: none) {
    @slot;
  }
}

Conclusion

By combining these technics and adapting them to your context you will be able to create amazing experiences and keep things simple and well organised. The main point of the creation of a theme is to stay consistent. You will benefit from the lightning speed of Tailwind and a robust way of tweaking your values.

With a little bit of javascript to handle states, or user inputs, toggling classes you will create some nice effects and animations avoiding the ton of classes it usually needs, you will focus on getting things right so the level of your outcome will rise.