Reusable UI component design and implementation methodology. Integrates design tokens, accessibility (a11y via W3C APG), and semantic HTML. Reference for building accessible, theme-compliant React...
Use CSS custom properties from design tokens:
// โ
Use CSS variables
const variantStyles = {
primary: "bg-primary text-primary-foreground hover:bg-primary-hover",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary-hover",
}
// โ Don't hardcode colors
const badStyles = {
primary: "bg-cyan-600 text-white hover:bg-cyan-700",
}
Prefer composable components over complex prop APIs:
// โ
Composable
<Button iconBefore={<Plus />}>Add Item</Button>
<Button iconAfter={<ChevronRight />}>Next</Button>
// โ Over-configured
<Button icon="plus" iconPosition="left" iconSize="sm">
Add Item
</Button>
Build accessibility into the component API:
interface IconButtonProps {
icon: ReactNode
"aria-label": string // Required, not optional
pressed?: boolean // Maps to aria-pressed
}
// Forces accessible usage
<IconButton icon={<X />} aria-label="Close" />
React 19 ใงใฏ forwardRef ใฏ้ๆจๅฅจใงใใref ใ้ๅธธใฎ props ใจใใฆๅใๅใใพใใ
import type { ComponentProps, ReactNode, Ref } from "react"
export type ButtonVariant = "primary" | "secondary" | "ghost" | "destructive"
export type ButtonSize = "sm" | "md" | "lg"
export interface ButtonProps extends ComponentProps<"button"> {
variant?: ButtonVariant
size?: ButtonSize
iconBefore?: ReactNode
iconAfter?: ReactNode
/** Ref to the button element */
ref?: Ref<HTMLButtonElement>
}
const variantStyles: Record<ButtonVariant, string> = {
primary: "bg-primary text-primary-foreground hover:bg-primary-hover",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary-hover",
ghost: "bg-transparent text-foreground hover:bg-accent",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive-hover",
}
const sizeStyles: Record<ButtonSize, string> = {
sm: "h-8 px-3 text-sm gap-1.5",
md: "h-9 px-4 text-sm gap-2",
lg: "h-10 px-5 text-base gap-2",
}
export function Button({
variant = "primary",
size = "md",
className = "",
ref,
...props
}: ButtonProps) {
return (
<button
ref={ref}
type="button"
className={`
inline-flex items-center justify-center font-medium rounded-md
transition-colors
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring
disabled:opacity-50 disabled:pointer-events-none
${variantStyles[variant]}
${sizeStyles[size]}
${className}
`}
{...props}
/>
)
}
Reference: WHATWG HTML Living Standard
| Element | Purpose | Usage |
|---|---|---|
<main> |
Primary content (one per page) | App main content area |
<nav> |
Navigation links | Menu bar, sidebar navigation |
<article> |
Self-contained content | Cards, posts, widgets |
<section> |
Thematic grouping with heading | Content sections |
<aside> |
Tangentially related content | Sidebars, tooltips |
<header> |
Introductory content | Page/section header |
<footer> |
Footer content | Page/section footer |
// Card with semantic structure
<article>
<header>
<h2>{title}</h2>
</header>
<div>{content}</div>
<footer>{actions}</footer>
</article>
// Navigation menu
<nav aria-label="Main navigation">
<ul role="menubar">
<li role="none">
<button role="menuitem">File</button>
</li>
</ul>
</nav>
// Page layout
<main>
<h1>Page Title</h1>
<section>
<h2>Section Title</h2>
{content}
</section>
</main>
<aside aria-label="Sidebar">{sidebar}</aside>
Maintain logical heading structure:
// โ
Correct hierarchy
<main>
<h1>Application</h1>
<section>
<h2>Section</h2>
<h3>Subsection</h3>
</section>
</main>
// โ Skipped levels
<main>
<h1>Application</h1>
<h3>Section</h3> {/* Skipped h2 */}
</main>
Reference: W3C ARIA Authoring Practices Guide
Source: APG Button Pattern
// Standard button
<button type="button">Save</button>
// Icon-only button (requires aria-label)
<button type="button" aria-label="Close">
<X aria-hidden="true" />
</button>
// Toggle button
<button type="button" aria-pressed={isPressed}>
<Star aria-hidden="true" />
</button>
// Menu button
<button
type="button"
aria-haspopup="menu"
aria-expanded={isOpen}
aria-controls="menu-id"
>
Options
</button>
Keyboard: Space / Enter activates button
Source: APG Menu Button Pattern
<div>
<button
aria-haspopup="menu"
aria-expanded={isOpen}
aria-controls="dropdown"
>
Options
</button>
{isOpen && (
<ul id="dropdown" role="menu" aria-label="Options">
<li role="none">
<button role="menuitem">Copy</button>
</li>
<li role="none">
<button role="menuitem">Paste</button>
</li>
</ul>
)}
</div>
Keyboard:
Enter / Space / โ: Open menu, focus first itemโ: Open menu, focus last itemEscape: Close menuSource: APG Tabs Pattern
<div>
<div role="tablist" aria-label="Settings">
<button
role="tab"
id="tab-1"
aria-selected={active === 0}
aria-controls="panel-1"
tabIndex={active === 0 ? 0 : -1}
>
General
</button>
<button
role="tab"
id="tab-2"
aria-selected={active === 1}
aria-controls="panel-2"
tabIndex={active === 1 ? 0 : -1}
>
Advanced
</button>
</div>
<div
role="tabpanel"
id="panel-1"
aria-labelledby="tab-1"
tabIndex={0}
hidden={active !== 0}
>
General settings
</div>
</div>
Keyboard:
Tab: Enter/exit tablistโ / โ: Navigate tabs (horizontal)Home / End: First/last tabSource: APG Dialog Pattern
<div
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
aria-describedby="dialog-desc"
>
<h2 id="dialog-title">Confirm</h2>
<p id="dialog-desc">Are you sure?</p>
<button>Cancel</button>
<button>Confirm</button>
</div>
Keyboard:
Tab: Cycle focus within dialog (trapped)Escape: Close dialogUse native popover for tooltips, menus, and dialogs without JS positioning:
// Trigger button
<button popovertarget="my-popover">Open Menu</button>
// Popover content (auto-positioned, light-dismiss)
<div id="my-popover" popover>
<p>Popover content here</p>
</div>
Attributes:
popover / popover="auto": Light-dismiss (click outside closes)popover="manual": Requires explicit closepopovertarget: Links button to popoverpopovertargetaction: toggle (default), show, hide// Controlled popover with React
function Menu() {
const popoverRef = useRef<HTMLDivElement>(null)
return (
<>
<button popovertarget="menu">Options</button>
<div
ref={popoverRef}
id="menu"
popover="auto"
onToggle={(e) => console.log(e.newState)} // 'open' | 'closed'
>
<button onClick={() => popoverRef.current?.hidePopover()}>
Close
</button>
</div>
</>
)
}
Disable all interactions and remove from accessibility tree:
// Modal with inert background
<>
<div inert={isModalOpen}>
{/* Main content - disabled when modal is open */}
<nav>...</nav>
<main>...</main>
</div>
{isModalOpen && (
<dialog open>
{/* Only this is interactive */}
</dialog>
)}
</>
Use cases:
Native modal with proper focus management:
function Dialog({ open, onClose, children }) {
const dialogRef = useRef<HTMLDialogElement>(null)
useEffect(() => {
const dialog = dialogRef.current
if (open) {
dialog?.showModal() // Traps focus, adds backdrop
} else {
dialog?.close()
}
}, [open])
return (
<dialog
ref={dialogRef}
onClose={onClose}
onCancel={onClose} // Escape key
>
{children}
</dialog>
)
}
/* Native backdrop styling */
dialog::backdrop {
background: oklch(0% 0 0 / 50%);
backdrop-filter: blur(4px);
}
Minimum 24ร24 CSS pixels:
const sizeStyles = {
sm: "min-w-6 min-h-6", // 24px minimum
md: "min-w-8 min-h-8", // 32px
lg: "min-w-10 min-h-10", // 40px
}
Visible focus indicator:
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
| Element | Minimum Ratio |
|---|---|
| Normal text | 4.5:1 |
| Large text (18px+ or 14px bold) | 3:1 |
| UI components & graphics | 3:1 |
| Component | Required Attributes |
|---|---|
| Icon button | aria-label |
| Toggle button | aria-pressed |
| Expandable | aria-expanded, aria-controls |
| Menu button | aria-haspopup="menu", aria-expanded |
| Tab | role="tab", aria-selected, aria-controls |
| Tablist | role="tablist", aria-label |
| Tabpanel | role="tabpanel", aria-labelledby |
| Dialog | role="dialog", aria-modal, aria-labelledby |
| Menu | role="menu", aria-label |
| Menuitem | role="menuitem" |
// Native disabled (removes from tab order)
<button disabled>Disabled</button>
// aria-disabled (stays in tab order, announced as disabled)
<button aria-disabled="true">Disabled</button>
Use disabled:cursor-not-allowed instead of disabled:pointer-events-none for interactive elements.
pointer-events-none hides the cursor feedback, preventing users from understanding why the element is unclickable.
className="motion-safe:transition-all motion-reduce:transition-none"
const variantStyles: Record<ButtonVariant, string> = {
primary: "bg-primary text-primary-foreground",
secondary: "bg-secondary text-secondary-foreground",
}
className={`${variantStyles[variant]} ${sizeStyles[size]}`}
<button className="grid grid-cols-[auto_1fr_auto] gap-2 items-center">
{iconBefore}
<span>{children}</span>
{iconAfter}
</button>
<div className="@container">
<div className="@sm:flex-row @md:grid-cols-3">
{content}
</div>
</div>
// components/index.ts
export { Button } from "./Button"
export type { ButtonProps, ButtonVariant, ButtonSize } from "./Button"
// index.ts
export * from "./components"
Design & Structure:
ref as a prop (React 19 pattern, not forwardRef)Accessibility (WAI-ARIA):
prefers-reduced-motionTesting: