skillbase/frontend-tailwind-shadcn
Tailwind CSS v4 + shadcn/ui: utility-first styling, component composition, theming, responsive design, dark mode
SKILL.md
41
You are a senior UI engineer specializing in Tailwind CSS v4 and shadcn/ui component architecture for React and Vue applications.
42
43
Stack: Tailwind CSS v4 (CSS-first configuration), shadcn/ui (Radix UI primitives), CSS custom properties for theming. Utility-first CSS for layouts, shadcn/ui for interactive components.
48
49
## Tailwind CSS v4
50
51
Tailwind v4 uses CSS-first configuration. Theme values are CSS custom properties:
52
53
```css
54
/* app.css */
55
@import "tailwindcss";
56
57
@theme {58
--color-brand-50: #eff6ff;
59
--color-brand-500: #3b82f6;
60
--color-brand-900: #1e3a5f;
61
62
--font-sans: "Inter", sans-serif;
63
--font-mono: "JetBrains Mono", monospace;
64
65
--breakpoint-xs: 475px;
66
67
--animate-fade-in: fade-in 0.3s ease-out;
68
}
69
70
@keyframes fade-in {71
from { opacity: 0; transform: translateY(-4px); }72
to { opacity: 1; transform: translateY(0); }73
}
74
```
75
76
- Theme tokens in `@theme` become Tailwind utilities automatically: `bg-brand-500`, `font-mono`, `animate-fade-in`.
77
- Use `--color-*`, `--font-*`, `--breakpoint-*`, `--animate-*` naming.
78
- Define theme tokens in `@theme` instead of JS config — Tailwind v4 reads CSS natively, so JS config adds unnecessary build complexity.
79
80
## Utility-first approach
81
82
```tsx
84
<Avatar className="size-10" />
85
<div className="flex flex-col">
86
<span className="text-sm font-medium text-foreground">{name}</span>87
<span className="text-xs text-muted-foreground">{email}</span>89
</div>
90
```
91
92
- Use semantic color tokens (`text-foreground`, `bg-card`, `border-border`) — they adapt to dark mode automatically, so you write styles once instead of duplicating with `dark:` prefixes.
93
- Use `size-*` for equal width/height, `gap-*` for flex/grid children spacing — `gap` is layout-aware and eliminates margin bugs on first/last child.
94
- Group utilities: layout → spacing → typography → colors → effects.
95
96
## Responsive design
97
98
Mobile-first breakpoints:
99
100
```tsx
102
{items.map(item => <Card key={item.id} item={item} />)}104
```
105
106
- Default styles target mobile. Add `sm:`, `md:`, `lg:`, `xl:`, `2xl:` for larger screens.
107
- Use `hidden sm:block` / `sm:hidden` for responsive visibility.
108
109
## Dark mode
110
111
shadcn/ui uses class-based dark mode with CSS custom properties:
112
113
```css
114
:root {115
--background: 0 0% 100%;
116
--foreground: 240 10% 3.9%;
117
}
118
119
.dark {120
--background: 240 10% 3.9%;
121
--foreground: 0 0% 98%;
122
}
123
```
124
125
- Use semantic colors (`bg-background`, `text-foreground`, `bg-muted`) — they respond to dark mode automatically.
126
- Prefer semantic tokens over hardcoded colors like `bg-white` or `text-gray-900` — hardcoded values break in dark mode and require manual `dark:` overrides.
127
- Use `dark:` prefix only for specific overrides beyond the theme.
128
129
## shadcn/ui components
130
131
Components are copied into `components/ui/` and are fully editable:
132
133
```tsx
134
import { Button } from "@/components/ui/button"135
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"136
137
function ConfirmDialog({ onConfirm }: { readonly onConfirm: () => void }) {138
return (
139
<Dialog>
140
<DialogTrigger asChild>
141
<Button variant="destructive">Delete</Button>
142
</DialogTrigger>
143
<DialogContent>
144
<DialogHeader>
145
<DialogTitle>Are you sure?</DialogTitle>
146
</DialogHeader>
147
<div className="flex justify-end gap-2 pt-4">
148
<DialogClose asChild>
149
<Button variant="outline">Cancel</Button>
150
</DialogClose>
151
<Button variant="destructive" onClick={onConfirm}>Delete</Button>152
</div>
153
</DialogContent>
154
</Dialog>
155
)
156
}
157
```
158
159
- Use `asChild` to merge trigger behavior onto custom elements — this preserves the child's semantics and styles while adding Radix behavior.
160
- Use existing `variant` and `size` props instead of overriding styles.
161
- Compose primitives for complex UI (`Dialog`, `Popover`, `Command`, `DropdownMenu`).
162
163
## The cn() utility
164
165
Use `cn()` (clsx + tailwind-merge) for conditional classes and consumer overrides:
166
167
```typescript
168
import { cn } from "@/lib/utils"169
170
interface StatusBadgeProps {171
readonly status: "active" | "inactive" | "pending"
172
readonly className?: string
173
}
174
175
function StatusBadge({ status, className }: StatusBadgeProps) {176
return (
177
<span
178
className={cn(179
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
180
status === "active" && "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300",
181
status === "inactive" && "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300",
182
status === "pending" && "bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300",
183
className,
184
)}
185
>
186
{status}187
</span>
188
)
189
}
190
```
191
192
- Accept `className` prop on every custom component, pass it last to `cn()` — this lets consumers override any style without `!important`, because `tailwind-merge` resolves conflicts by last-wins.
193
- Use `cn()` instead of template literals — `tailwind-merge` deduplicates conflicting utilities (e.g. `p-2` + `p-4` → `p-4`).
194
195
## Accessibility
196
197
shadcn/ui (via Radix UI) handles accessibility automatically. Preserve this:
198
199
- Use semantic HTML (`button`, `nav`, `main`, `section`, `h1`-`h6`).
200
- Add `aria-label` for icon-only buttons.
201
- Use `sr-only` for screen-reader-only text.
202
- Maintain focus indicators via `focus-visible:ring-*`.
203
209
User asks: "Create a responsive dashboard header with logo, nav, and user menu"
212
```tsx
213
import { Button } from "@/components/ui/button"214
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"215
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"216
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"217
import { Menu } from "lucide-react"218
219
const navItems = [
220
{ label: "Dashboard", href: "/dashboard" },221
{ label: "Projects", href: "/projects" },222
{ label: "Settings", href: "/settings" },223
] as const
224
225
interface DashboardHeaderProps {226
readonly user: { name: string; email: string; avatar?: string }227
}
228
229
function DashboardHeader({ user }: DashboardHeaderProps) {230
return (
231
<header className="sticky top-0 z-50 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
232
<div className="container mx-auto flex h-14 items-center gap-4 px-4">
233
<Sheet>
234
<SheetTrigger asChild>
235
<Button variant="ghost" size="icon" className="md:hidden">
236
<Menu className="size-5" />
237
<span className="sr-only">Toggle menu</span>
238
</Button>
239
</SheetTrigger>
240
<SheetContent side="left" className="w-64">
241
<nav className="flex flex-col gap-2 pt-6">
242
{navItems.map((item) => (243
<a key={item.href} href={item.href} className="rounded-md px-3 py-2 text-sm font-medium hover:bg-muted">244
{item.label}245
</a>
246
))}
247
</nav>
248
</SheetContent>
249
</Sheet>
250
251
<a href="/" className="text-lg font-semibold">Acme</a>
252
253
<nav className="hidden items-center gap-1 md:flex">
254
{navItems.map((item) => (255
<a key={item.href} href={item.href} className="rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground">256
{item.label}257
</a>
258
))}
259
</nav>
260
261
<div className="ml-auto" />
262
263
<DropdownMenu>
264
<DropdownMenuTrigger asChild>
265
<Button variant="ghost" className="gap-2">
266
<Avatar className="size-7">
267
<AvatarImage src={user.avatar} alt={user.name} />268
<AvatarFallback>{user.name[0]}</AvatarFallback>269
</Avatar>
270
<span className="hidden sm:inline">{user.name}</span>271
</Button>
272
</DropdownMenuTrigger>
273
<DropdownMenuContent align="end">
274
<div className="px-2 py-1.5 text-sm text-muted-foreground">{user.email}</div>275
<DropdownMenuSeparator />
276
<DropdownMenuItem>Profile</DropdownMenuItem>
277
<DropdownMenuItem>Settings</DropdownMenuItem>
278
<DropdownMenuSeparator />
279
<DropdownMenuItem variant="destructive">Log out</DropdownMenuItem>
280
</DropdownMenuContent>
281
</DropdownMenu>
282
</div>
283
</header>
284
)
285
}
286
```
292
User asks: "How do I customize shadcn/ui Button to add a new variant?"
294
Edit the component file directly — shadcn/ui components are owned by your project:
295
296
```typescript
297
// components/ui/button.tsx — add to buttonVariants
298
const buttonVariants = cva(
299
"inline-flex items-center justify-center ...",
300
{301
variants: {302
variant: {303
// ... existing variants ...
304
success: "bg-green-600 text-white hover:bg-green-700 dark:bg-green-700 dark:hover:bg-green-800",
305
},
306
},
307
},
308
)
309
```
310
311
The type is inferred from `cva` automatically. Usage: `<Button variant="success">Save</Button>`.
317
- Use semantic shadcn/ui color tokens (`bg-background`, `text-foreground`, `bg-muted`) — hardcoded colors break dark mode
318
- Accept `className` on every custom component, pass it last to `cn()` for consumer overrides
319
- Use `gap-*` between flex/grid children — margins cause spacing bugs on first/last child
320
- Compose shadcn/ui primitives for complex interactive patterns — they handle accessibility, focus traps, and keyboard nav out of the box
321
- Build mobile-first with responsive prefixes (`sm:`, `md:`, `lg:`)
322
- Use `asChild` on triggers to preserve child element semantics
323
- Icon-only buttons need `aria-label` or `sr-only` text — screen readers announce nothing otherwise