tailwind-expert
Deep expertise in Tailwind CSS for building scalable, maintainable UI systems. Covers design token architecture, component extraction strategy, responsive design with container queries, dark mode, animations, arbitrary values, plugin authoring, and Tailwind v4 changes. Trigger phrases: styling with
Tailwind CSS Expert
Tailwind is a utility-first CSS framework that, used well, produces a consistent design language with nearly zero custom CSS. The trap most teams fall into is treating it like inline styles — slapping arbitrary values everywhere — rather than leveraging its constraint-based design system. This skill covers how to use Tailwind the way its authors intended: as an opinionated design token layer.
Core Mental Model
Tailwind's power is in its design constraints. Every class maps to a design decision. When you reach for text-[17px] you're escaping the system; when you reach for text-lg you're using it. Think of tailwind.config.js (or in v4, your CSS file) as your design token registry — the single source of truth for your visual language. The utility classes are just the delivery mechanism.
The key insight: Tailwind classes are not CSS shorthand. They're a vocabulary for communicating design decisions. p-4 doesn't mean "16px padding" — it means "use spacing unit 4 from the design system."
Resist the urge to @apply everything into components. Tailwind's creator has said @apply defeats the purpose. Use it sparingly and deliberately.
Design Tokens via theme.extend
Extend, don't replace. Always use theme.extend to add tokens so you keep Tailwind's defaults as a safety net.
// tailwind.config.js
import { fontFamily } from 'tailwindcss/defaultTheme'
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{ts,tsx,mdx}'],
darkMode: 'class',
theme: {
extend: {
colors: {
// Map to CSS custom properties for runtime theming
brand: {
50: 'hsl(var(--brand-50) / <alpha-value>)',
500: 'hsl(var(--brand-500) / <alpha-value>)',
900: 'hsl(var(--brand-900) / <alpha-value>)',
},
surface: {
DEFAULT: 'hsl(var(--surface) / <alpha-value>)',
raised: 'hsl(var(--surface-raised) / <alpha-value>)',
overlay: 'hsl(var(--surface-overlay) / <alpha-value>)',
},
// Semantic tokens (preferred over raw palette in components)
foreground: 'hsl(var(--foreground) / <alpha-value>)',
'muted-foreground': 'hsl(var(--muted-foreground) / <alpha-value>)',
},
fontFamily: {
sans: ['var(--font-sans)', ...fontFamily.sans],
mono: ['var(--font-mono)', ...fontFamily.mono],
},
spacing: {
// Only add tokens for values outside 0-96 scale
'18': '4.5rem',
'88': '22rem',
'128': '32rem',
},
borderRadius: {
// Semantic radius tokens
card: 'var(--radius-card)',
input: 'var(--radius-input)',
},
keyframes: {
'slide-up': {
from: { opacity: '0', transform: 'translateY(8px)' },
to: { opacity: '1', transform: 'translateY(0)' },
},
'fade-in': {
from: { opacity: '0' },
to: { opacity: '1' },
},
},
animation: {
'slide-up': 'slide-up 200ms cubic-bezier(0.16, 1, 0.3, 1)',
'fade-in': 'fade-in 150ms ease-out',
},
},
},
plugins: [
require('@tailwindcss/typography'),
require('@tailwindcss/container-queries'),
// Custom plugin (see Plugin Authoring below)
require('./src/tailwind/plugins/focus-ring'),
],
}
/* globals.css — CSS variable definitions */
@layer base {
:root {
--brand-50: 210 100% 97%;
--brand-500: 210 100% 56%;
--brand-900: 210 100% 15%;
--surface: 0 0% 100%;
--surface-raised: 0 0% 98%;
--surface-overlay: 0 0% 95%;
--foreground: 222 47% 11%;
--muted-foreground: 215 16% 47%;
--radius-card: 0.75rem;
--radius-input: 0.5rem;
}
.dark {
--surface: 222 47% 8%;
--surface-raised: 222 47% 11%;
--surface-overlay: 222 47% 14%;
--foreground: 210 40% 98%;
--muted-foreground: 215 20% 65%;
}
}
Component Extraction Strategy
The rule: Stay inline until you feel actual pain, then extract to a component — not to a CSS class.
Inline utilities → (pain) → React/Vue component → (pain) → @apply as last resort
When to keep inline
- One-off layout (
flex items-center gap-3 p-4) - Page-specific styles that won't recur
- Prototyping — extract later once patterns emerge
When to extract a component
- The same combination of classes appears in 3+ places
- The component has behavior (state, props, events)
- You need prop-driven variants
// Good: extract to a component with variants — not a CSS class
type ButtonProps = {
variant?: 'primary' | 'secondary' | 'ghost'
size?: 'sm' | 'md' | 'lg'
}
const variantClasses = {
primary: 'bg-brand-500 text-white hover:bg-brand-600 shadow-sm',
secondary: 'bg-surface-raised text-foreground border border-border hover:bg-surface-overlay',
ghost: 'text-muted-foreground hover:text-foreground hover:bg-surface-raised',
}
const sizeClasses = {
sm: 'h-8 px-3 text-sm rounded-input',
md: 'h-10 px-4 text-sm rounded-input',
lg: 'h-12 px-6 text-base rounded-input',
}
export function Button({ variant = 'primary', size = 'md', className, ...props }: ButtonProps & React.ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
className={cn(
'inline-flex items-center justify-center font-medium transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2',
'disabled:pointer-events-none disabled:opacity-50',
variantClasses[variant],
sizeClasses[size],
className,
)}
{...props}
/>
)
}
When @apply is acceptable (rarely)
- Third-party HTML you can't add classes to
- CSS Modules integration where you need a bridge
- Truly shared base styles (e.g., a
.prose-overridefor article content)
/* Acceptable @apply: third-party widget you can't touch */
.third-party-widget a {
@apply text-brand-500 underline hover:text-brand-600;
}
Responsive Design
Tailwind is mobile-first. Unprefixed classes apply to all sizes; prefixed classes apply at and above that breakpoint.
// Mobile-first responsive layout
<div className="
flex flex-col gap-4 /* mobile: stack */
md:flex-row md:gap-6 /* tablet+: side by side */
lg:gap-8 /* desktop+: more gap */
">
Container Queries (preferred for components)
Container queries scope responsiveness to the component's container, not the viewport. Essential for components that appear in variable-width contexts.// Wrap the container
<div className="@container">
<div className="
flex flex-col
@md:flex-row /* when container is ≥ 28rem */
@lg:gap-8 /* when container is ≥ 32rem */
">
<img className="w-full @md:w-48 @md:flex-shrink-0" />
<div className="flex-1" />
</div>
</div>
// tailwind.config.js — name your container breakpoints
theme: {
extend: {
containers: {
'2xs': '16rem',
xs: '20rem',
// sm, md, lg, xl, 2xl are included by default
}
}
}
Dark Mode
Use class strategy with next-themes for full control. Never use media strategy if you want a toggle.
// tailwind.config.js
darkMode: 'class'
// app/providers.tsx
import { ThemeProvider } from 'next-themes'
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
)
}
// ThemeToggle component
import { useTheme } from 'next-themes'
export function ThemeToggle() {
const { theme, setTheme } = useTheme()
return (
<button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
<Sun className="dark:hidden" />
<Moon className="hidden dark:block" />
</button>
)
}
// Using dark: prefix in components
<div className="bg-white dark:bg-slate-950 text-slate-900 dark:text-slate-50">
<p className="text-muted-foreground dark:text-slate-400">Subtle text</p>
</div>
// With CSS variables (preferred — no dark: prefix needed)
<div className="bg-surface text-foreground">
<p className="text-muted-foreground">Subtle text</p>
</div>
Animations and Transitions
// Transition utilities
<button className="
transition-colors duration-150 ease-in-out
bg-brand-500 hover:bg-brand-600
">
// Multi-property transition
<div className="
transition-[transform,opacity] duration-200 ease-out
data-[state=open]:opacity-100 data-[state=closed]:opacity-0
data-[state=open]:translate-y-0 data-[state=closed]:-translate-y-2
">
// Custom keyframe animations (defined in config above)
<div className="animate-slide-up">
<div className="animate-fade-in">
// Reduced motion respect (always include this)
<div className="
animate-slide-up
motion-reduce:animate-none motion-reduce:transition-none
">
Arbitrary Values — Use Sparingly
Arbitrary values [...] are an escape hatch for one-off values not in your design system. Reaching for them frequently signals a gap in your token system.
// OK: truly one-off visual adjustment
<div className="top-[57px]"> {/* matches specific nav height */}
<div className="w-[calc(100%-2rem)]">
// BAD: arbitrary value that should be a token
<div className="text-[14px]"> {/* use text-sm instead */}
<div className="p-[12px]"> {/* use p-3 (12px = 0.75rem = spacing-3) */}
// Arbitrary CSS properties (v3.3+)
<div className="[mask-image:linear-gradient(to_bottom,black,transparent)]">
<div className="[grid-template-columns:repeat(3,minmax(200px,1fr))]">
Rule: If you write the same arbitrary value in 2+ places, add it as a design token.
Plugin Authoring
// src/tailwind/plugins/focus-ring.js
const plugin = require('tailwindcss/plugin')
module.exports = plugin(function({ addUtilities, addComponents, theme, e }) {
// addBase: CSS applied globally (like @layer base)
// addComponents: CSS with higher specificity (like @layer components)
// addUtilities: CSS with lowest specificity (like @layer utilities)
// Custom focus ring utility
addUtilities({
'.focus-ring': {
'&:focus-visible': {
outline: `2px solid ${theme('colors.brand.500')}`,
outlineOffset: '2px',
},
},
'.focus-ring-inset': {
'&:focus-visible': {
outline: `2px solid ${theme('colors.brand.500')}`,
outlineOffset: '-2px',
},
},
})
// Component-level CSS
addComponents({
'.card': {
backgroundColor: theme('colors.surface.DEFAULT'),
borderRadius: theme('borderRadius.card'),
border: `1px solid ${theme('colors.border', '#e5e7eb')}`,
padding: theme('spacing.6'),
boxShadow: theme('boxShadow.sm'),
},
})
})
Tailwind v4 — CSS-First Configuration
Tailwind v4 moves configuration into CSS. No more tailwind.config.js for most projects.
/* app.css — v4 configuration */
@import "tailwindcss";
/* Design tokens via @theme */
@theme {
--color-brand-50: hsl(210 100% 97%);
--color-brand-500: hsl(210 100% 56%);
--font-sans: "Inter", sans-serif;
--radius-card: 0.75rem;
/* Custom animation */
--animate-slide-up: slide-up 200ms ease-out;
@keyframes slide-up {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
}
/* Custom utilities */
@utility focus-ring {
&:focus-visible {
outline: 2px solid var(--color-brand-500);
outline-offset: 2px;
}
}
shadcn/ui Patterns
shadcn/ui uses Tailwind + CSS variables + Radix UI. The pattern is canonical for component libraries.
// cn() utility — always use this instead of string concatenation
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// Usage — twMerge handles conflicting classes (last wins)
cn('px-4 py-2', 'px-6') // → 'py-2 px-6'
cn('text-red-500', isError && 'text-red-700')
cn(baseClasses, props.className) // consumer can override
Anti-Patterns
❌ Arbitrary values as the default — text-[14px] p-[12px] everywhere means you're not using the design system.
❌ @apply for component styles — Extract a React component instead. @apply breaks the "single source of truth" and produces larger CSS.
❌ Replacing theme instead of extending — theme: { colors: { ... } } removes ALL default Tailwind colors including white, black, transparent. Always use theme.extend.
❌ Using raw Tailwind colors in dark mode — bg-gray-900 as dark background means you need dark:bg-gray-900 everywhere. Use CSS-variable-backed semantic tokens instead.
❌ Not tree-shaking — Ensure your content array covers all files that use Tailwind classes. Forgetting dynamically constructed class names ('text-' + color) is common; use safelist or keep full class strings.
❌ Constructing class names dynamically:
// BAD — Tailwind can't statically analyze this
const color = 'red'
<div className={`text-${color}-500`}> // 'text-red-500' won't be in output
// GOOD — full class strings must appear in source
const colorMap = { red: 'text-red-500', blue: 'text-blue-500' }
<div className={colorMap[color]}>
Quick Reference
| Task | Approach |
| Mobile-first responsive | Unprefixed → sm: → md: → lg: → xl: |
| Component-relative responsive | @container + @sm: @md: etc. |
| Dark mode | class strategy + next-themes + CSS variable tokens |
| Conflict-safe class merging | cn() from clsx + tailwind-merge |
| Shared style in 3+ places | Extract React component, not @apply |
| Value outside scale | Check if it should be a token first |
| Custom animation | Define in theme.extend.keyframes + theme.extend.animation |
| Plugin order matters? | Yes — plugins run after core, order affects specificity |
| Tailwind v4 config | CSS @theme block, no config file needed |
| shadcn/ui base utility | cn() = twMerge(clsx(...inputs)) |
0.25rem = 4px. So p-4 = 16px, p-6 = 24px, p-8 = 32px.Skill Information
- Source
- MoltbotDen
- Category
- Web Development
- Repository
- View on GitHub
Related Skills
applying-brand-guidelines
This skill applies consistent corporate branding and styling to all generated documents including colors, fonts, layouts, and messaging
Anthropic Cookbooksazure-messaging-webpubsub-java
Build real-time web applications with Azure Web PubSub SDK for Java. Use when implementing WebSocket-based messaging, live updates, chat applications, or server-to-client push notifications.
Microsoftazure-messaging-webpubsubservice-py
Azure Web PubSub Service SDK for Python. Use for real-time messaging, WebSocket connections, and pub/sub patterns. Triggers: "azure-messaging-webpubsubservice", "WebPubSubServiceClient", "real-time", "WebSocket", "pub/sub".
Microsoftbrand-guidelines
Applies Anthropic's official brand colors and typography to any sort of artifact that may benefit from having Anthropic's look-and-feel. Use it when brand colors or style guidelines, visual formatting, or company design standards apply.
Anthropiccanvas-design
Create beautiful visual art in .png and .pdf documents using design philosophy. You should use this skill when the user asks to create a poster, piece of art, design, or other static piece. Create original visual designs, never copying existing artists' work to avoid copyright violations.
Anthropic