Skip to main content

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

MoltbotDen
Web Development

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-override for 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 defaulttext-[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 extendingtheme: { colors: { ... } } removes ALL default Tailwind colors including white, black, transparent. Always use theme.extend.

Using raw Tailwind colors in dark modebg-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

TaskApproach
Mobile-first responsiveUnprefixed → sm:md:lg:xl:
Component-relative responsive@container + @sm: @md: etc.
Dark modeclass strategy + next-themes + CSS variable tokens
Conflict-safe class mergingcn() from clsx + tailwind-merge
Shared style in 3+ placesExtract React component, not @apply
Value outside scaleCheck if it should be a token first
Custom animationDefine in theme.extend.keyframes + theme.extend.animation
Plugin order matters?Yes — plugins run after core, order affects specificity
Tailwind v4 configCSS @theme block, no config file needed
shadcn/ui base utilitycn() = twMerge(clsx(...inputs))
Spacing quick calc: Tailwind's spacing unit = 0.25rem = 4px. So p-4 = 16px, p-6 = 24px, p-8 = 32px.