From frontendskills
Use when establishing a design system for a frontend project — defines Tailwind v4 @theme tokens (colors/spacing/radius/fonts), class-based dark mode driven by a persisted theme store, a cn() class merger, and variant-driven primitives via class-variance-authority, with shadcn/ui as the optional component registry.
How this skill is triggered — by the user, by Claude, or both
Slash command
/frontendskills:set-up-design-systemThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
```bash
grep -E '"(class-variance-authority|clsx|tailwind-merge|tailwindcss|tailwind-variants)"' package.json 2>/dev/null
grep -n "@theme\|@custom-variant" src/index.css src/style.css 2>/dev/null
ls components.json 2>/dev/null # shadcn/ui marker
Detect existing tokens, a variant lib, and dark-mode wiring. Prerequisites: Tailwind v4 installed (scaffold-frontend-project), @/ alias, and set-up-state-management (the theme toggle is a UI-state store).
cn() + cva variants.cn/cva; just add the token layer + theme store.React → primitives as .tsx with cva. Vue → primitives as SFCs using the same cva class strings. cn() and @theme are framework-agnostic.
pnpm add class-variance-authority clsx tailwind-merge
(Optional, React: pnpm dlx shadcn@latest init — generates owned, cva-based primitives into your tree. Then skip hand-writing step 7.)
@theme (CSS-first)Tailwind v4 reads tokens from CSS and generates the matching utilities (bg-brand-600, rounded-card, …). Use semantic names and oklch for perceptually-even colors.
/* src/index.css (or src/style.css) */
@import "tailwindcss";
/* class-based dark mode: `dark:` applies under .dark on <html> */
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--color-brand-50: oklch(0.97 0.02 255);
--color-brand-500: oklch(0.62 0.19 255);
--color-brand-600: oklch(0.54 0.20 255);
--radius-card: 0.75rem;
--font-sans: "Inter", system-ui, sans-serif;
}
cn() class merger// src/utils/cn.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
/** Merge class lists, with later Tailwind utilities winning over earlier ones. */
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// src/components/atoms/Button/Button.tsx
import { cva, type VariantProps } from 'class-variance-authority';
import type { ButtonHTMLAttributes } from 'react';
import { cn } from '@/utils/cn';
const button = cva(
'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-600 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
solid: 'bg-brand-600 text-white hover:bg-brand-500',
outline: 'border border-brand-600 text-brand-600 hover:bg-brand-50 dark:hover:bg-brand-600/10',
ghost: 'text-brand-600 hover:bg-brand-50 dark:hover:bg-brand-600/10',
},
size: { sm: 'h-8 px-3 text-sm', md: 'h-10 px-4', lg: 'h-12 px-6 text-lg' },
},
defaultVariants: { variant: 'solid', size: 'md' },
},
);
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof button>;
export function Button({ className, variant, size, ...props }: ButtonProps) {
return <button className={cn(button({ variant, size }), className)} {...props} />;
}
For Vue, mirror with an SFC: define the same button cva map, then :class="cn(button({ variant, size }), $attrs.class)".
The theme is UI state — a small persisted store — but it must reach <html> before the bundle paints, or dark-mode users get a flash of light. Two parts:
1. Pre-paint, inline in index.html (runs before the JS bundle loads):
<script>
(() => {
let t = 'system';
try { t = JSON.parse(localStorage.getItem('theme') ?? '{}')?.state?.theme ?? 'system'; } catch {}
const dark = t === 'dark' || (t === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.classList.toggle('dark', dark);
})();
</script>
(It parses Zustand's persisted shape stored under the theme key — keep that key in sync with the store.)
2. The store — 'light' | 'dark' | 'system', defaulting to system:
// src/stores/useThemeStore.ts (React — Zustand)
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type Theme = 'light' | 'dark' | 'system';
type ThemeState = { theme: Theme; setTheme: (t: Theme) => void };
export const useThemeStore = create<ThemeState>()(
persist((set) => ({ theme: 'system', setTheme: (theme) => set({ theme }) }), { name: 'theme' }),
);
export const resolveTheme = (t: Theme): 'light' | 'dark' =>
t === 'system' ? (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') : t;
// apply on change, and react to OS changes while on 'system'
useEffect(() => {
const apply = () =>
document.documentElement.classList.toggle('dark', resolveTheme(useThemeStore.getState().theme) === 'dark');
apply();
const unsub = useThemeStore.subscribe(apply);
const mq = matchMedia('(prefers-color-scheme: dark)');
mq.addEventListener('change', apply);
return () => { unsub(); mq.removeEventListener('change', apply); };
}, []);
Vue: the same store as a Pinia setup-store + a watchEffect + the same matchMedia listener; the inline script's theme key must match the store's persist name.
pnpm tsc --noEmit
pnpm dev
<Button variant="outline" size="lg"> renders with tokens; toggling the theme store flips .dark on <html> and dark: utilities apply. Reload preserves the choice (persisted).
@/ alias, atoms layer, stores/.npx claudepluginhub velimirmueller/claude_development_skills --plugin frontendskillsGuides creation, editing, and verification of skills for AI coding agents using test-driven development with subagent scenarios. Use when authoring or debugging skills.