---
name: tailwind-css-coding
description: Apply when writing or editing Tailwind CSS classes in any template or component file. Behavioral corrections for dynamic styling, class composition, responsive design, dark mode, interaction states, accessibility, and common antipatterns. Project conventions always override these defaults.
---
# Tailwind CSS Coding
Match the project's existing conventions. When uncertain, read 2-3 existing components to infer the local style. Check `package.json` for the `tailwindcss` version. v4 signals: `@tailwindcss/postcss` or `@tailwindcss/vite` in deps, `@import "tailwindcss"` in CSS, `@theme {}` blocks. v3 signals: `tailwind.config.js` with `module.exports`, `@tailwind base;` directives, `autoprefixer` as separate dep. These defaults apply only when the project has no established convention.
## Never rules
These are unconditional. They prevent broken builds, invisible bugs, and inaccessible UI regardless of project style.
- **Never construct class names dynamically** -- Tailwind's compiler scans source files as plain text with regex. It never executes code. Template literals, string concatenation, and interpolation produce classes the compiler cannot find. Use lookup maps of complete static strings.
```tsx
// Wrong: compiler cannot extract "bg-red-500" from this
const cls = `bg-${color}-500`;
// Correct: every class is a complete static string
const bgMap = {
red: "bg-red-500",
blue: "bg-blue-500",
} as const;
const cls = bgMap[color];
```
- **Never use template literal concatenation for class composition** -- CSS source order determines which class wins when two utilities target the same property, not HTML attribute order. `p-4 p-6` is unpredictable. Use `cn()` (clsx + tailwind-merge) to merge classes safely.
```tsx
// Wrong: conflicting padding, last-in-source wins (not last-in-string)
className={`p-4 ${isLarge ? "p-6" : ""}`}
// Correct: tailwind-merge resolves conflicts deterministically
import { cn } from "@/lib/utils";
className={cn("p-4", isLarge && "p-6")}
```
- **Never use arbitrary values when a design token exists** -- `p-[16px]` is `p-4`. `bg-[#3b82f6]` is `bg-blue-500`. `w-[100%]` is `w-full`. `text-[14px]` is `text-sm`. Arbitrary values bypass the design system and create inconsistency.
- **Never omit interaction states on interactive elements** -- every button and link needs `hover:`, `focus-visible:`, and `disabled:` states. Add `transition-colors` for smooth feedback. In v4: add `cursor-pointer` explicitly -- Preflight no longer sets it on buttons.
```tsx
// Wrong: no interaction feedback
// Correct: full interaction states (v4: include cursor-pointer)
```
- **Never use `@apply` for patterns extractable to components** -- extract a React/Vue/Svelte component instead. `@apply` is only for third-party library overrides, CMS/Markdown HTML, and non-component template languages.
- **Never forget dark mode counterparts** -- every `bg-`, `text-`, and `border-` color needs a `dark:` variant, or use CSS variable theming to handle both modes in one declaration.
- **Never use `sm:` thinking it means "small screens"** -- `sm:` means 640px AND ABOVE. Unprefixed utilities apply to all screens (mobile-first). Write base styles for mobile, then layer breakpoints upward.
```html
Only on small screens
Mobile only
Desktop only
```
- **Never hallucinate class names** -- common fakes: `flex-center` (use `flex items-center justify-center`), `text-bold` (use `font-bold`), `bg-grey-500` (American spelling: `bg-gray-500`), `d-flex` (Bootstrap, not Tailwind).
- **Never output conflicting utilities** -- `p-4 p-6` is unpredictable. One value per CSS property. Don't redundantly set defaults (`flex flex-row`, `flex flex-nowrap`).
- **Never forget accessibility** -- use `sr-only` for icon-only button labels, `focus:not-sr-only` for skip links. Every interactive element needs visible focus indication via `focus-visible:`.
## Dynamic styling
For conditional classes, use `cn()` (clsx + tailwind-merge). For truly dynamic values that cannot be enumerated (user-set colors, computed positions), use inline `style` props -- these bypass the compiler entirely and always work.
```tsx
// Conditional classes: cn()
// Truly dynamic: inline style
```
For variant-driven styling, use a lookup map with complete static strings:
```tsx
const sizeClasses = {
sm: "px-2 py-1 text-sm",
md: "px-4 py-2 text-base",
lg: "px-6 py-3 text-lg",
} as const;
function Button({ size = "md", className, ...props }: ButtonProps) {
return ;
}
```
For components with 2+ variant dimensions, consider `cva` from class-variance-authority.
When the compiler must see classes that only appear in dynamic data (CMS content, database values), safelist them. In v4: `@source inline("bg-red-500 bg-blue-500")`. In v3: `safelist` array in `tailwind.config.js`.
## Class composition
The `cn()` helper combines `clsx` (conditional joining) with `tailwind-merge` (conflict resolution). Standard setup:
```ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
```
Use `cn()` whenever merging external `className` props with internal defaults -- raw concatenation silently breaks when both sides set the same property.
```tsx
// Wrong: parent's p-6 may or may not override internal p-4
function Card({ className }: { className?: string }) {
return ;
}
// Correct: tailwind-merge ensures parent overrides win
function Card({ className }: { className?: string }) {
return ;
}
```
## Responsive design
Mobile-first: write base styles for the smallest screen, then add breakpoints upward. Always order breakpoints `sm:` -> `md:` -> `lg:` -> `xl:` -> `2xl:`. Never skip to `lg:` without considering the gap.
```html
```
In v4: container queries are built-in (no plugin). Use `@container` for component-scoped responsive design:
```tsx
// Parent declares a container
{/* Child responds to container width, not viewport */}
{children}
```
## Dark mode
Cover every visible color. A component with `bg-white text-gray-900` needs `dark:bg-gray-900 dark:text-white`. Missing a single `dark:` variant causes unreadable text or invisible elements.
Better approach -- CSS variable theming. Define colors once, switch palettes:
```css
/* v4: @theme block */
@theme {
--color-surface: #ffffff;
--color-on-surface: #111827;
}
@custom-variant dark (&:where(.dark, .dark *));
.dark {
--color-surface: #111827;
--color-on-surface: #f9fafb;
}
```
Then use `bg-surface text-on-surface` everywhere -- no `dark:` variants needed per component.
## Interaction states
Minimum states for buttons: `hover:`, `focus-visible:`, `disabled:`, `transition-colors`. Minimum for inputs: `focus:`, `disabled:`, `placeholder:`. Minimum for links: `hover:`, `focus-visible:`.
```tsx
// Complete button pattern