How I Configure Tailwind CSS 3

The patterns I've used on custom UIs large and small
Various birds in flight, including eagles, a heron, a pigeon and ducks. Etching after F. Barlow
Various birds in flight, including eagles, a heron, a pigeon and ducks. Etching after F. Barlow. Public Domain Mark. Source: Wellcome Collection.

The use of Tailwind I’m talking about

I see Tailwind used in two ways: as a JS API for CSS with significant theme customization and plugin creation, and as an off-the-shelf design system with few or no modifications to the configuration. This article applies to the former.

I’ve used Tailwind a lot, in a lot of different stacks. The agency team I was on in late 2017 learned about Tailwind in its first weeks, was using it on most new projects by May 2018’s v1.0.0, and made it our official standard later than year; I stayed with that team another five years, and we used Tailwind on every project that entire time. Setting Tailwind up on many projects, and seeing my teammates set it up on many others, let my approach to using it evolve quickly. Here’s how I Tailwind, grown out of years using Tailwind to style UIs from large to small, generic to fully custom: what I put where, how I name things, what of the defaults I keep, what I refactor, and what I scrap.

Highlights:

  • I keep as much as possible in the Tailwind config file.
  • I readily reach for custom utilities and custom variants.
  • For “design system” core plugins I prefer overriding over extending.
  • Where the Tailwind defaults are qualitative (font-light, leading-loose, tracking-widest) I rename them quantitatively (font-300, leading-2, tracking-0.1)
  • Where the Tailwind defaults use an arbitrary scale (p-7) I switch to pixel sizes (p-28)
  • I use rems for nearly all sizes, including converting screens’ breakpoints from pixel values to rem values
  • I avoid using Tailwind’s special DEFAULT key.

What goes in the Tailwind config?

I prefer to keep as much as possible in the Tailwind config. I’d rather scroll up and down one file, with code folding, than keep things in different places. Custom utilities, components, and variants are defined as plugins directly in the Tailwind config. Arbitrary CSS (for example global styling of specific HTML elements) is defined directly in the Tailwind config.

Two exceptions:

  • @font-face in addBase has given me trouble in the past, forcing me to put them a @layer base { … } in my CSS entry file (immediately after @tailwind base;).
  • I sometimes opt to keep overrides for external libraries out of Tailwind, writing them instead in the CSS entry file (and not in a Tailwind @layer) or, if there are a lot of them, in a separate file.

To extend or not?

My first look is to override (theme.<core plugin>). I extend (theme.extend.<core plugin>) when I know I’ll have use for the Tailwind defaults. This keeps the theme —and, so, the editor intellisense suggestions when I’m in component files— as focused as possible. That typically means, at a minimum, overriding colors, fontFamily, and spacing.

I extend gridTemplateColumns and zIndex if I need custom values.

I’ve sometimes extended aspectRatio, using Tailwind’s “square” and “video”, and I’ve sometimes overridden it.

Sizing

I use rem sizes a lot, motivated by accessibility. (If that’s new to you, add Josh Comeau’s article on pixels vs rems to your reading list. It aligns well with my own preferred practices, and has good examples.) But I find it easier to think in pixels, and have typically worked from pixel-based designs.

Fluid sizing

For fluid sizing (not counting fluid font sizing) I use my JS utility function css-fluid-length.

So I use a pixels-to-rems function in the Tailwind config. I write pixel values, and the CSS uses rems. This function is handy elsewhere, for example in Font size and line height and Spacing.

tailwind.config.js
js
const plugin = require('tailwindcss/plugin');

const rootFontSizePx = 16;

/**
* pxToRem
* Given a size in pixels, returns a size rems
*
* @param {Number} px Size to convert to rems, in unitless pixels
* @param {Number} rootPx Size rems are relative to, in unitless pixels
* @returns
*/
function pxToRem(px, rootPx = rootFontSizePx) {
  const rem = `${px / rootPx}rem`;

  return rem;
}

module.exports = {
  theme: {
    fontSize: {
        24: pxToRem(24),
    },
  },
  plugins: [
    plugin(function({ addBase }) {
      addBase({
        ':root': {
          fontSize: `${rootFontSizePx}px`,
        },
      }),
    }),
  ],
}

Core Plugins

Colors

Override colors with the site palette.

Keys are as CSS font-weight values (400 is baseline, 500 is darker/heavier/stronger, etc.). That happens to match Tailwind’s default config.

No DEFAULTs. I prefer to be explicit in the markup: text-blue-400 requires less domain knowledge than text-blue, and can make it easier for new contributors to join the project.

tailwind.config.js
js
  theme: {
    colors: {
      // global
      inherit: 'inherit',

      // keyword
      current: 'currentColor',
      transparent: 'transparent',

      // palette
      black: '#0a0a0a',
      white: '#fff',

      red: {
        400: /* … */,
      },
      green: {
        300: /* … */,
        400: /* … */,
      },
      blue: {
        400: /* … */,
        500: /* … */,
      },
      // …
    },
  }

Font family

Unless otherwise specified in the design, I use Tailwind’s default fallback stacks.

tailwind.config.js
js
const defaultTheme = require("tailwindcss/defaultTheme");

module.exports = {
  theme: {
    fontFamily: {
      sans: ["My Font", ...defaultTheme.fontFamily.sans],
    },
  },
};

Font size and line height

Font size values are string rem sizes, written with a “pixels-to-rems” function (see Sizing).

Fluid sizing

For fluid font sizes I use my plugin tailwindcss-fluid-font-size.

If the design system has semantically named text styles (e.g. “Body”), font size keys follow the design system body: …. Otherwise, and for outliers, font size keys are unitless pixel sizes (28: pxToRem(28)). Fluid values’ keys include the breakpoint’s names (see tailwindcss-fluid-font-size).

I’m conservative about taking advantage of Tailwind’s support for multiple properties in font size values. Unless the design very tightly ties styles to each other, I’ll use multiple classes (leading-… text-…). And when I do combine font-size and other properties, I first extend the other properties and then reuse values in font-size:

tailwind.config.js
js
const lineHeight = {
  /* … */
  3: "3",
};

module.exports = {
  theme: {
    fontSize: {
      12: pxToRem(12),
      24: pxToRem(24),

      body: [pxToRem(20), lineHeight["3"]],
    },
    ...lineHeight,
  },
};

Font weight

I replace Tailwind’s typographer jargon font weight keys (e.g. “medium”) with CSS’s hundred-increment numbers (“500”).

Keys are identical to the value. Values are strings.

tailwind.config.js
js
  theme: {
    fontWeight: {
      100: '100',
      200: '200',
      300: '300',
      400: '400',
      500: '500',
      600: '600',
      700: '700',
      800: '800',
      900: '900',
    }
  }

Height and Width

There are some oddities in Tailwind’s default theme’s height, minWidth, minHeight, maxWidth, and maxHeight.

  • By default, height and width include utilities for percentage values (e.g. w-1/2), but max-height and min-height do not.
  • By default, width includes utilities for percentage values on a 12-column scale (e.g. w-1/12), but height does not.
  • By default, width includes utilities for 100dvw, 100lvw, 100svw, and 100vw (w-dvw, w-lvw, w-svw, and w-screen respectively), but max-width and min-width do not.
  • By default, height includes utilities for 100dvh, 100lvh, 100svh, and 100vh (h-dvw, h-lvw, h-svw, and h-screen respectively), but max-height and min-height do not.

I equalize those all by combining the default theme’s width and height, and applying that to all of width, height, minWidth, minHeight, maxWidth, and maxHeight. (I extend width with defaultTheme.height even though as of this writing width isn’t missing anything height has, as future-proofing.)

tailwind.config.js
js
const defaultTheme = require('tailwindcss/defaultTheme');

/** @type {import('tailwindcss').Config} */
module.exports {
  theme: {
    extend: {
      height: defaultTheme.width,
      maxHeight: ({ theme }) => theme('height'),
      minHeight: ({ theme }) => theme('height'),
      maxWidth: ({ theme }) => theme('width'),
      minWidth: ({ theme }) => theme('width'),
      width: defaultTheme.height,
    },
  },
}

In practice, a site’s design system will likely have additional specific heights and widths. Add those with extend. A real configuration might look something like

tailwind.config.js
js
const defaultTheme = require('tailwindcss/defaultTheme');

/** @type {import('tailwindcss').Config} */
module.exports {
  theme: {
    extend: {
      height: {
        ...defaultTheme.width,
        80: pxToRem(80),
        220: pxToRem(220),
      },
      maxHeight: ({ theme }) => {
        ...theme('height'),
        110: pxToRem(110),
      },
      minHeight: ({ theme }) => {
        ...theme('height'),
        // …
      },
      maxWidth: ({ theme }) => {
        ...theme('width'),
        // …
      },
      minWidth: ({ theme }) => {
        ...theme('width'),
        // …
      },
      width: {
        ...defaultTheme.height,
        // …
      },
    },
  },
}

Letter spacing

Em values. Keep the unit in the key— don’t assume collaborators and/or future me will intuit “no unit means such-and-such a unit”. Override, don’t extend — don’t want IDE suggestions slipping me into using letter spacings beyond what’s in the design.

tailwind.config.js
js
  theme: {
    letterSpacing: {
      '0.01em': '0.01em',
      '0.02em': '0.02em',
    },
  },

Line height

Line height keys are the same as their respective values. If the value has a unit, the key does too. Note that the values are strings not numbers (1: '1').

js
const lineHeight = {
  // without units
  1: "1",
  3: "3",

  // with units
  "24px": pxToRem(24),
};

Screens

I translate Tailwind’s default theme’s pixel screens values to rems, for better accessibility.

Read More

I’ve written up my approach in Convert Tailwind’s default screens from px to rem.

Spacing

Values are rem written as pixels in a pixels-to-rem function (see Sizing). Keys are pixel values without the unit.

Exception: following Tailwind defaults, I sometimes use the key px instead of 1px for the value '1px'.

tailwind.config.js
js
import { fluidLength } from "@olets/css-fluid-length";

// …
  theme: {
    spacing: {
      0: '0',
      'px': '1px',
      4: pxToRem(4),
      // …
    },
  },

Fluid values use my css-fluid-length utility, and get keys with breakpoints.

tailwind.config.js
js
import { fluidLength } from "@olets/css-fluid-length";

// …
  theme: {
    spacing: {
      // …
      'from-16-to-32': fluidLength(theme('spacing.16'), theme('spacing.32')),
      // …
    },
  },

I sometimes inline CSS variable spacings as arbitrary classes pt-[--my-var], but I prefer to add them to the config with semantic keys. For example 'mobile-header' to set the mobile header’s height and the mobile page content’s top padding.

tailwind.config.js
js
  theme: {
    spacing: {
      // …
      'mobile-header': 'var(--mobile-header-height)',
      // …
    },
  },

Width

See Height and Width

Styling HTML Elements

CSS which targets HTML elements goes in an addBase() plugin in the Tailwind config’s plugins. It could go in a CSS file imported into the index CSS file, or in a standalone plugin file imported into the Tailwind config file, but that’d be one more file to know about and refer to.

In plugins, I prefer @applying Tailwind classes over vanilla CSS (e.g. '@apply hidden': {} not display: 'none'), including using dynamic @apply keys.

If Tailwind doesn’t cover the styles I need, I use arbitrary CSS rather than applying an arbitrary Tailwind class (e.g. counterReset: 'my-counter' not '@apply [counter-increment:my-counter]': "",).

One exception is styling content. I often find Tailwind’s .content-[<val>] CSS variable approach weightier than I need, so I will use content: '""' over '@apply content-[""]': {}.

tailwind.config.js
js
const plugin = require("tailwindcss/plugin");

module.exports = {
  plugins: [
    plugin(function ({ addBase }) {
      const linkInteractionClasses = "transition-… focus-visible:… hover:…";
      const linkClasses = `${linkInteractionClasses} …`;

      addBase({
        /**
         * custom reset
         */
        html: {
          "@apply scroll-smooth motion-reduce:scroll-auto update-slow:scroll-auto":
            "",
        },
        a: {
          [`@apply ${linkClasses} …`]: "",
        },
        img: {
          "@apply w-full": "",
        },
        summary: {
          [`@apply ${linkInteractionClasses} …`]: "",
        },
        // …
        /**
         * end custom reset
         */

        // …
      });

      // …
    }),
  ],
};

Custom utilities, components, and variants

All custom utilities go in an addUtilities() and/or in matchUtilities()s in the Tailwind config’s plugins, and all custom variants go in addVariant()s or matchVariant()s. If you’re not already writing utility plugins and variant plugins, it’s worth putting in the time to get comfortable with them, even if you aren’t already comfortable with JavaScript; Tailwind’s built-in helper functions make adding new classes about as simple as is possible (read more in Tailwind’s plugin docs).

As with addBase, I prefer @applying Tailwind classes in the config over vanilla CSS. See Styling HTML Elements.

tailwind.config.js
js
const plugin = require("tailwindcss/plugin");

module.exports = {
  plugins: [
    plugin(function ({
      addUtilities,
      addVariant,
      matchUtilities,
      matchVariant,
    }) {
      addUtilities({
        /* … */
      });

      addVariant(/* … */);
      addVariant(/* … */);

      matchUtilities(/* … */);

      matchVariant(/* … */);
    }),
  ],
};

I heavily favor long class values in view files over dedicated component classes. I will occasionally use addComponents(), but consider it a code smell for a need to refactor component files. There probably is a legitimate use for matchComponents, but I find steering clear of it promotes maintainable, understandable component view file architecture. If you haven’t tried going all in on the atomic-classes-in-markup approach the haters love to hate, try it.

That is, I try to always do

MyComponent.xyz
html
<div class="flex flex-col gap-14 max-w-4xl w-full">…</div>

and I try to never do (and in fact essentially never do)

MyComponent.xyz
html
<div class="MyComponent">…</div>
tailwind.config.js
js
const plugin = require("tailwindcss/plugin");

module.exports = {
  plugins: [
    plugin(function ({ addComponents }) {
      addComponents({
        MyComponent: {
          "@apply flex flex-col gap-14 max-w-4xl w-full": "",
        },
      });
    }),
  ],
};

Typography for raw content

The Tailwind team’s typography plugin @tailwindcss/typography is a handy naive solution for projects that don’t include typography design. But the CSS is generates is heavy (it has to cover a lot of conditions and edge cases), and I find its utility classes hard to read. For fully-custom projects, I skip that plugin and add my own CSS with addUtilities(). One less dependency, and complete control.

I’ve given the wrapper utility different names on different projects. .prose to match @tailwindcss/typography, or .rich-text to reflect its common role in styling CMS rich text fields. As of this writing I prefer .raw.

Here again, I @apply Tailwind utilities in the config. See Styling HTML Elements.

tailwind.config.js
js
  plugins: [
    ({ addUtilities }) => {
      addUtilities({
        '.raw': {
          a:  {
            '@apply underline text-blue': "",
          },

          h1:  { /* … */ },

          ol:  { /* … */ },

          ul: { /* … */ },

          // …
        },
      })
    },
  ],

Articles You Might Enjoy

Or Go To All Articles