Set up design system
Skill VelimirMueller/claude_development_skills/skills/frontend/set-up-design-system
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.From its SKILL.md
npx -y skills add VelimirMueller/claude_development_skills --skill set-up-design-systemAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 4 stars4 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
6.1 KB, as published. Nobody here has run it
Set Up Design System
1. Audit current state
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).
2. Decide what to do
- No tokens/primitives → full setup.
- Tokens present but ad-hoc class strings → add
cn()+ cva variants. - shadcn/ui already initialized → adopt its
cn/cva; just add the token layer + theme store.
3. Detect framework
React → primitives as .tsx with cva. Vue → primitives as SFCs using the same cva class strings. cn() and @theme are framework-agnostic.
4. Install
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.)
5. Define tokens in @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;
}
6. The 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));
}
7. A variant-driven primitive (atom)
React
// 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)".
8. Dark mode = persisted UI state, applied before first paint (no FOUC)
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.
9. Verify
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).
References
- ./design-tokens.md — token naming, cva + cn rationale, dark-mode strategy, theme-is-UI-state, shadcn/ui, when to deviate.
- ../_shared/glossary.md — theme as UI state.
- ../_shared/conventions.md —
@/alias, atoms layer,stores/.