The most important advanced Tailwind skill is knowing when to stop using utility classes and extract a component. Everything else (custom tokens, dark mode, v4 changes) follows naturally once you understand where the limits of inline utilities are.
Custom Design Tokens: Extending the Theme
Tailwind's utility classes are built on a configurable theme. Custom design tokens let you extend this theme with your brand's colors, spacing scale, and typography choices so every utility class reflects your brand system instead of Tailwind's defaults.
In Tailwind v3, you extend the theme in tailwind.config.ts:
import type { Config } from "tailwindcss";
const config: Config = {
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
brand: {
50: "#eff6ff",
100: "#dbeafe",
500: "#3b82f6",
900: "#1e3a5f",
},
surface: {
base: "#ffffff",
elevated: "#f8fafc",
overlay: "#f1f5f9",
},
},
fontFamily: {
sans: ["Inter", "system-ui", "sans-serif"],
mono: ["JetBrains Mono", "monospace"],
},
spacing: {
18: "4.5rem",
88: "22rem",
},
},
},
};
export default config;
With these tokens defined, you use bg-brand-500, text-brand-900, bg-surface-elevated, and font-sans as utility classes. Every usage references your design system, not hardcoded values.
Dark Mode: Class vs Media Strategy
Tailwind supports two dark mode strategies: class and media.
Media strategy (darkMode: "media") uses the prefers-color-scheme CSS media query. Dark styles activate automatically based on the user's OS preference. This requires zero JavaScript and is the simpler option. The limitation: users cannot toggle dark mode within your app without changing their OS setting.
Class strategy (darkMode: "class") adds dark styles when a dark class is present on the <html> element. You control when dark mode activates by toggling that class. This requires a small JavaScript implementation to read/store the user's preference and apply the class before first render (to avoid flash of wrong theme).
For most applications where you expose a theme toggle to the user, use the class strategy. For developer tools or simple sites where OS preference is sufficient, media works fine.
Dark mode utilities follow the same pattern:
<div className="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100">
Content
</div>
Arbitrary Values and the JIT Engine
Since Tailwind v3, the JIT (just-in-time) engine is the default. It generates CSS on demand for only the classes you use, instead of generating a full stylesheet and purging. This enables arbitrary values: one-off utility values that are not in your design tokens.
<div className="top-[117px] bg-[#1da1f2] grid-cols-[1fr_2fr_1fr]">
Arbitrary values are useful for values that appear once in your codebase and do not belong in your design tokens. Use them sparingly. If you find yourself using the same arbitrary value in multiple places, promote it to a design token.
You can also use arbitrary CSS properties:
<div className="[mask-image:linear-gradient(to_bottom,black,transparent)]">
This is a safety valve for CSS properties Tailwind does not have utilities for.
Component Extraction: When to Stop Writing Utility Classes
This is the decision Tailwind developers get wrong most often. They either write 30 utility classes on a single element (unreadable) or extract a custom @apply class for every pattern (defeating the purpose of Tailwind).
The right rule: extract a React component, not a CSS class.
When you have a pattern that repeats across multiple files, wrap it in a component:
// Before: repeated in 12 places
<button className="inline-flex items-center justify-center rounded-md bg-brand-500 px-4 py-2 text-sm font-medium text-white hover:bg-brand-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 disabled:opacity-50">
// After: extracted as a component
function Button({ children, ...props }) {
return (
<button
className="inline-flex items-center justify-center rounded-md bg-brand-500 px-4 py-2 text-sm font-medium text-white hover:bg-brand-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 disabled:opacity-50"
{...props}
>
{children}
</button>
);
}
@apply is for non-component contexts only (global styles, third-party HTML you cannot component-ify). Do not use @apply to avoid long class lists in components. Use component extraction instead.
Tailwind v4: The CSS-First Configuration
Tailwind v4 changes the configuration model. Instead of a JavaScript config file, your configuration lives in CSS using the @theme directive:
@import "tailwindcss";
@theme {
--color-brand-500: #3b82f6;
--color-brand-900: #1e3a5f;
--font-sans: "Inter", system-ui, sans-serif;
--spacing-18: 4.5rem;
}
Your CSS variables become utility classes automatically. --color-brand-500 becomes bg-brand-500, text-brand-500, etc.
The plugin system changes in v4: instead of JavaScript plugins, you write CSS using @utility and @variant. The mental model shifts toward CSS-native configuration. Existing v3 projects can migrate incrementally.
shadcn/ui Integration
shadcn/ui is a component library where you copy components directly into your project rather than installing them from npm. Each component is a regular React file using Tailwind classes and Radix UI primitives.
shadcn components reference CSS variables for theming (defined in your globals.css), which means they respond to your dark mode setup automatically. Customizing a shadcn component means editing the actual file in your project. There is no fighting against a library's styles.
/* globals.css */
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
}
Performance: Purge Is Automatic
Tailwind v3 and v4 generate only the CSS for classes actually used in your project. The content configuration in v3 (or the automatic detection in v4) scans your source files. You do not ship a 3MB CSS file in production. A typical Next.js app with extensive Tailwind usage might generate 10-30KB of CSS.
The one performance pitfall: dynamic class construction. Tailwind's scanner looks for complete class name strings. If you build class names by concatenating strings at runtime, the scanner cannot detect them and they will not be included in the output. Always use complete class names:
// Wrong: scanner cannot see these classes
const color = isDanger ? "red" : "blue";
<div className={`bg-${color}-500`}>
// Correct: complete class names are visible to the scanner
const className = isDanger ? "bg-red-500" : "bg-blue-500";
<div className={className}>
Keep Reading
- TypeScript for React Developers — typing your Tailwind-based component props correctly
- Next.js App Router Patterns 2026 — CSS organization in an App Router project
- Storybook Component Library Guide — documenting your Tailwind component library
Pristren builds AI-powered software for teams. Zlyqor is our all-in-one workspace — chat, projects, time tracking, AI meeting summaries, and invoicing — in one tool. Try it free.