Imported from stevenmunoz/app-template (
.claude/skills/design-system-creator/SKILL.md). Install upstream withnpx skills add stevenmunoz/app-template --skill design-system-creator. Copyright stays with the author.
Design System Creator
Build a complete, token-driven design system for a React + TypeScript web application prototype. This skill produces four artifacts: a design tokens file, a visual showcase page, a shared CSS file, and reusable UI components — all wired together and ready for use across every page in the app.
Phase 0: Gather Requirements
Before writing any code, ask the user for:
- App name — e.g., "MedFlow", "Pulse", "Nexus"
- Domain — e.g., "healthcare workflow management", "developer tooling", "e-commerce"
- Primary brand color — a hex value (e.g.,
#2563EB) or a description (e.g., "deep teal", "vibrant violet")
If the user has already provided these in their message, proceed directly to Phase 1 without asking.
Confirm your plan with a one-paragraph summary before generating files:
"I'll build a design system for {App Name} — a {domain} app — using {primary color} as the brand color. This will include a design tokens file, a showcase page at
web/src/pages/DesignSystemPage.tsx, a shared CSS file, and reusable UI components. Let me start."
Phase 1: Generate the Color Palette
Derive a full color system from the primary brand color. Every scale must have 10 stops: 50, 100, 200, 300, 400, 500, 600, 700, 800, 900.
Color Roles
| Role | Purpose | Derivation |
|---|---|---|
primary |
Brand actions, CTAs, interactive elements | Use the provided primary color as 500. Lighten toward 50, darken toward 900. |
accent |
Complementary highlights, secondary actions | Shift hue ~180 degrees from primary (complementary) or ~30 degrees (analogous) — pick the one that reads as a distinct "accent" without clashing. |
success |
Positive states, confirmations, healthy metrics | Anchor at green (~#22c55e for 500). |
warning |
Caution states, pending items | Anchor at amber (~#f59e0b for 500). |
error |
Destructive actions, validation failures, critical alerts | Anchor at red (~#ef4444 for 500). |
neutral |
Text, borders, backgrounds, disabled states | True gray scale. Add a 0 stop (pure white #ffffff) and 950 stop (near black). Standard stops: 50, 100, 150, 200, 300, 400, 500, 600, 700, 800, 900. |
Semantic Surface Colors
Define surface colors as direct references to palette values, not new hex codes:
surface: {
page: colors.neutral[50], // Slightly warm/tinted background — not pure white
card: '#ffffff', // Pure white cards lift off the page
sidebar: colors.primary[900], // Dark sidebar using darkest primary tone
sidebarHover: colors.primary[800],
sidebarActive: colors.primary[700],
}
Phase 2: Create design-tokens.ts
File path: web/src/shared/design-tokens.ts
Create this directory if it does not exist.
The file must export a single tokens object. Structure it exactly as follows:
// web/src/shared/design-tokens.ts
// Design tokens for {App Name}
// Generated by the design-system-creator skill
// Primary brand: {primary hex}
export const tokens = {
colors: {
primary: {
50: '{hex}',
100: '{hex}',
200: '{hex}',
300: '{hex}',
400: '{hex}',
500: '{hex}', // Primary brand color
600: '{hex}',
700: '{hex}',
800: '{hex}',
900: '{hex}',
},
accent: { /* same 10-stop structure */ },
success: { /* same 10-stop structure */ },
warning: { /* same 10-stop structure */ },
error: { /* same 10-stop structure */ },
neutral: {
0: '#ffffff',
50: '{hex}',
100: '{hex}',
150: '{hex}',
200: '{hex}',
300: '{hex}',
400: '{hex}',
500: '{hex}',
600: '{hex}',
700: '{hex}',
800: '{hex}',
900: '{hex}',
},
surface: {
page: '{reference to neutral[50]}',
card: '#ffffff',
sidebar: '{reference to primary[900]}',
sidebarHover: '{reference to primary[800]}',
sidebarActive: '{reference to primary[700]}',
},
},
typography: {
fontFamily: {
display: "'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, sans-serif",
body: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
mono: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace",
},
fontSize: {
xs: '0.75rem', // 12px
sm: '0.875rem', // 14px
base: '1rem', // 16px
lg: '1.125rem', // 18px
xl: '1.25rem', // 20px
'2xl': '1.5rem', // 24px
'3xl': '1.875rem', // 30px
},
fontWeight: {
normal: 400,
medium: 500,
semibold: 600,
bold: 700,
extrabold: 800,
},
lineHeight: {
tight: 1.25,
snug: 1.375,
normal: 1.5,
relaxed: 1.625,
},
letterSpacing: {
tight: '-0.025em',
normal: '0em',
wide: '0.025em',
wider: '0.05em',
widest: '0.1em',
},
},
spacing: {
1: '0.25rem',
2: '0.5rem',
3: '0.75rem',
4: '1rem',
5: '1.25rem',
6: '1.5rem',
7: '1.75rem',
8: '2rem',
9: '2.25rem',
10: '2.5rem',
},
borderRadius: {
sm: '0.25rem',
md: '0.5rem',
lg: '0.75rem',
xl: '1rem',
'2xl': '1.5rem',
full: '9999px',
},
shadow: {
sm: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
md: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
lg: '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
},
transition: {
fast: 'all 0.1s ease',
default: 'all 0.15s ease',
smooth: 'all 0.2s ease',
spring: 'all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)',
},
layout: {
sidebarWidth: '240px',
headerHeight: '60px',
maxContentWidth: '1280px',
},
} as const;
// Convenience aliases for the most-used token groups
export const { colors, typography, spacing, borderRadius, shadow, transition, layout } = tokens;
Write out the full file with all actual hex values computed — not placeholders.
Phase 3: Create the Design System Showcase Page
File path: web/src/pages/DesignSystemPage.tsx
This page imports tokens and visually displays every part of the design system. It is a living reference — developers and designers use it to verify the system is correct.
Page Structure
Use this exact section order:
- Page header — title "{App Name} Design System", subtitle, small note about token-driven approach
- Color Palettes — one row per color role, showing all stops as swatches
- Typography — font family samples at each weight and size
- Spacing Scale — horizontal bars visualizing each spacing value
- Button Variants — primary, secondary, ghost, danger; plus loading and disabled states
- Form Inputs — default, focus (simulated with border color), error, valid, disabled
- Badges and Status Indicators — pill badges in success/warning/error/neutral/primary
- Card Components — plain card, card with header/body/footer, metric/KPI card
- Data Table — a sample table with sortable column headers
- Sidebar Navigation Preview — a mock sidebar showing nav items in default, hover, and active states
- Pattern Cards — sprint/feature/task cards representing domain-specific UI patterns
Implementation Rules
- Import
tokensfrom'../shared/design-tokens'(adjust path as needed based on actual file location) - Use destructured aliases:
const { colors, typography, spacing, borderRadius, shadow, transition } = tokens - All style values come from tokens. No hardcoded hex values, pixel values, or font strings
- Use inline
styleprops for component styles — not CSS classes (except for animations and pseudo-states) - Wrap each section in a card:
border: \1px solid ${colors.neutral[150]}`, borderRadius: borderRadius.lg, background: colors.surface.card` - Section titles use
typography.fontFamily.display,fontWeight.semibold,colors.neutral[900] - The page background is
colors.surface.page - Max content width:
layout.maxContentWidth, centered withmargin: '0 auto', paddingspacing[8]
Color Swatch Component (inline, no separate file)
const ColorSwatch = ({ hex, label }: { hex: string; label: string }) => (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: spacing[1] }}>
<div style={{
width: '48px',
height: '48px',
borderRadius: borderRadius.md,
background: hex,
border: `1px solid ${colors.neutral[200]}`,
boxShadow: shadow.sm,
}} />
<span style={{ fontSize: typography.fontSize.xs, color: colors.neutral[500], fontFamily: typography.fontFamily.mono }}>
{label}
</span>
<span style={{ fontSize: typography.fontSize.xs, color: colors.neutral[400], fontFamily: typography.fontFamily.mono }}>
{hex}
</span>
</div>
);
Button Variants
Render four variants using inline styles derived from tokens:
| Variant | Background | Text | Border |
|---|---|---|---|
| Primary | colors.primary[500] |
colors.neutral[0] |
none |
| Secondary | colors.neutral[0] |
colors.primary[600] |
1px solid colors.primary[200] |
| Ghost | transparent |
colors.neutral[600] |
1px solid colors.neutral[200] |
| Danger | colors.error[500] |
colors.neutral[0] |
none |
All buttons use: borderRadius: borderRadius.md, padding: \${spacing[2]} ${spacing[4]}`, fontFamily: typography.fontFamily.body, fontWeight: typography.fontWeight.medium, cursor: 'pointer'`
KPI Card Pattern
const KpiCard = ({ label, value, delta, deltaDirection }: { label: string; value: string; delta: string; deltaDirection: 'up' | 'down' }) => (
<div style={{
background: colors.surface.card,
border: `1px solid ${colors.neutral[150]}`,
borderRadius: borderRadius.lg,
padding: spacing[5],
boxShadow: shadow.sm,
}}>
<div style={{ fontSize: typography.fontSize.sm, color: colors.neutral[500], marginBottom: spacing[1] }}>{label}</div>
<div style={{ fontSize: typography.fontSize['3xl'], fontWeight: typography.fontWeight.bold, color: colors.neutral[900], fontFamily: typography.fontFamily.display }}>{value}</div>
<div style={{ fontSize: typography.fontSize.sm, color: deltaDirection === 'up' ? colors.success[600] : colors.error[600], marginTop: spacing[1] }}>
{deltaDirection === 'up' ? '↑' : '↓'} {delta}
</div>
</div>
);
Phase 4: Create the Shared CSS File
File path: web/src/shared/design-system.css
This file handles only what inline styles cannot: pseudo-states, keyframe animations, and global resets.
/* {App Name} Design System — Shared Styles */
/* Only put here what cannot be done with inline styles:
pseudo-classes (:hover, :focus), keyframes, ::selection */
/* Text selection */
::selection {
background-color: var(--color-primary-200, #bfdbfe);
color: var(--color-primary-900, #1e3a5f);
}
/* Button reset for nav and icon buttons */
.btn-reset {
background: none;
border: none;
padding: 0;
cursor: pointer;
font: inherit;
color: inherit;
}
/* Form input base */
.app-input {
width: 100%;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
font-family: inherit;
font-size: 0.875rem;
outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
box-sizing: border-box;
}
.app-input:focus {
border-color: var(--color-primary-400);
box-shadow: 0 0 0 3px var(--color-primary-100);
}
.app-input.error {
border-color: var(--color-error-400);
}
.app-input.error:focus {
box-shadow: 0 0 0 3px var(--color-error-100);
}
.app-input.valid {
border-color: var(--color-success-400);
}
.app-input:disabled {
background-color: var(--color-neutral-50);
color: var(--color-neutral-400);
cursor: not-allowed;
}
/* Custom select */
.app-select-wrapper {
position: relative;
display: inline-block;
}
.app-select-wrapper::after {
content: '▾';
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
color: var(--color-neutral-400);
font-size: 0.75rem;
}
/* Sidebar nav items */
.sidebar-nav-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
text-decoration: none;
font-family: inherit;
border: none;
background: transparent;
width: 100%;
text-align: left;
}
.sidebar-nav-item:hover {
background-color: var(--color-primary-800);
}
.sidebar-nav-item.active {
background-color: var(--color-primary-700);
}
/* Animations */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes confetti {
0% { transform: translateY(0) rotate(0deg); opacity: 1; }
100% { transform: translateY(-60px) rotate(720deg); opacity: 0; }
}
@keyframes validationShake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-6px); }
40% { transform: translateX(6px); }
60% { transform: translateX(-4px); }
80% { transform: translateX(4px); }
}
@keyframes stepComplete {
0% { transform: scale(1); }
50% { transform: scale(1.15); }
100% { transform: scale(1); }
}
.animate-fade-in-up {
animation: fadeInUp 0.3s ease forwards;
}
.animate-shake {
animation: validationShake 0.4s ease;
}
.animate-step-complete {
animation: stepComplete 0.25s ease;
}
Import this CSS file in the app's entry point (web/src/main.tsx or equivalent).
Phase 5: Create Reusable UI Components
File path: web/src/shared/components.tsx
Export four components. All use inline styles from tokens — no CSS classes except for animation utilities.
CustomSelect
A portal-based dropdown that renders outside overflow containers.
import React, { useState, useRef, useEffect } from 'react';
import ReactDOM from 'react-dom';
import { tokens } from './design-tokens';
const { colors, typography, spacing, borderRadius, shadow, transition } = tokens;
interface SelectOption { value: string; label: string; }
interface CustomSelectProps {
options: SelectOption[];
value: string;
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
}
export const CustomSelect: React.FC<CustomSelectProps> = ({ options, value, onChange, placeholder = 'Select...', disabled = false }) => {
const [open, setOpen] = useState(false);
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
const triggerRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (open && triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
setDropdownStyle({
position: 'fixed',
top: rect.bottom + 4,
left: rect.left,
width: rect.width,
zIndex: 9999,
});
}
}, [open]);
const selected = options.find(o => o.value === value);
return (
<>
<button
ref={triggerRef}
onClick={() => !disabled && setOpen(p => !p)}
disabled={disabled}
style={{
width: '100%',
padding: `${spacing[2]} ${spacing[3]}`,
background: disabled ? colors.neutral[50] : colors.surface.card,
border: `1px solid ${colors.neutral[200]}`,
borderRadius: borderRadius.md,
fontSize: typography.fontSize.sm,
fontFamily: typography.fontFamily.body,
color: selected ? colors.neutral[800] : colors.neutral[400],
cursor: disabled ? 'not-allowed' : 'pointer',
textAlign: 'left',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
transition: transition.default,
}}
>
<span>{selected ? selected.label : placeholder}</span>
<span style={{ color: colors.neutral[400], fontSize: typography.fontSize.xs }}>▾</span>
</button>
{open && ReactDOM.createPortal(
<>
<div
onClick={() => setOpen(false)}
style={{ position: 'fixed', inset: 0, zIndex: 9998 }}
/>
<div style={{
...dropdownStyle,
background: colors.surface.card,
border: `1px solid ${colors.neutral[200]}`,
borderRadius: borderRadius.md,
boxShadow: shadow.lg,
overflow: 'hidden',
}}>
{options.map(opt => (
<button
key={opt.value}
onClick={() => { onChange(opt.value); setOpen(false); }}
style={{
display: 'block',
width: '100%',
padding: `${spacing[2]} ${spacing[3]}`,
background: opt.value === value ? colors.primary[50] : 'transparent',
border: 'none',
textAlign: 'left',
fontSize: typography.fontSize.sm,
fontFamily: typography.fontFamily.body,
color: opt.value === value ? colors.primary[700] : colors.neutral[700],
cursor: 'pointer',
transition: transition.fast,
}}
>
{opt.label}
</button>
))}
</div>
</>,
document.body
)}
</>
);
};
SidebarNavItem
Handles hover/active states via useState since inline styles cannot target :hover.
interface SidebarNavItemProps {
icon: React.ReactNode;
label: string;
active?: boolean;
onClick?: () => void;
}
export const SidebarNavItem: React.FC<SidebarNavItemProps> = ({ icon, label, active = false, onClick }) => {
const [hovered, setHovered] = useState(false);
const bg = active
? colors.surface.sidebarActive
: hovered
? colors.surface.sidebarHover
: 'transparent';
return (
<button
onClick={onClick}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
display: 'flex',
alignItems: 'center',
gap: spacing[3],
padding: `${spacing[2]} ${spacing[3]}`,
borderRadius: borderRadius.md,
background: bg,
border: 'none',
width: '100%',
textAlign: 'left',
cursor: 'pointer',
transition: transition.default,
color: active ? colors.neutral[0] : hovered ? colors.neutral[0] : colors.primary[200],
fontSize: typography.fontSize.sm,
fontFamily: typography.fontFamily.body,
fontWeight: active ? typography.fontWeight.semibold : typography.fontWeight.normal,
}}
>
<span style={{ opacity: active ? 1 : 0.8 }}>{icon}</span>
<span>{label}</span>
{active && (
<span style={{
marginLeft: 'auto',
width: '6px',
height: '6px',
borderRadius: borderRadius.full,
background: colors.neutral[0],
}} />
)}
</button>
);
};
FormField
Wraps a label, input/control, and optional error/hint message.
interface FormFieldProps {
label: string;
required?: boolean;
error?: string;
hint?: string;
children: React.ReactNode;
}
export const FormField: React.FC<FormFieldProps> = ({ label, required, error, hint, children }) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: spacing[1] }}>
<label style={{
fontSize: typography.fontSize.sm,
fontWeight: typography.fontWeight.medium,
color: colors.neutral[700],
fontFamily: typography.fontFamily.body,
}}>
{label}
{required && <span style={{ color: colors.error[500], marginLeft: spacing[1] }}>*</span>}
</label>
{children}
{error && (
<span style={{ fontSize: typography.fontSize.xs, color: colors.error[600] }}>{error}</span>
)}
{hint && !error && (
<span style={{ fontSize: typography.fontSize.xs, color: colors.neutral[400] }}>{hint}</span>
)}
</div>
);
SectionHeader
Consistent section titles with optional action slot.
interface SectionHeaderProps {
title: string;
subtitle?: string;
action?: React.ReactNode;
}
export const SectionHeader: React.FC<SectionHeaderProps> = ({ title, subtitle, action }) => (
<div style={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
marginBottom: spacing[5],
}}>
<div>
<h2 style={{
margin: 0,
fontSize: typography.fontSize['2xl'],
fontFamily: typography.fontFamily.display,
fontWeight: typography.fontWeight.bold,
color: colors.neutral[900],
letterSpacing: typography.letterSpacing.tight,
}}>
{title}
</h2>
{subtitle && (
<p style={{
margin: `${spacing[1]} 0 0`,
fontSize: typography.fontSize.sm,
color: colors.neutral[500],
fontFamily: typography.fontFamily.body,
}}>
{subtitle}
</p>
)}
</div>
{action && <div>{action}</div>}
</div>
);
Phase 6: Wire Tokens into Existing Pages
After creating the four artifacts, audit every existing page in web/src/pages/ for hardcoded values.
Audit Checklist
For each existing .tsx file in the pages directory:
- Search for hardcoded hex values (e.g.,
#1a1a2e,#3b82f6,rgb(...)) - Search for hardcoded px/rem font sizes not from tokens
- Search for hardcoded color strings (
'white','black','gray') - Check that any dark sidebar or dark navigation uses
colors.surface.sidebarand related tokens
Replacement Pattern
When replacing a hardcoded value:
// Before
style={{ background: '#1a1a2e', color: '#ffffff' }}
// After
import { colors } from '../shared/design-tokens';
style={{ background: colors.surface.sidebar, color: colors.neutral[0] }}
If a page implements its own dark theme (e.g., a hardcoded dark sidebar), replace it with the sidebar surface tokens from the design system. The goal is one source of truth for all visual decisions.
Import the CSS
Confirm that web/src/shared/design-system.css is imported in the app entry point. If it is not, add:
// web/src/main.tsx (or equivalent)
import './shared/design-system.css';
Quality Standards
Before completing the skill, verify each of the following:
-
design-tokens.tscompiles with no TypeScript errors (all objects areas const) - All 5 color roles have complete 10-stop scales with valid hex values
-
DesignSystemPage.tsxrenders without errors and all sections are visible - No hardcoded hex values in
DesignSystemPage.tsx— every color referencescolors.* -
design-system.cssis imported in the app entry point -
components.tsxexports all four components - CustomSelect portal renders correctly (verify by placing it inside an
overflow: hiddencontainer) - The showcase page is accessible via a route (add it to
web/src/routes/index.tsxif not present) - Every existing page that was audited either passes the audit or has been updated
Route Registration
If a DesignSystemPage route does not exist, add one. Read the routing file first, then append a route at the path /design-system. Use whatever routing library the project already uses — do not introduce a new one.
Output Summary
After all files are written, report to the user:
Design system created for {App Name}.
Files written:
web/src/shared/design-tokens.ts — Color, type, spacing, shadow, layout tokens
web/src/pages/DesignSystemPage.tsx — Visual showcase of all tokens and components
web/src/shared/design-system.css — Animations, pseudo-states, global resets
web/src/shared/components.tsx — CustomSelect, SidebarNavItem, FormField, SectionHeader
Primary color: {hex}
Color roles: primary, accent, success, warning, error, neutral
Font stack: Plus Jakarta Sans (display) / Inter (body) / JetBrains Mono (mono)
View the design system at: http://localhost:3000/design-system
Then list any existing pages that were updated with a one-line description of what changed in each.