Building an Accessible Design System Playground: A Practical Frontend Guide

typescript dev.to

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
        }
      `}
    />
  );
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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 />);
}
Enter fullscreen mode Exit fullscreen mode
// src/App.tsx
import React from 'react';
import { Playground } from './Playground';

export const App: React.FC = () => {
  return <Playground />;
};
Enter fullscreen mode Exit fullscreen mode

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

Source: dev.to

arrow_back Back to Tutorials