EmpireUI
Get Pro
← Blog9 min read#tailwind#config#theme

Tailwind Config Deep Dive: theme, extend, safelist and Presets

Master tailwind.config.js — theme vs extend, dynamic safelist patterns, preset composition, and plugin authoring. Stop guessing, start controlling every token.

Developer writing Tailwind CSS configuration code on dark monitor screen

theme vs extend — and Why the Difference Actually Matters

This trips up everyone at least once. When you put values directly under theme, you replace Tailwind's defaults entirely. So theme: { colors: { brand: '#7C3AED' } } nukes all of slate, zinc, sky, and every other default color in one move. That's occasionally what you want — a locked-down design system with zero drift — but usually it's not.

The fix is obvious once you know it: nest your additions under theme.extend instead. That merges your custom tokens into the existing scale rather than overwriting it. You keep text-gray-500, you keep bg-blue-600, and you also get your brand-* palette on top. In Tailwind v3.4 and later this behavior hasn't changed, but it's still the most common config mistake people paste into Discord.

// tailwind.config.js
module.exports = {
  theme: {
    // This REPLACES the default font family list entirely
    fontFamily: {
      sans: ['Inter', 'system-ui', 'sans-serif'],
    },
    extend: {
      // This MERGES alongside defaults
      colors: {
        brand: {
          50: '#f5f3ff',
          500: '#7C3AED',
          900: '#2e1065',
        },
      },
      spacing: {
        '18': '4.5rem',
        '128': '32rem',
      },
    },
  },
}

Honestly, a clean rule of thumb: use bare theme.* only for typography and border-radius when you're building a white-label product that must match a brand guide pixel-for-pixel. For everything else, extend. You'll thank yourself when a junior dev adds a component and wonders why bg-slate-900 stopped working.

One more thing — the order of keys inside theme.extend doesn't matter. Tailwind resolves them all before generating CSS. But documenting them in logical groups (colors, spacing, typography, animation) saves you 10 minutes every time someone new opens the file.

safelist: Keeping Classes That Purge Would Otherwise Kill

Tailwind's JIT engine scans your source files for class strings and only emits the CSS it finds. That's great for bundle size. It's terrible when class names are assembled at runtime. Dynamic color maps, CMS-driven themes, user-configurable badge colors — these all produce class strings that no static scanner can detect.

The safelist array in your config is the escape hatch. You can add plain strings, or — more powerfully — pattern objects with a pattern regex and an optional variants array. The regex runs against full class names, not just the utility prefix.

module.exports = {
  safelist: [
    // Exact strings — always emitted
    'text-center',
    'opacity-0',

    // Pattern-based — emit every matched class
    {
      pattern: /bg-(red|green|blue|amber)-(100|500|900)/,
      variants: ['hover', 'focus', 'dark'],
    },

    // All ring utilities up to ring-8
    {
      pattern: /ring-[0-9]+/,
    },
  ],
}

Worth noting: the regex matches against the generated class name *without* the variant prefix. So hover:bg-red-500 is matched by /bg-red-500/, not /hover:bg-red-500/. The variants array handles the prefix expansion separately. Get that backwards and you'll spend 20 minutes confused about why hover: classes aren't emitting.

In practice, keep your safelist as tight as possible. A loose /bg-.*/ pattern will bloat your CSS by several hundred KB in a project with a rich color scale. Profile with npx tailwindcss --content './src/**/*.{js,tsx}' --output /dev/null --minify and check the output size before committing a broad pattern. If you're building something like the components on Empire UI, keeping the safelist surgical matters — those gradient and glassmorphism utilities add up fast.

Plugins: Writing Your Own Utilities and Components

The plugins array is where config graduates to code. Tailwind exposes a plugin API with addUtilities, addComponents, addBase, and matchUtilities. Most devs know addUtilities for one-off CSS classes, but matchUtilities is the underrated one — it's how you build value-aware utilities like tab-4 or mask-circle-sm that respond to arbitrary values via square-bracket syntax.

const plugin = require('tailwindcss/plugin')

module.exports = {
  plugins: [
    plugin(function ({ addUtilities, matchUtilities, theme }) {
      // Static utility
      addUtilities({
        '.text-shadow-sm': {
          textShadow: '0 1px 2px rgb(0 0 0 / 0.25)',
        },
        '.text-shadow-lg': {
          textShadow: '0 4px 16px rgb(0 0 0 / 0.45)',
        },
      })

      // Value-aware utility — supports `tab-[8]` arbitrary values
      matchUtilities(
        {
          tab: (value) => ({
            tabSize: value,
          }),
        },
        { values: theme('tabSize', { 2: '2', 4: '4', 8: '8' }) }
      )
    }),
  ],
}

That matchUtilities call gives you tab-2, tab-4, tab-8 out of the box, plus tab-[10] via the JIT arbitrary value syntax. One function, infinite flexibility. Compare that to hand-writing a dozen static utilities and you see why the plugin API exists.

Quick aside: if your plugin grows past ~50 lines, extract it to its own file and require it. plugins: [require('./tailwind/text-shadow')] is totally valid. Keeps your main config readable and lets you unit-test the plugin in isolation.

Presets: Shareable Config Across Multiple Projects

Presets are Tailwind's answer to the monorepo design-token problem. A preset is just a standard tailwind.config.js export — same shape — that you load via the presets array in your project config. The consuming project's config merges on top of the preset, so you can override anything at the project level.

// packages/design-tokens/tailwind.preset.js
module.exports = {
  theme: {
    extend: {
      colors: {
        brand: { 500: '#7C3AED', 900: '#2e1065' },
        surface: { DEFAULT: '#0f0f11', muted: '#1a1a1f' },
      },
      borderRadius: {
        '4xl': '2rem',
      },
      fontFamily: {
        display: ['Cal Sans', 'Inter', 'sans-serif'],
      },
    },
  },
  plugins: [
    require('@tailwindcss/typography'),
  ],
}
// apps/marketing/tailwind.config.js
module.exports = {
  presets: [require('@acme/design-tokens/tailwind.preset')],
  content: ['./src/**/*.{ts,tsx}'],
  theme: {
    extend: {
      // Marketing site overrides — adds to preset, doesn't replace it
      colors: {
        cta: '#f59e0b',
      },
    },
  },
}

Look, this pattern changes how you think about cross-app consistency. Instead of copy-pasting color tokens between three repos and watching them drift by 2027, you publish one preset package. Every app imports it. Update the preset, bump the version, done. That's the same model that drives component-library packages like Empire UI — a single source of truth for visual tokens that multiple surfaces consume.

That said, presets don't merge deep arrays. If the preset registers plugins: [require('@tailwindcss/typography')] and your project config also has a plugins array, Tailwind concatenates them — it doesn't deduplicate. So don't register the same plugin in both the preset and the consumer, or you'll get doubled CSS output.

Content Paths, Transforms, and the `raw` Escape Hatch

The content array is how Tailwind knows what files to scan. Most guides show the glob pattern form, but there's a less-known object form that gives you two extra knobs: raw for injecting arbitrary strings as scan targets, and transform for preprocessing non-HTML file types before scanning.

module.exports = {
  content: [
    // Standard glob
    './src/**/*.{ts,tsx,mdx}',

    // Object form with transform
    {
      files: ['./src/**/*.json'],
      transform: (content) => {
        // Parse JSON component configs that store class names as values
        const parsed = JSON.parse(content)
        return Object.values(parsed).join(' ')
      },
    },

    // Raw string — classes injected directly, no file needed
    {
      raw: 'bg-red-500 text-white font-bold p-4',
    },
  ],
}

The transform option is genuinely useful if you store class names in JSON design tokens, YAML config files, or database-seeded fixtures checked into your repo. Instead of safelisting every possible class, you point Tailwind at the source of truth and let the scanner do the work.

The raw escape hatch reads silly, but it's the cleanest solution when you're generating HTML server-side from a language that has no Tailwind plugin — a Go or Rust template, a Python email renderer, anything outside the JS ecosystem. Drop the classes as a raw string and they'll always emit, zero safelist maintenance.

Worth noting: in Tailwind v3.3+, you can also pass content globs as relative or absolute paths and Tailwind respects .gitignore patterns automatically. If you've got a massive node_modules or dist folder that keeps getting scanned and slowing dev startup, add an explicit ! negation to your content array: '!./dist/**'.

Theming with CSS Variables and the `darkMode` Strategy

Since Tailwind v3.2 you can reference CSS custom properties directly in your config using var(--my-token). That bridges Tailwind and any external design-token system — Figma Variables exports, Style Dictionary outputs, whatever you're running. You define the variables in your global CSS, and Tailwind utilities resolve them at runtime.

/* globals.css */
:root {
  --color-surface: 15 15 17; /* RGB channels only, no `rgb()` wrapper */
  --color-brand: 124 58 237;
  --radius-card: 1.25rem;
}

.dark {
  --color-surface: 255 255 255;
  --color-brand: 167 139 250;
}
// tailwind.config.js
module.exports = {
  darkMode: 'class', // or 'media'
  theme: {
    extend: {
      colors: {
        // Tailwind opacity modifier syntax requires raw channels
        surface: 'rgb(var(--color-surface) / <alpha-value>)',
        brand: 'rgb(var(--color-brand) / <alpha-value>)',
      },
      borderRadius: {
        card: 'var(--radius-card)',
      },
    },
  },
}

The <alpha-value> placeholder is what makes Tailwind's opacity modifier work — bg-brand/50 will correctly resolve to rgb(124 58 237 / 0.5) because Tailwind substitutes 0.5 for <alpha-value> at class-generation time. Skip the placeholder and the modifier silently does nothing.

As for darkMode, the 'class' strategy is almost always the right call. The 'media' strategy respects OS-level preference only — no user toggle, no per-session override. Honestly, any app with a theme switcher needs 'class', where you toggle dark on <html>. If you're building interfaces with heavy glassmorphism or dark-mode aurora effects — check out the aurora components for reference — the 'class' strategy gives you the control to swap CSS variable values and get a seamless day/night transition without a full re-render.

Putting It All Together: A Production-Grade Config

Here's what a real config looks like when all of these pieces compose — preset-aware, safelist-minimal, plugin-powered, CSS-variable-integrated. This isn't a toy example. It's closer to what ships in design-system repos that serve multiple apps.

// tailwind.config.js
const plugin = require('tailwindcss/plugin')
const { fontFamily } = require('tailwindcss/defaultTheme')

/** @type {import('tailwindcss').Config} */
module.exports = {
  presets: [require('@acme/tokens/tailwind.preset')],
  darkMode: 'class',
  content: [
    './src/**/*.{ts,tsx,mdx}',
    // Scan JSON token files too
    { files: ['./tokens/**/*.json'], transform: (c) => Object.values(JSON.parse(c)).join(' ') },
  ],
  safelist: [
    // CMS-driven badge colors — only what's possible in the schema
    { pattern: /bg-(emerald|rose|amber|violet)-500/, variants: ['hover', 'dark'] },
  ],
  theme: {
    extend: {
      fontFamily: {
        sans: ['Inter var', ...fontFamily.sans],
        mono: ['JetBrains Mono', ...fontFamily.mono],
      },
      colors: {
        surface: 'rgb(var(--color-surface) / <alpha-value>)',
        brand: 'rgb(var(--color-brand) / <alpha-value>)',
      },
      animation: {
        'fade-in': 'fadeIn 0.3s ease-out both',
        'slide-up': 'slideUp 0.4s cubic-bezier(0.16,1,0.3,1) both',
      },
      keyframes: {
        fadeIn: { from: { opacity: 0 }, to: { opacity: 1 } },
        slideUp: { from: { transform: 'translateY(16px)', opacity: 0 }, to: { transform: 'translateY(0)', opacity: 1 } },
      },
    },
  },
  plugins: [
    require('@tailwindcss/typography'),
    plugin(({ addUtilities }) => {
      addUtilities({
        '.glass': {
          background: 'rgba(255,255,255,0.08)',
          backdropFilter: 'blur(12px)',
          border: '1px solid rgba(255,255,255,0.12)',
        },
      })
    }),
  ],
}

That .glass utility at the bottom is a practical example of why custom plugins beat one-off inline styles — it encapsulates the 24px blur, the translucent fill, and the edge border into a single class. Pair it with the glassmorphism generator to dial in the exact values before committing them.

The real win here is that any developer on the team can open this file and understand the full token surface in under five minutes. No hunting through SCSS variables, no wondering which --color-* custom properties are active. The config is the contract.

That said, don't let the config file become a dumping ground. If a plugin exceeds 40 lines, it belongs in its own file. If you have more than eight entries in safelist, audit them — half are usually classes that can be made static with a quick refactor.

FAQ

What's the difference between `theme` and `theme.extend` in tailwind.config.js?

theme replaces Tailwind's defaults for that key entirely. theme.extend merges your values alongside the defaults. Use bare theme when you want zero defaults; use extend for everything else.

Why are my dynamic Tailwind classes not showing up in production?

The JIT scanner can't detect classes assembled at runtime (template literals, object maps). Add them to safelist with a regex pattern, or refactor so the full class string appears as a static literal in your source.

Can I share a Tailwind config across multiple apps in a monorepo?

Yes — create a preset file and load it via the presets array in each app's config. Each app can then override or extend the preset without touching shared tokens.

Do CSS variable colors work with Tailwind's opacity modifier syntax like `bg-brand/50`?

Only if you use the <alpha-value> placeholder in your config: 'rgb(var(--color-brand) / <alpha-value>)'. Without it, the opacity modifier silently produces broken output.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Tailwind Color Customization: Extend, Override, Semantic AliasesAdvanced Glassmorphism in Tailwind: Multi-Layer Glass EffectsWhat Is Glassmorphism? A Free React + Tailwind GuideFree Stacked Cards Component for React — Cards Stack Animation