Building an Accessible Design System Playground: A Practical Frontend Guide
Building an Accessible Design System Playground: A Practical Frontend Guide
Creating a design system is more than building a component library; it’s about establishing a living, accessible, and scalable playground where teams can experiment, learn, and align on UI decisions. This tutorial walks you through building an accessible design system playground from scratch, with real code patterns you can drop into a modern frontend project. You’ll learn how to structure components, wire up tokens and themes, enforce accessibility, and provide a collaborative playground for designers and engineers.
Goals
- Create a reusable design system playground that demonstrates tokens, components, and accessibility.
- Implement a token system (colors, typography, spacing) and a theming switcher (light/dark/high-contrast).
- Build a small component catalog (buttons, inputs, cards) with live-editable props.
- Provide keyboard and screen reader-friendly interactions.
-
Integrate with a lightweight build setup (no heavy framework coupling) and sane testing patterns.
Tech stack (minimal, modern)
React with TypeScript for type safety and predictable props
CSS-in-JS or CSS Modules for scoping (this guide uses CSS-in-JS via Emotion)
Story-like playground inside a single page to avoid extra tooling
Accessible-first ARIA attributes and semantic HTML
-
Local token file (tokens.ts) and a simple theme engine
Project scaffold
Create a new React app (or add to an existing one) with TypeScript.
Add Emotion for styling.
Add a lightweight script to start a dev server.
Example structure:
- src/
- tokens.ts
- themes.ts
- components/
- Button.tsx
- TextInput.tsx
- Card.tsx
- Playground.tsx
- index.tsx
- App.tsx
- styles/
- global.css (optional)
- index.html
1) Define design tokens
Design tokens are the single source of truth for a design system. They govern color, typography, spacing, borders, shadows, and radii.
tokens.ts
- Centralizes values
- Exposes a simple API for themes
Example:
// src/tokens.ts
export type TokenScale = number;
export type Breakpoints = {
sm: string;
md: string;
lg: string;
};
export const tokens = {
color: {
primary: '#4F46E5',
primaryDark: '#4338CA',
text: '#111827',
textMuted: '#6B7280',
background: '#ffffff',
surface: '#F8FAFC',
border: '#E5E7EB',
error: '#DC2626',
success: '#16A34A',
},
font: {
family: '"Inter", system-ui, -apple-system, "Segoe UI", Roboto',
size: {
xs: '12px',
sm: '14px',
base: '16px',
lg: '20px',
xl: '24px',
},
weight: {
normal: 400,
medium: 500,
bold: 700,
},
},
space: {
xs: '4px',
sm: '8px',
md: '12px',
lg: '16px',
xl: '24px',
},
radius: {
sm: '6px',
md: '10px',
lg: '14px',
},
shadows: {
subtle: '0 1px 2px rgba(0,0,0,.05)',
elevated: '0 8px 24px rgba(0,0,0,.08)',
},
} as const;
2) Theme system
We’ll support light, dark, and high-contrast themes. The theme system applies tokens with CSS custom properties for easy CSS references and also keeps a TypeScript-safe mapping.
themes.ts
// src/themes.ts
import { tokens } from './tokens';
type ThemeName = 'light' | 'dark' | 'high-contrast';
type ThemeMap = Record<ThemeName, typeof tokens>;
export const themeMap: ThemeMap = {
light: {
color: {
primary: tokens.color.primary,
primaryDark: tokens.color.primaryDark,
text: '#111827',
textMuted: '#6B7280',
background: '#FFFFFF',
surface: '#F9FAFB',
border: '#E5E7EB',
error: '#DC2626',
success: '#16A34A',
},
font: tokens.font,
space: tokens.space,
radius: tokens.radius,
shadows: tokens.shadows,
},
dark: {
color: {
primary: tokens.color.primary,
primaryDark: tokens.color.primaryDark,
text: '#E5E7EB',
textMuted: '#9CA3AF',
background: '#0B1020',
surface: '#141A2A',
border: '#1F2A3A',
error: '#F87171',
success: '#34D399',
},
font: tokens.font,
space: tokens.space,
radius: tokens.radius,
shadows: tokens.shadows,
},
'high-contrast': {
color: {
primary: '#FFD166',
primaryDark: '#FFC107',
text: '#000',
textMuted: '#333',
background: '#000',
surface: '#111',
border: '#FFF',
error: '#FF6B6B',
success: '#4ADE80',
},
font: tokens.font,
space: tokens.space,
radius: tokens.radius,
shadows: tokens.shadows,
},
};
export type Theme = typeof tokens;
export { ThemeName } from './types'; // optional if you want to export a type alias
Emotion setup (global styles)
// src/GlobalStyles.tsx
import { Global, css } from '@emotion/react';
import { Theme } from './types';
import { themeMap } from './themes';
type ThemeVars = typeof themeMap['light'];
interface GlobalProps {
mode: keyof typeof themeMap;
}
export const GlobalStyles: React.FC<GlobalProps> = ({ mode }) => {
const t = themeMap[mode];
return (
<Global
styles={css`
:root {
color-primary: ${t.color.primary};
color-text: ${t.color.text};
color-text-muted: ${t.color.textMuted};
color-background: ${t.color.background};
color-surface: ${t.color.surface};
color-border: ${t.color.border};
font-family: ${t.font.family};
font-size-base: ${t.font.size.base};
space-xs: ${t.space.xs};
space-sm: ${t.space.sm};
space-md: ${t.space.md};
space-lg: ${t.space.lg};
radius-md: ${t.radius.md};
shadow-subtle: ${t.shadows.subtle};
shadow-elevated: ${t.shadows.elevated};
}
html, body, #root {
height: 100%;
}
body {
margin: 0;
font-family: var(font-family);
font-size: var(font-size-base);
color: var(color-text);
background: var(color-background);
}
`}
/>
);
};
3) Accessible components
We’ll implement three core components with accessibility in mind: Button, TextInput, and Card.
Button.tsx
import React from 'react';
import { css } from '@emotion/react';
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: 'primary' | 'secondary';
as?: keyof JSX.IntrinsicElements;
label?: string;
};
export const Button: React.FC<ButtonProps> = ({
variant = 'primary',
children,
label,
...rest
}) => {
const content = children ?? label ?? 'Button';
const styles = css`
display: inline-flex;
align-items: center;
justify-content: center;
padding: 10px 14px;
border-radius: 8px;
border: 1px solid var(color-border);
background: ${variant === 'primary' ? 'var(color-primary)' : 'var(color-surface)'};
color: ${variant === 'primary' ? '#fff' : 'var(color-text)'};
cursor: pointer;
font-weight: 600;
transition: transform 0.1s ease, background 0.2s ease;
`;
return (
<button aria-label={label ?? content} css={styles} {...rest}>
{content}
</button>
);
};
TextInput.tsx
import React from 'react';
import { css } from '@emotion/react';
type TextInputProps = React.InputHTMLAttributes<HTMLInputElement> & {
label?: string;
error?: string;
};
export const TextInput: React.FC<TextInputProps> = ({
label,
error,
id,
...rest
}) => {
const inputId = id ?? 'ts-input-' + Math.random().toString(36).slice(2, 7);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{label && (
<label htmlFor={inputId} style={{ fontWeight: 600 }}>
{label}
</label>
)}
<input
id={inputId}
aria-invalid={Boolean(error)}
aria-describedby={error ? inputId + '-error' : undefined}
css={css`
padding: 10px 12px;
border-radius: 6px;
border: 1px solid var(color-border);
background: var(color-surface);
color: var(color-text);
font-size: 14px;
`}
{...rest}
/>
{error && (
<span id={inputId + '-error'} role="alert" style={{ color: 'var(color-error)', fontSize: 12 }}>
{error}
</span>
)}
</div>
);
};
Card.tsx
import React from 'react';
import { css } from '@emotion/react';
type CardProps = React.HTMLAttributes<HTMLDivElement> & {
title?: string;
subtitle?: string;
};
export const Card: React.FC<CardProps> = ({ title, subtitle, children, ...rest }) => {
return (
<section
aria-label={title ?? 'Card'}
css={css`
padding: 16px;
border: 1px solid var(color-border);
border-radius: 12px;
background: var(color-surface);
box-shadow: var(shadow-subtle);
display: block;
`}
{...rest}
>
{(title || subtitle) && (
<header
css={css`
margin-bottom: 12px;
`}
>
{title && (
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700 }}>{title}</h3>
)}
{subtitle && (
<p style={{ margin: 0, color: 'var(color-text-muted)' }}>{subtitle}</p>
)}
</header>
)}
{children}
</section>
);
};
4) Build a Live Playground
Playground.tsx brings tokens, theme switching, and component demos together with live-editing controls.
Playground.tsx
import React, { useMemo, useState } from 'react';
import { Button } from './components/Button';
import { TextInput } from './components/TextInput';
import { Card } from './components/Card';
import { GlobalStyles } from './GlobalStyles';
import { themeMap } from './themes';
type ThemeName = keyof typeof themeMap;
export const Playground: React.FC = () => {
const [theme, setTheme] = useState<ThemeName>('light');
const [input, setInput] = useState('');
const [error, setError] = useState<string | undefined>(undefined);
// simple validation example
const validate = (v: string) => {
if (v.length < 3) return 'Minimum 3 characters';
return undefined;
};
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = e.target.value;
setInput(v);
setError(validate(v));
};
const currentThemeName = theme;
return (
<div style={{ padding: 24, display: 'grid', gridTemplateColumns: '300px 1fr', gap: 20 }}>
<GlobalStyles mode={theme} />
<aside
aria-label="Theme and controls"
style={{
border: '1px solid var(color-border)',
borderRadius: 12,
padding: 16,
background: 'var(color-surface)',
display: 'flex',
flexDirection: 'column',
gap: 12,
}}
>
<h2 style={{ margin: 0, fontSize: 16 }}>Theme</h2>
<div role="group" aria-label="Theme switcher" style={{ display: 'flex', gap: 8 }}>
{(['light', 'dark', 'high-contrast'] as ThemeName[]).map((t) => (
<button
key={t}
onClick={() => setTheme(t)}
aria-pressed={theme === t}
style={{
padding: '8px 12px',
borderRadius: 6,
border: '1px solid var(color-border)',
background: theme === t ? 'var(color-primary)' : 'var(color-surface)',
color: theme === t ? '#fff' : 'var(color-text)',
cursor: 'pointer',
}}
>
{t}
</button>
))}
</div>
<hr style={{ border: 'none', borderTop: '1px solid var(color-border)' }} />
<div>
<label htmlFor="live-input" style={{ fontWeight: 600 }}>
Live Input
</label>
<TextInput id="live-input" value={input} onChange={onChange} placeholder="Type to test" />
{error && (
<p role="alert" style={{ color: 'var(color-error)', marginTop: 6 }}>
{error}
</p>
)}
</div>
<Button onClick={() => setInput('Demo')} label="Fill Demo" />
</aside>
<main>
<Card title="Component Spotlight" subtitle="Live, accessible components">
<div style={{ display: 'grid', gap: 12, gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))' }}>
<Button onClick={() => alert('Clicked')} label="Primary Button" />
<Button onClick={() => alert('Secondary')} variant="secondary" label="Secondary" />
<TextInput label="Username" value={input} onChange={onChange} />
</div>
</Card>
<Card title="Accessibility Checklist" subtitle="Focus trap, keyboard, and ARIA">
<ul aria-label="Checklist" style={{ margin: 0, paddingLeft: 20 }}>
<li>Semantic HTML: headings, sections, landmarks</li>
<li>Keyboard navigation: tab order and focus outlines</li>
<li>ARIA attributes: aria-label, aria-invalid when needed</li>
<li>Color contrast: WCAG AA-compliant tokens</li>
</ul>
</Card>
</main>
</div>
);
};
index.tsx and App.tsx wire up the playground:
// src/index.tsx
import React from 'react';
import { createRoot } from 'react-dom/client';
import { Playground } from './Playground';
const App: React.FC = () => (
<div>
<Playground />
</div>
);
const root = document.getElementById('root');
if (root) {
createRoot(root).render(<App />);
}
// src/App.tsx
import React from 'react';
import { Playground } from './Playground';
export const App: React.FC = () => {
return <Playground />;
};
5) Accessibility patterns to adopt
- Use semantic elements where possible (header, main, nav, section, aside, footer).
- Ensure high-contrast focus states for keyboard users. Outline or box-shadow emphasis is vital.
- Provide ARIA labels and roles when the element’s purpose isn’t clear from text alone.
- Use appropriate contrast ratios for text and background. Tools like contrast checkers help.
- Ensure form inputs have associated labels; use aria-invalid for errors.
- Keep focus visible and predictable when components render or update.
-
Offer keyboard shortcuts to common actions in the playground (e.g., focus search, reset).
6) Testing and quality
Visual regression: snapshot components in various themes to verify appearance.
Interaction tests: keyboard navigation, focus, and aria attributes.
Accessibility checks: run automated checks with tools like axe-core, Lighthouse, or a11y audit plugins.
Sample quick tests (conceptual):
- Verify that the button has aria-label and accessible text.
- Verify input shows aria-invalid when error exists.
-
Verify color tokens reflect theme changes in CSS variables.
7) Collaboration workflow
Design tokens live-update: store tokens.ts in a central repo; update theme maps accordingly.
Component demos in the playground serve as “living documentation” of decisions.
Use pull requests to review accessibility changes and token updates.
-
Document decisions in a DESIGN.md file within the playground repo.
8) Packaging ideas (optional)
If you want to reuse this as a library:
- Extract Button, TextInput, Card into a standalone component package.
- Publish a minimal token-driven theme package.
-
Provide a small demo playground in the package’s repo.
9) Quick start checklist
[ ] Initialize React + TypeScript project
[ ] Install Emotion (or your preferred CSS-in-JS)
[ ] Create tokens.ts with your token values
[ ] Implement theme system and global styles
[ ] Build accessible components (Button, TextInput, Card)
[ ] Create a Playground.tsx to demonstrate tokens and components
[ ] Ensure keyboard and screen reader accessibility basics
[ ] Start dev server and iterate with real design changes
If you’d like, I can tailor this starter to your existing project (Next.js, Vite, or plain CRA), adjust the token set for your brand, or add more components (Autocomplete, Tooltip, Modal) with the same accessibility-first approach. Would you prefer a Next.js-based setup with server-side token hydration or a client-side SPA approach?
-
Rizwan Saleem | https://rizwansaleem.co
Top comments (0)