Utility-First Workflow
Utility-first CSS means composing styles from small, single-purpose classes (flex, gap-4, text-slate-900) directly in the markup. The trade-off is fewer named abstractions in CSS files in exchange for never leaving the HTML to find a style and never naming components by accident.
Patterns, anti-patterns, extraction
EXAMPLE
<!-- 1) Composition from utilities — readable once you know the vocabulary -->
<button class="
inline-flex items-center gap-2
px-4 py-2 rounded-md
bg-indigo-600 text-white text-sm font-medium
hover:bg-indigo-700 active:bg-indigo-800
focus-visible:outline focus-visible:outline-2 focus-visible:outline-indigo-500
disabled:opacity-50 disabled:cursor-not-allowed
transition
">
Save changes
</button>
<!-- vs the BEM equivalent
.button { display:inline-flex; align-items:center; gap:0.5rem;
padding:0.5rem 1rem; border-radius:0.375rem;
background:#4f46e5; color:#fff; … }
.button:hover { … }
.button:active { … }
.button:focus { … }
.button--primary { … }
.button--disabled { … }
-->
<!-- 2) The mental model
1) Open the markup
2) Read styles where they're applied
3) Edit styles where they're applied
4) No 'where is this used?' question because the use IS the style -->
<!-- 3) Theming via design tokens (tailwind.config.js) -->
<script>
module.exports = {
theme: {
extend: {
colors: {
brand: {
50: '#eef2ff',
500: '#4f46e5',
700: '#3730a3',
},
},
spacing: {
72: '18rem',
80: '20rem',
},
borderRadius: { 'card': '0.75rem' },
},
},
};
</script>
<button class="bg-brand-500 hover:bg-brand-700 text-white rounded-card">Save</button>
<!-- Now the button COULDN'T accidentally use a one-off color; the only options are the design tokens. -->
<!-- 4) Arbitrary values — escape hatch for one-offs -->
<div class="top-[117px] grid-cols-[200px_1fr] text-[#fefefe]">…</div>
<!-- Use arbitrary values for genuinely unique constraints, not as a default. -->
<!-- 5) State variants compose with everything -->
<input class="
block w-full px-3 py-2 rounded-md
border border-slate-300 placeholder:text-slate-400
focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500
aria-invalid:border-red-500 aria-invalid:ring-red-500
dark:bg-slate-900 dark:border-slate-700 dark:text-slate-100
" />
<!-- 6) Extracting reusable components — when you HAVE repetition -->
<!-- React example: one Button component, used everywhere -->
<script>
import { cn } from '@/lib/cn'; // small classnames helper
export function Button({ variant = 'primary', className, children, ...props }) {
return (
<button
{...props}
className={cn(
'inline-flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition',
'focus-visible:outline focus-visible:outline-2',
variant === 'primary' && 'bg-indigo-600 text-white hover:bg-indigo-700',
variant === 'secondary' && 'bg-slate-200 text-slate-900 hover:bg-slate-300',
variant === 'ghost' && 'bg-transparent text-slate-700 hover:bg-slate-100',
className,
)}
>
{children}
</button>
);
}
</script>
<!-- 7) @apply — extract to CSS when component extraction is impractical -->
<style>
/* globals.css */
.btn-primary {
@apply inline-flex items-center gap-2 px-4 py-2 rounded-md;
@apply bg-indigo-600 text-white text-sm font-medium;
@apply hover:bg-indigo-700 transition;
}
</style>
<!-- Use @apply sparingly. The whole point of utility-first is colocating style with markup;
wrapping utilities back into class names defeats most of the benefit.
Good cases: legacy CMS templates, third-party widget overrides. -->
<!-- 8) Patterns to reach for
• A 3-column responsive grid in 4 utilities
• A vertical stack with consistent rhythm: 'flex flex-col gap-4'
• A 'cluster' for tags: 'flex flex-wrap gap-2'
• A 'cover' for hero with min height: 'min-h-svh flex flex-col justify-center'
• A 'switcher' that wraps below width: 'flex flex-wrap gap-4 [&>*]:flex-1 [&>*]:min-w-64' -->
<!-- 9) Anti-patterns
• Mixing Tailwind with @apply'd classes named after components (".card", ".button") -->
<!-- → Pick one model; mixing leads to duplicate definitions. -->
<!-- • Listing dozens of utilities in random order -->
<!-- → Group with a logical order (layout / box / typography / color / state); install prettier-plugin-tailwindcss -->
<!-- • Using arbitrary values for the same one-off in many places -->
<!-- → Promote to the theme; it's a real design token now -->
<!-- • Long ternary class strings inline -->
<!-- → Use a cn() helper or class-variance-authority -->
<!-- 10) Code-style helpers -->
<!-- Install prettier-plugin-tailwindcss — it sorts your classes consistently. -->
<script>
// package.json
{ "prettier": { "plugins": ["prettier-plugin-tailwindcss"] } }
</script>
<!-- ESLint plugin to catch invalid utilities, conflicting groups, etc. -->
<!-- npm install -D eslint-plugin-tailwindcss -->
<!-- 11) Tailwind CSS purging — only what you USE ends up in the bundle -->
<!-- tailwind.config.js -->
<script>
module.exports = {
content: [
'./app/**/*.{tsx,ts,html}',
'./components/**/*.{tsx,ts,html}',
'./node_modules/@my-org/ui/**/*.js', // include 3rd-party Tailwind libs you use
],
// Anything not statically found in 'content' is dropped from the output.
};
</script>
<!-- IMPORTANT: don't compose class names dynamically (`bg-${color}-500`) — Tailwind can't see
it during scan; the class gets purged. Use a static map: { red: 'bg-red-500', green: 'bg-green-500' }[color]. -->
<!-- 12) Working with designers
Tailwind's spacing / sizing / typography scale is a language. Once the design system
adopts it, designers ship Figma specs in 'p-4 / text-lg / rounded-lg' terms; engineering
copies them verbatim. No translation layer. -->
<!-- 13) When utility-first ISN'T the right call
• Sites that don't ship a build step (plain WordPress, no node toolchain) — semantic CSS wins
• Truly content-first sites where designers write CSS directly (prose blogs, docs)
• Existing huge CSS codebases — pick at boundaries, don't rewrite the world
• Highly visual generative art — abstractions live in the JS / shader -->
<!-- 14) Common bugs
• Long class lists become 'oh no this is unmaintainable' — extract a component, not @apply
• Two utilities applied for the same property — last one wins; Tailwind detects conflicts in newer versions
• Dark mode classes ignored — darkMode strategy ('class' vs 'media') mismatch with toggle
• Arbitrary value not working — likely a typo or unsupported property
• Animation utilities have no effect — check parent has overflow / transform context -->
Why it matters
Utility-first feels alien for the first day and natural by the third. The trick is to treat the utilities as your design system: when a pattern repeats, extract a component (not an @apply class), keep dynamic class names static-string-friendly so purging works, and install prettier-plugin-tailwindcss so the long class lists at least order themselves.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
<button class="px-4 py-2 rounded bg-emerald-600 text-white hover:bg-emerald-700">Save</button>Try it Yourself »
Exercise
Padding all sides 1rem (4 in scale) is…
class="
"
Three characters.
Discussion
Loading…