$npx -y skills add tranhieutt/software_development_department --skill frontend-ui-dark-tsBuilds dark-themed TypeScript UIs with accessible color systems, contrast compliance, and responsive design patterns. Use when implementing dark mode or building accessible TypeScript UI components.
| 1 | # Frontend UI Dark (TypeScript) |
| 2 | |
| 3 | ## Critical rules (non-obvious) |
| 4 | |
| 5 | - **WCAG contrast minimums**: text on bg requires 4.5:1 (AA) or 7:1 (AAA); UI elements (borders, icons) require 3:1 |
| 6 | - **Never use `prefers-color-scheme` media query alone** — users need a toggle; sync with `localStorage` to avoid flash on hydration |
| 7 | - **HSL for dark themes**: use `hsl(220 15% 10%)` not `#1a1a2e` — HSL lets you programmatically adjust lightness |
| 8 | - **Avoid pure black (`#000`)** for dark backgrounds — causes eye strain; use `hsl(220 15% 8%)` instead |
| 9 | - **`color-scheme: dark`** on `:root` makes browser UI (scrollbars, inputs) follow dark theme |
| 10 | |
| 11 | ## CSS variable token system |
| 12 | |
| 13 | ```css |
| 14 | /* globals.css */ |
| 15 | :root { |
| 16 | /* HSL values only (no hsl() wrapper) — allows opacity modifiers */ |
| 17 | --bg-base: 222 47% 8%; |
| 18 | --bg-surface: 222 47% 12%; |
| 19 | --bg-elevated: 222 47% 16%; |
| 20 | --text-primary: 220 20% 95%; |
| 21 | --text-secondary: 220 15% 70%; |
| 22 | --text-muted: 220 10% 50%; |
| 23 | --brand: 220 90% 60%; |
| 24 | --brand-hover: 220 90% 65%; |
| 25 | --border: 220 20% 20%; |
| 26 | --error: 0 85% 60%; |
| 27 | --success: 142 70% 45%; |
| 28 | |
| 29 | color-scheme: dark; |
| 30 | } |
| 31 | |
| 32 | /* Light mode override */ |
| 33 | [data-theme="light"] { |
| 34 | --bg-base: 0 0% 100%; |
| 35 | --bg-surface: 220 14% 96%; |
| 36 | --bg-elevated: 0 0% 100%; |
| 37 | --text-primary: 222 47% 11%; |
| 38 | --text-secondary: 220 14% 40%; |
| 39 | color-scheme: light; |
| 40 | } |
| 41 | ``` |
| 42 | |
| 43 | ## Theme provider (React + no flash) |
| 44 | |
| 45 | ```typescript |
| 46 | // providers/ThemeProvider.tsx |
| 47 | export function ThemeProvider({ children }: { children: React.ReactNode }) { |
| 48 | const [theme, setTheme] = useState<"dark" | "light">(() => |
| 49 | typeof window !== "undefined" |
| 50 | ? (localStorage.getItem("theme") as "dark" | "light") ?? "dark" |
| 51 | : "dark" |
| 52 | ); |
| 53 | |
| 54 | useEffect(() => { |
| 55 | document.documentElement.dataset.theme = theme; |
| 56 | localStorage.setItem("theme", theme); |
| 57 | }, [theme]); |
| 58 | |
| 59 | return ( |
| 60 | <ThemeContext.Provider value={{ theme, toggle: () => setTheme(t => t === "dark" ? "light" : "dark") }}> |
| 61 | {children} |
| 62 | </ThemeContext.Provider> |
| 63 | ); |
| 64 | } |
| 65 | |
| 66 | // Prevent flash — add to <head> before React hydrates |
| 67 | const themeScript = ` |
| 68 | (function() { |
| 69 | var t = localStorage.getItem('theme') || 'dark'; |
| 70 | document.documentElement.dataset.theme = t; |
| 71 | })(); |
| 72 | `; |
| 73 | ``` |
| 74 | |
| 75 | ## Accessible component patterns |
| 76 | |
| 77 | ```typescript |
| 78 | // Button with all a11y attributes |
| 79 | interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> { |
| 80 | variant?: "primary" | "ghost" | "danger"; |
| 81 | isLoading?: boolean; |
| 82 | } |
| 83 | |
| 84 | export function Button({ variant = "primary", isLoading, children, disabled, ...props }: ButtonProps) { |
| 85 | return ( |
| 86 | <button |
| 87 | {...props} |
| 88 | disabled={disabled || isLoading} |
| 89 | aria-busy={isLoading} |
| 90 | aria-disabled={disabled || isLoading} |
| 91 | className={cn( |
| 92 | "inline-flex items-center gap-2 rounded-md px-4 py-2 font-medium transition-colors", |
| 93 | "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--brand))]", |
| 94 | "disabled:pointer-events-none disabled:opacity-50", |
| 95 | variant === "primary" && "bg-[hsl(var(--brand))] text-white hover:bg-[hsl(var(--brand-hover))]", |
| 96 | variant === "ghost" && "hover:bg-[hsl(var(--bg-surface))]", |
| 97 | variant === "danger" && "bg-[hsl(var(--error))] text-white", |
| 98 | )} |
| 99 | > |
| 100 | {isLoading && <Spinner aria-hidden="true" />} |
| 101 | {children} |
| 102 | </button> |
| 103 | ); |
| 104 | } |
| 105 | ``` |
| 106 | |
| 107 | ## Color utility function |
| 108 | |
| 109 | ```typescript |
| 110 | // Use CSS variables with alpha |
| 111 | function token(variable: string, alpha?: number): string { |
| 112 | return alpha !== undefined |
| 113 | ? `hsl(var(--${variable}) / ${alpha})` |
| 114 | : `hsl(var(--${variable}))`; |
| 115 | } |
| 116 | |
| 117 | // Usage: token("brand", 0.2) → "hsl(var(--brand) / 0.2)" |
| 118 | ``` |
| 119 | |
| 120 | ## Tailwind dark theme config (v3) |
| 121 | |
| 122 | ```javascript |
| 123 | // tailwind.config.ts |
| 124 | export default { |
| 125 | darkMode: ["class", '[data-theme="dark"]'], // class-based, controlled by JS |
| 126 | theme: { |
| 127 | extend: { |
| 128 | colors: { |
| 129 | bg: { |
| 130 | base: "hsl(var(--bg-base) / <alpha-value>)", |
| 131 | surface: "hsl(var(--bg-surface) / <alpha-value>)", |
| 132 | elevated: "hsl(var(--bg-elevated) / <alpha-value>)", |
| 133 | }, |
| 134 | text: { |
| 135 | primary: "hsl(var(--text-primary) / <alpha-value>)", |
| 136 | secondary: "hsl(var(--text-secondary) / <alpha-value>)", |
| 137 | }, |
| 138 | brand: "hsl(var(--brand) / <alpha-value>)", |
| 139 | } |
| 140 | } |
| 141 | } |
| 142 | } |
| 143 | ``` |
| 144 | |
| 145 | ## Contrast checker utility |
| 146 | |
| 147 | ```typescript |
| 148 | // Quick WCAG contrast ratio check |
| 149 | function getContrastRatio(fg: string, bg: string): number { |