DEV Community

Rizwan Saleem
Rizwan Saleem

Posted on

Building Accessible, High-Performance UI Components with Component-Driven Styling

Building Accessible, High-Performance UI Components with Component-Driven Styling

Building Accessible, High-Performance UI Components with Component-Driven Styling

In this tutorial, you’ll learn to design, implement, and test a small but practical UI component library for a frontend project. You’ll focus on accessibility, performance, and a scalable approach to styling that stays robust as your app grows. By the end, you’ll have a reusable Button and TextInput system with composable styling, real-world patterns, and code you can adapt today.

Goals and prerequisites

  • Create a small component library that can be dropped into any React project.
  • Ensure accessibility (ARIA, keyboard navigation, proper focus management).
  • Implement a scalable styling approach that avoids CSS-in-JS pitfalls and keeps bundle sizes reasonable.
  • Provide practical patterns for composing components, theming, and testing.
  • Use real code you can copy-paste with minimal setup.

Prerequisites:

  • Basic React knowledge (function components, hooks)
  • TypeScript basics (types for props)
  • Familiarity with npm/yarn

    Architecture overview

  • Core concept: lean components with a shared foundation to guarantee consistency.

  • Styling approach: a lightweight token-based system with CSS custom properties (variables) and a tiny utility for variants.

  • Accessibility: semantic HTML, ARIA attributes, and keyboard operability.

  • Testing: pragmatic checks for rendering, interactions, and accessibility semantics.

Key pieces:

  • A base Button that supports different variants, sizes, and disabled state.
  • A TextInput (controlled) with clear button, validity states, and accessible labeling.
  • A Theme/Token system to keep colors, sizes, and radii centralized.
  • A small library structure with index exports to enable tree-shaking. ### 1) Design tokens and styling strategy

Create a tokens file with color, spacing, typography, and radii. Use CSS custom properties to enable theming, while React components consume values via a simple utility.

Example: tokens.ts

// src/tokens.ts
export type ThemeToken = string | number;

export const tokens = {
  colors: {
    primary: '#2563eb',
    primaryDark: '#1e40af',
    onPrimary: '#ffffff',
    surface: '#ffffff',
    surfaceVariant: '#f7f7fb',
    text: '#1f2937',
    textMuted: '#6b7280',
    border: '#e5e7eb',
    error: '#ef4444',
    success: '#16a34a',
  },
  space: ,
  fontSizes: ,
  radii: ,
  fontWeights: {
    normal: 400,
    bold: 700,
  },
  shadows: {
    small: '0 1px 2px rgba(0,0,0,.05)',
    focus: '0 0 0 3px rgba(37,99,235,.25)',
  },
} as const;
Enter fullscreen mode Exit fullscreen mode

2) Theming via CSS vars

Create a minimal CSS reset and a theme root. This keeps styling accessible and fast.

/* src/styles/base.css */
:root {
  color-primary: #2563eb;
  color-on-primary: #ffffff;
  color-surface: #ffffff;
  color-border: #e5e7eb;
  color-text: #1f2937;
  space-0: 0px;
  space-1: 4px;
  space-2: 8px;
  space-3: 12px;
  space-4: 16px;
  radius-0: 0px;
  radius-1: 6px;
  radius-2: 10px;
  shadow-small: 0 1px 2px rgba(0,0,0,.05);
}
Enter fullscreen mode Exit fullscreen mode

2) Utility: classNames helper

// src/utils/classNames.ts
export function cx(...classes: Array<string | false | null | undefined>) {
  return classes.filter(Boolean).join(' ');
}
Enter fullscreen mode Exit fullscreen mode

3) Variant system

Define variant keys and a simple mapping function to generate classNames.

// src/components/styles/variants.ts
export type ButtonVariant = 'filled' | 'outline' | 'text';
export type ButtonSize = 'sm' | 'md' | 'lg';

export function buttonVariantClasses(variant: ButtonVariant) {
  switch (variant) {
    case 'filled':
      return 'btnfilled';
    case 'outline':
      return 'btnoutline';
    case 'text':
      return 'btntext';
  }
}
export function buttonSizeClasses(size: ButtonSize) {
  switch (size) {
    case 'sm':
      return 'btnsm';
    case 'md':
      return 'btnmd';
    case 'lg':
      return 'btnlg';
  }
}
Enter fullscreen mode Exit fullscreen mode

4) Lightweight CSS for components

/* src/styles/components.css */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: 1px solid var(color-border);
  background: var(color-surface);
  color: var(color-text);
  padding: 8px 14px;
  border-radius: 8px;
  cursor: pointer;
  font-weight: 600;
  transition: transform .05s ease, background .2s ease, border-color .2s;
  box-shadow: var(shadow-small);
}
.btn:focus-visible {
  outline: none;
  box-shadow: 0 0 0 3px rgba(37,99,235,.25);
}
.btnfilled {
  background: var(color-primary);
  color: var(color-on-primary);
  border: 1px solid var(color-primary);
}
.btnoutline {
  background: transparent;
  color: var(color-text);
  border: 1px solid var(color-border);
}
.btntext {
  background: transparent;
  border: none;
  color: var(color-primary);
}
.btnsm { padding: 6px 10px; font-size: 12px; border-radius: 6px; }
.btnmd { padding: 8px 14px; font-size: 14px; border-radius: 8px; }
.btnlg { padding: 12px 18px; font-size: 16px; border-radius: 10px; }

.input {
  display: inline-flex;
  align-items: center;
  border: 1px solid var(color-border);
  padding: 8px 12px;
  border-radius: 8px;
  background: #fff;
  min-width: 240px;
}
.input input {
  border: none;
  outline: none;
  font: inherit;
  width: 100%;
}
.inputerror {
  border-color: #f87171;
}
.input-icon {
  margin-right: 8px;
  opacity: .6;
}
.clear {
  background: transparent;
  border: none;
  cursor: pointer;
  color: var(color-text);
}
Enter fullscreen mode Exit fullscreen mode

5) Exports index

// src/index.ts
export { Button } from './components/Button';
export { TextInput } from './components/TextInput';
Enter fullscreen mode Exit fullscreen mode

2) Button component

A flexible button with accessible states, keyboard support, and theming.

// src/components/Button/Button.tsx
import React from 'react';
import { cx } from '../../utils/classNames';
import { buttonSizeClasses, buttonVariantClasses } from '../styles/variants';
import '../styles/components.css';
import '../../styles/base.css';

type ButtonVariant = 'filled' | 'outline' | 'text';
type ButtonSize = 'sm' | 'md' | 'lg';

export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: ButtonVariant;
  size?: ButtonSize;
  as?: 'button' | 'a';
  href?: string;
  leftIcon?: React.ReactNode;
  rightIcon?: React.ReactNode;
}

export const Button: React.FC<ButtonProps> = ({
  variant = 'filled',
  size = 'md',
  as: Component = 'button',
  className,
  leftIcon,
  rightIcon,
  children,
  ...rest
}) => {
  const isLink = Component === 'a';
  const classes = cx(
    'btn',
    `btn${variant}`,
    `btn${size}`,
    className
  );

  // If used as anchor, ensure href is present for accessibility and semantics
  const commonProps = {
    className: classes,
    ...rest,
  };

  return (
    <Component {...commonProps}>
      {leftIcon && <span aria-hidden="true" style={{ display: 'inline-flex', marginRight: 6 }}>{leftIcon}</span>}
      <span>{children}</span>
      {rightIcon && <span aria-hidden="true" style={{ display: 'inline-flex', marginLeft: 6 }}>{rightIcon}</span>}
    </Component>
  );
};
Enter fullscreen mode Exit fullscreen mode

Usage example:

import React from 'react';
import { Button } from './components/Button/Button';

export function App() {
  return (
    <div style={{ padding: 20 }}>
      <Button variant="filled" size="md" onClick={() => alert('Clicked!')}>
        Primary
      </Button>
      <Button variant="outline" size="md" style={{ marginLeft: 8 }}>
        Secondary
      </Button>
      <Button as="a" href="#" variant="filled" size="md" style={{ marginLeft: 8 }}>
        Link Button
      </Button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Accessibility notes:

  • Buttons are native button elements by default, which provides correct semantics and keyboard handling.
  • If rendered as a link, ensure href is provided and role remains appropriate.
  • Focus styles use a visible outline (focus-visible pattern) to aid keyboard navigation. ### 3) TextInput component

A controlled input with label, helper text, and a dismissible clear button.

// src/components/TextInput/TextInput.tsx
import React, { useState } from 'react';
import { cx } from '../../utils/classNames';
import '../../styles/base.css';
import '../../styles/components.css';

export interface TextInputProps {
  id: string;
  label: string;
  value: string;
  onChange: (v: string) => void;
  placeholder?: string;
  helperText?: string;
  error?: string;
}

export const TextInput: React.FC<TextInputProps> = ({
  id,
  label,
  value,
  onChange,
  placeholder,
  helperText,
  error,
}) => {
  const [hasFocus, setFocus] = useState(false);
  const isError = Boolean(error);

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
      <label htmlFor={id} style={{ fontSize: 14, color: '#374151' }}>{label}</label>
      <div
        className={cx('input', isError && 'inputerror')}
        aria-invalid={isError}
        aria-describedby={helperText ? `${id}-helper` : undefined}
      >
        <span aria-hidden="true" style={{ marginRight: 6 }}>🔎</span>
        <input
          id={id}
          value={value}
          onChange={(e) => onChange(e.target.value)}
          placeholder={placeholder}
          onFocus={() => setFocus(true)}
          onBlur={() => setFocus(false)}
        />
        {value && (
          <button
            type="button"
            className="clear"
            aria-label="Clear"
            onClick={() => onChange('')}
            style={{ marginLeft: 6 }}
          ></button>
        )}
      </div>
      {helperText && (
        <div id={`${id}-helper`} style={{ fontSize: 12, color: '#6b7280' }}>
          {helperText}
        </div>
      )}
      {isError && (
        <div role="alert" style={{ fontSize: 12, color: '#f87171' }}>
          {error}
        </div>
      )}
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Usage example:

import React, { useState } from 'react';
import { TextInput } from './components/TextInput/TextInput';
import { Button } from './components/Button/Button';

export function SearchBox() {
  const [q, setQ] = useState('');
  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 12, width: 320 }}>
      <TextInput
        id="search"
        label="Search"
        value={q}
        onChange={setQ}
        placeholder="Type to search..."
        helperText="Press Enter to submit"
        error={q.length > 0 && q.length < 2 ? 'Need at least 2 characters' : undefined}
      />
      <Button variant="filled" onClick={() => alert(`Searching for "${q}"`)}>
        Search
      </Button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Accessibility notes:

  • Label is associated with input via htmlFor/id.
  • Helper text uses aria-describedby for screen readers.
  • Clear button uses aria-label for clarity.

    4) Theming and composition

  • To change the overall look, adjust CSS variables in a theme file or swap a theme class on a root element.

  • For larger apps, consider a lightweight theming hook that updates CSS variables at runtime.

Example: simple theme switcher

// src/themes.ts
export const themes = {
  light: {
    'color-primary': '#2563eb',
    'color-on-primary': '#ffffff',
    'color-surface': '#ffffff',
    'color-border': '#e5e7eb',
    'color-text': '#1f2937',
  },
  dark: {
    'color-primary': '#3b82f6',
    'color-on-primary': '#0b1020',
    'color-surface': '#111827',
    'color-border': '#374151',
    'color-text': '#e5e7eb',
  },
};

// Simple utility to apply theme vars to document root
export function applyTheme(themeName: keyof typeof themes) {
  const root = document.documentElement;
  const vars = themes[themeName];
  Object.entries(vars).forEach(([k, v]) => {
    root.style.setProperty(k, v);
  });
}
Enter fullscreen mode Exit fullscreen mode

Usage:

import React from 'react';
import { applyTheme } from './themes';

export function ThemeToggle() {
  const [t, setT] = React.useState<'light' | 'dark'>('light');
  React.useEffect(() => applyTheme(t), [t]);
  return (
    <button onClick={() => setT((p) => (p === 'light' ? 'dark' : 'light'))}>
      Toggle Theme
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

5) Testing tips and patterns

  • Render tests ensure basic structure and label association.
  • Interaction tests check keyboard navigation and focus states.
  • Accessibility checks: confirm aria attributes, role correctness, and visible focus rings.
  • Lightweight tests with React Testing Library:
    • Button renders correct text and variant classNames
    • TextInput updates value and clears
  • Avoid end-to-end tests for tiny components; keep tests fast and deterministic.

Example test: Button.test.tsx (conceptual)

import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from './Button';

test('renders with label and handles click', () => {
  const onClick = jest.fn();
  render(<Button onClick={onClick}>Click me</Button>);
  const btn = screen.getByText('Click me');
  expect(btn).toBeInTheDocument();
  fireEvent.click(btn);
  expect(onClick).toHaveBeenCalledTimes(1);
});
Enter fullscreen mode Exit fullscreen mode

Example test: TextInput.test.tsx (conceptual)

import { render, screen, fireEvent } from '@testing-library/react';
import { TextInput } from './TextInput';

test('updates value and clears on action', () => {
  const onChange = jest.fn();
  render(<TextInput id="ti" label="Query" value="" onChange={v => onChange(v)} />);
  const input = screen.getByLabelText('Query') as HTMLInputElement;
  fireEvent.change(input, { target: { value: 'abc' } });
  expect(onChange).toHaveBeenCalledWith('abc');
});
Enter fullscreen mode Exit fullscreen mode

6) Performance considerations

  • Keep components small and focused; avoid unnecessary re-renders by memoizing or using stable props.
  • Use CSS with variables for theming instead of heavy CSS-in-JS libraries that add runtime overhead.
  • Tree-shakeable exports: only export what you need; avoid global CSS that affects most of the page unnecessarily.
  • Measure impact with simple tooling (e.g., bundle size analyzers) in CI to catch bloat.

    7) Quick-start project structure

  • src/

    • components/
    • Button/
      • Button.tsx
    • TextInput/
      • TextInput.tsx
    • styles/
    • base.css
    • components.css
    • utils/
    • classNames.ts
    • index.ts
    • tokens.ts
  • examples/

    • App.tsx (demo usage)
  • package.json scripts:

    • build, test, lint, start (adjust for your stack) ### 8) Real-world guidance: when to reuse vs reinvent
  • Reuse: if you need consistent behavior across multiple pages (buttons, inputs, forms), a small shared component library saves time and reduces bugs.

  • Reinvent carefully: avoid over-abstracting early. Start with a minimal, solid API and iterate based on real needs.

  • Accessibility first: if a feature affects keyboard navigation or screen readers, prioritize correct semantics from day one.

    Summary

You now have a pragmatic blueprint for building accessible, high-performance UI components with a scalable styling approach. You’ve seen a lean Button and a TextInput with an accessible structure, a minimal theming strategy, and a testing pattern that keeps feedback fast. This pattern scales: add more components by following the same approach-semantic HTML, token-driven styling, and small, composable units.

Would you like me to tailor this into a ready-to-run starter repo (with npm/yarn setup, TypeScript configs, and a sample App) for your environment? If yes, tell me your preferred React version (e.g., React 18), your bundler (Vite, Next.js, or Create React App), and whether you want a dark mode toggle included out of the box.

-

Rizwan Saleem | https://rizwansaleem.co

Top comments (0)