Tailwind Design System: Advanced Patterns
Advanced Tailwind CSS v4 patterns including animations, dark mode theming, custom utilities, theme modifiers, namespace overrides, and the v3-to-v4 migration checklist.
Pattern 5: Native CSS Animations (v4)
/* In your CSS file - native @starting-style for entry animations */
@theme {
--animate-dialog-in: dialog-fade-in 0.2s ease-out;
--animate-dialog-out: dialog-fade-out 0.15s ease-in;
}
@keyframes dialog-fade-in {
from {
opacity: 0;
transform: scale(0.95) translateY(-0.5rem);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@keyframes dialog-fade-out {
from {
opacity: 1;
transform: scale(1) translateY(0);
}
to {
opacity: 0;
transform: scale(0.95) translateY(-0.5rem);
}
}
/* Native popover animations using @starting-style */
[popover] {
transition:
opacity 0.2s,
transform 0.2s,
display 0.2s allow-discrete;
opacity: 0;
transform: scale(0.95);
}
[popover]:popover-open {
opacity: 1;
transform: scale(1);
}
@starting-style {
[popover]:popover-open {
opacity: 0;
transform: scale(0.95);
}
}// components/ui/dialog.tsx - Using native popover API
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { cn } from '@/lib/utils'
const DialogPortal = DialogPrimitive.Portal
export function DialogOverlay({
className,
ref,
...props
}: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> & {
ref?: React.Ref<HTMLDivElement>
}) {
return (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80',
'data-[state=open]:animate-fade-in data-[state=closed]:animate-fade-out',
className
)}
{...props}
/>
)
}
export function DialogContent({
className,
children,
ref,
...props
}: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
ref?: React.Ref<HTMLDivElement>
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border border-border bg-background p-6 shadow-lg sm:rounded-lg',
'data-[state=open]:animate-dialog-in data-[state=closed]:animate-dialog-out',
className
)}
{...props}
>
{children}
</DialogPrimitive.Content>
</DialogPortal>
)
}Pattern 6: Dark Mode with CSS (v4)
// providers/ThemeProvider.tsx - Simplified for v4
'use client'
import { createContext, useContext, useEffect, useState } from 'react'
type Theme = 'dark' | 'light' | 'system'
interface ThemeContextType {
theme: Theme
setTheme: (theme: Theme) => void
resolvedTheme: 'dark' | 'light'
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
export function ThemeProvider({
children,
defaultTheme = 'system',
storageKey = 'theme',
}: {
children: React.ReactNode
defaultTheme?: Theme
storageKey?: string
}) {
const [theme, setTheme] = useState<Theme>(defaultTheme)
const [resolvedTheme, setResolvedTheme] = useState<'dark' | 'light'>('light')
useEffect(() => {
const stored = localStorage.getItem(storageKey) as Theme | null
if (stored) setTheme(stored)
}, [storageKey])
useEffect(() => {
const root = document.documentElement
root.classList.remove('light', 'dark')
const resolved = theme === 'system'
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: theme
root.classList.add(resolved)
setResolvedTheme(resolved)
// Update meta theme-color for mobile browsers
const metaThemeColor = document.querySelector('meta[name="theme-color"]')
if (metaThemeColor) {
metaThemeColor.setAttribute('content', resolved === 'dark' ? '#09090b' : '#ffffff')
}
}, [theme])
return (
<ThemeContext.Provider value={{
theme,
setTheme: (newTheme) => {
localStorage.setItem(storageKey, newTheme)
setTheme(newTheme)
},
resolvedTheme,
}}>
{children}
</ThemeContext.Provider>
)
}
export const useTheme = () => {
const context = useContext(ThemeContext)
if (!context) throw new Error('useTheme must be used within ThemeProvider')
return context
}
// components/ThemeToggle.tsx
import { Moon, Sun } from 'lucide-react'
import { useTheme } from '@/providers/ThemeProvider'
export function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme()
return (
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
>
<Sun className="size-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute size-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
)
}Advanced v4 Patterns
Custom Utilities with @utility
Define reusable custom utilities:
/* Custom utility for decorative lines */
@utility line-t {
@apply relative before:absolute before:top-0 before:-left-[100vw] before:h-px before:w-[200vw] before:bg-gray-950/5 dark:before:bg-white/10;
}
/* Custom utility for text gradients */
@utility text-gradient {
@apply bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent;
}Theme Modifiers
/* Use @theme inline when referencing other CSS variables */
@theme inline {
--font-sans: var(--font-inter), system-ui;
}
/* Use @theme static to always generate CSS variables (even when unused) */
@theme static {
--color-brand: oklch(65% 0.15 240);
}
/* Import with theme options */
@import "tailwindcss" theme(static);Namespace Overrides
@theme {
/* Clear all default colors and define your own */
--color-*: initial;
--color-white: #fff;
--color-black: #000;
--color-primary: oklch(45% 0.2 260);
--color-secondary: oklch(65% 0.15 200);
/* Clear ALL defaults for a minimal setup */
/* --*: initial; */
}Semi-transparent Color Variants
@theme {
/* Use color-mix() for alpha variants */
--color-primary-50: color-mix(in oklab, var(--color-primary) 5%, transparent);
--color-primary-100: color-mix(
in oklab,
var(--color-primary) 10%,
transparent
);
--color-primary-200: color-mix(
in oklab,
var(--color-primary) 20%,
transparent
);
}Container Queries
@theme {
--container-xs: 20rem;
--container-sm: 24rem;
--container-md: 28rem;
--container-lg: 32rem;
}v3 to v4 Migration Checklist
- [ ] Replace
tailwind.config.tswith CSS@themeblock - [ ] Change
@tailwind base/components/utilitiesto@import "tailwindcss" - [ ] Move color definitions to
@theme { --color-*: value } - [ ] Replace
darkMode: "class"with@custom-variant dark - [ ] Move
@keyframesinside@themeblocks (ensures keyframes output with theme) - [ ] Replace
require("tailwindcss-animate")with native CSS animations - [ ] Update
h-10 w-10tosize-10(new utility) - [ ] Remove
forwardRef(React 19 passes ref as prop) - [ ] Consider OKLCH colors for better color perception
- [ ] Replace custom plugins with
@utilitydirectives
Best Practices
Do's
- Use
@themeblocks - CSS-first configuration is v4's core pattern - Use OKLCH colors - Better perceptual uniformity than HSL
- Compose with CVA - Type-safe variants
- Use semantic tokens -
bg-primarynotbg-blue-500 - Use
size-*- New shorthand forw-* h-* - Add accessibility - ARIA attributes, focus states
Don'ts
- Don't use
tailwind.config.ts- Use CSS@themeinstead - Don't use
@tailwinddirectives - Use@import "tailwindcss" - Don't use
forwardRef- React 19 passes ref as prop - Don't use arbitrary values - Extend
@themeinstead - Don't hardcode colors - Use semantic tokens
- Don't forget dark mode - Test both themes