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;
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);
}
2) Utility: classNames helper
// src/utils/classNames.ts
export function cx(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ');
}
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';
}
}
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);
}
5) Exports index
// src/index.ts
export { Button } from './components/Button';
export { TextInput } from './components/TextInput';
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>
);
};
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>
);
}
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>
);
};
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>
);
}
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);
});
}
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>
);
}
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);
});
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');
});
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)