Overlay, Navigation & Feedback Components
Build the second tier of the shared component library — Modal, Dialog, Tooltip, Dropdown, Tabs, Accordion, Table, Pagination, Toast, Spinner, Skeleton, and Alert — with correct focus management, keyboard interaction, and typed APIs, then package and document the whole library for consumption by other teams.
Where Lesson 1 covered the form primitives users type into, this lesson covers the components that manage overlays, navigation between views, and feedback about system state — the parts of a UI most prone to accessibility bugs because they involve focus trapping, portals, timing, and live regions rather than just markup. It closes with the practical side of shipping a component library as an actual package: semantic versioning, a build setup that emits types, and Storybook-style documentation that keeps the published API honest.
Modal and Dialog: focus trapping and the portal pattern
A Modal (or Dialog — the terms are often used interchangeably, though 'dialog' technically refers to the ARIA role/HTML element and 'modal' to the behavior of blocking interaction with the rest of the page) has three non-negotiable accessibility requirements: focus moves into the dialog when it opens (usually to the first focusable element or a designated initial-focus target), focus is trapped inside the dialog while it's open (Tab cycling never reaches the page behind it), and focus returns to the element that triggered the dialog when it closes.
Modals render via createPortal into a node outside the normal component tree (commonly document.body) so their position: fixed overlay and z-index stacking aren't constrained by an ancestor's overflow: hidden or transform, which would otherwise clip or mis-position the overlay.
Escape closes the dialog, clicking the backdrop (by convention, though this is a product decision, not an accessibility requirement) closes it, and the dialog's root element carries role="dialog", aria-modal="true", and aria-labelledby pointing at its heading so assistive technology announces both that a dialog opened and what it's titled.
Tooltip and Dropdown: hover/focus disclosure and positioning
A Tooltip must be triggerable by both hover and keyboard focus — a mouse-only tooltip is invisible to keyboard users — and it must not be the only place critical information lives, since tooltips are easy to miss and are stripped from some assistive-technology output. It's associated with its trigger via aria-describedby, and it should not require the user to interact with the tooltip's own content — pointer/focus leaving the trigger is enough to dismiss it, per the WAI-ARIA tooltip pattern.
A Dropdown (a button that reveals a floating panel of options) needs aria-haspopup, aria-expanded reflecting open state on the trigger, and returns focus to the trigger on close. Arrow keys move a 'active descendant' highlight through the options without moving actual DOM focus (aria-activedescendant on the trigger, role="option" on items) — this is the same interaction model native <select> provides, reimplemented for a custom-styled panel.
Positioning both floating elements against a trigger — flipping above/below or left/right when the viewport doesn't have room — is genuinely hard geometry (collision detection, scroll-container awareness) and is why most production libraries delegate this specific concern to a dedicated positioning engine (e.g. Floating UI) rather than hand-rolling it.
Tabs and Accordion: managed vs. composed disclosure
Tabs implement the WAI-ARIA tabs pattern: a role="tablist" containing role="tab" triggers, each aria-controls-linked to a role="tabpanel", with only the selected tab in the natural Tab order (tabIndex={0} on the active tab, -1 on the rest) and Arrow-Left/Right moving selection between tabs without a full Tab keypress — this is what makes tabbed navigation fast for keyboard users instead of requiring one Tab press per tab.
Accordion is structurally similar but the interaction model differs: each header is a real, individually focusable button (not removed from tab order) that toggles its own panel's visibility, and multiple panels may be open simultaneously (unless the product explicitly wants single-open behavior, which is a prop, not a hardcoded rule). aria-expanded on the header and hidden (or conditional rendering) on the panel communicate state.
Table and Pagination: data-driven, generic components
A reusable Table component is a strong candidate for TypeScript generics: Table<T> accepts data: T[] and columns: Column<T>[] where each column knows how to read and render a field of T, giving full type safety on column accessors without the component needing to know the shape of any particular dataset ahead of time. Semantically it should still render real <table>/<thead>/<tbody>/<th scope="col"> markup — a <div> grid loses the row/column navigation and announcement behavior screen readers get from native table semantics.
Pagination is a small, stateless navigation component (currentPage, totalPages, onPageChange) with aria-current="page" on the active page control and aria-label on Previous/Next buttons — its main design challenge is truncating long page-number lists with an ellipsis pattern rather than rendering fifty page buttons.
Toast, Spinner, Skeleton, Alert: transient and status feedback
A Toast is rendered through a portal into a single toast-container region, is queued (only N toasts visible at once, the rest wait), auto-dismisses after a configurable duration but pauses that timer on hover/focus, and is exposed through an imperative API (toast.success('Saved')) from anywhere in the app rather than requiring every call site to manage its own toast state — this imperative pattern is one of the few justified uses of a module-level event emitter or a singleton context in an otherwise declarative codebase.
Spinner is presentational but still needs role="status" and visually-hidden text (Loading…) so its presence is announced to screen reader users, who otherwise get no signal that content is pending. Skeleton (a placeholder shape mimicking the loading content's layout) similarly should carry aria-busy="true" on its container and hidden loading text, and should be swapped for real content rather than left visible once data arrives — it is a loading affordance, not decoration.
Alert communicates a status message inline in the page (as opposed to Toast's transient overlay) and uses role="alert" (assertive, interrupts) or role="status" (polite, waits) depending on urgency — this ARIA live-region role is what causes screen readers to announce the message automatically without the user needing to navigate to it.
• Toast: portal-rendered, queued, pausable auto-dismiss timer, imperative trigger API. • Spinner: role="status" + visually-hidden loading text so it's announced, not just seen. • Skeleton: aria-busy on the loading container; swapped out for real content, not decorative. • Alert: role="alert" (assertive) vs role="status" (polite) chosen by message urgency.
Packaging the library: builds, types, and documentation
Publishing this component set as an installable package means the build must emit both JavaScript (commonly ESM and CJS builds via a bundler like tsup or Rollup) and a .d.ts type declaration file — the type declarations are what give consuming projects the same autocomplete and compile-time variant checking the library's own code enjoys, so skipping type emission defeats a large part of why the components were written in TypeScript.
package.json's exports field maps subpath imports (@company/ui/button) to their built files, and sideEffects: false lets consumers' bundlers tree-shake unused components out of their final bundle — without it, importing one component can pull the entire library into the bundle.
Storybook-style documentation for this tier follows the same discipline as Lesson 1's form components: one story per component showing its default and edge-case states (a Table story with zero rows, a Toast story showing the queue behavior, a Modal story demonstrating focus return), plus an autogenerated prop table, plus a written accessibility note listing the keyboard interactions and ARIA roles implemented — so a consumer never has to read the component's source to know how to use it correctly.
A generic, type-safe Table<T> column API
Generic components (function Table<T>(...)) let the same implementation serve any row shape while still type-checking column accessors against that shape at every call site. Real <table>/<th scope="col"> markup preserves screen reader row/column navigation that a div-based grid loses.
interface Column<T> {
key: string;
header: string;
render: (row: T) => React.ReactNode;
}
interface TableProps<T> {
data: T[];
columns: Column<T>[];
getRowId: (row: T) => string;
}
// The generic parameter T flows from data/columns into render's argument,
// so accessing row.name is type-checked against the actual shape of T.
function Table<T>({ data, columns, getRowId }: TableProps<T>) {
return (
<table>
<thead>
<tr>{columns.map((c) => <th key={c.key} scope="col">{c.header}</th>)}</tr>
</thead>
<tbody>
{data.map((row) => (
<tr key={getRowId(row)}>
{columns.map((c) => <td key={c.key}>{c.render(row)}</td>)}
</tr>
))}
</tbody>
</table>
);
}Code Examples
The effect captures the previously-focused element, moves focus into the dialog, traps Tab within its focusable descendants, and — on cleanup, when the dialog closes — restores focus to whatever triggered it. createPortal mounts the whole overlay at document.body so parent overflow/transform never clips it.
return () => { document.removeEventListener('keydown', onKeyDown); triggerRef.current?.focus(); };
Common mistake: Rendering the modal in place (no portal) inside a component that has overflow: hidden or a CSS transform ancestor causes the overlay to be clipped or mis-positioned — always portal overlay content to the document root.
A single portal-rendered role="status" region holds the live queue; useToast() gives any component toast.success('Saved') without threading toast state through props — the ARIA live region is what makes new toasts get announced automatically.
if (!ctx) throw new Error('useToast must be used within a ToastProvider');
Each header stays a normal, individually-focusable <button> (never removed from Tab order, unlike Tabs), and allowMultiple is a prop controlling whether opening one panel closes the others — both single- and multi-open accordions are the same component.
<div id={panelId} role="region" hidden={!isOpen}>
import { createPortal } from "react-dom";
import { useEffect, useRef } from "react";
interface ModalProps {
isOpen: boolean;
onClose: () => void;
titleId: string;
children: React.ReactNode;
}
function Modal({ isOpen, onClose, titleId, children }: ModalProps) {
const dialogRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!isOpen) return;
triggerRef.current = document.activeElement as HTMLElement;
dialogRef.current?.focus();
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
if (e.key === "Tab") {
const focusables = dialogRef.current?.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (!focusables || focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
}
document.addEventListe
…type ToastTone = "success" | "error" | "info";
interface ToastItem { id: string; message: string; tone: ToastTone; duration: number; }
const ToastContext = React.createContext<{ push: (t: Omit<ToastItem, "id">) => void } | null>(null);
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = React.useState<ToastItem[]>([]);
const push = React.useCallback((t: Omit<ToastItem, "id">) => {
const id = crypto.randomUUID();
setToasts((prev) => [...prev, { ...t, id }]);
setTimeout(() => setToasts((prev) => prev.filter((x) => x.id !== id)), t.duration);
}, []);
return (
<ToastContext.Provider value={{ push }}>
{children}
{createPortal(
<div className="toast-region" role="status" aria-live="polite">
{toasts.map((t) => <div key={t.id} className={`toast toast-${t.tone}`}>{t.message}</div>)}
</div>,
document.body
)}
</ToastContext.Provider>
);
}
// Exposed as a simple imperative-feeling hook, usable from any event handler.
export function useToast() {
const ctx = React.useContext(ToastContext);
if (!ctx) throw new Error("useToast must be used within a ToastP
…interface AccordionItemData { id: string; header: string; content: React.ReactNode; }
function Accordion({ items, allowMultiple = false }: { items: AccordionItemData[]; allowMultiple?: boolean }) {
const [openIds, setOpenIds] = React.useState<Set<string>>(new Set());
function toggle(id: string) {
setOpenIds((prev) => {
const next = allowMultiple ? new Set(prev) : new Set<string>();
if (prev.has(id) && !allowMultiple) return new Set(); // close if re-toggling the only open one
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}
return (
<div className="accordion">
{items.map((item) => {
const isOpen = openIds.has(item.id);
const panelId = `${item.id}-panel`;
return (
<div key={item.id} className="accordion-item">
<h3>
<button
type="button"
aria-expanded={isOpen}
aria-controls={panelId}
onClick={() => toggle(item.id)}
>
{item.header}
</button>
</h3>
<div id={panelId} role="region" hidden={!isOpen}>
{item.content}
…Modal focus lifecycle
Focus enters the dialog on open, is trapped inside it while open (Tab wraps at the edges), and returns to the original trigger on close — the three non-negotiable modal accessibility behaviors.
trigger button [ user activates trigger ]
│ capture as triggerRef
v
┌─────────────────────────────┐
│ Modal opens (portal to │
│ document.body) │
│ focus -> dialog root │ Tab / Shift+Tab
│ ┌─────────────────────┐ │ wrapped at edges
│ │ first focusable <──┼─────┼──┐ (focus trap)
│ │ ... │ │ │
│ │ last focusable ───┼─────┼──┘
│ └─────────────────────┘ │
│ Escape -> onClose() │
└───────────────┬───────────────┘
│ onClose()
v
focus restored -> triggerRef.currentImportant Rules
Overlay components (Modal, Toast) render through createPortal to document.body so ancestor overflow/transform can't clip or mis-position them.
A Modal traps Tab focus within itself while open and restores focus to its trigger element when it closes.
Tabs remove inactive tabs from the natural Tab order (tabIndex={-1}) and use Arrow-Left/Right for selection; Accordion headers stay individually focusable buttons.
Status/feedback components pick the correct live-region role: role="alert" for urgent/interrupting messages, role="status" for polite ones, always with visually-hidden text for pure-visual indicators like Spinner.
A published component package emits .d.ts type declarations alongside its JS build, and declares sideEffects: false so consumers' bundlers can tree-shake unused components.
Why teams reach for headless primitives here
Every component in this lesson — Modal, Dropdown, Tabs, Accordion, Toast — has correct-but-fiddly keyboard and ARIA behavior that is easy to get subtly wrong (off-by-one in a focus trap, a missing aria-controls, a Toast that steals focus from what the user was doing). This is exactly why production teams increasingly build these on top of headless libraries like Radix UI or React Aria rather than writing the interaction logic from scratch per component — a topic the next lesson, on headless components, covers directly.
<div className="modal">{children}</div> // rendered inline, no portal, no focus management createPortal(<div role="dialog" aria-modal="true" ref={dialogRef} tabIndex={-1}>{children}</div>, document.body)
Without a portal the overlay can be visually clipped by an ancestor's overflow: hidden; without focus management, Tab continues to move through the page behind the modal, breaking keyboard and screen-reader users' expectation that a modal blocks the rest of the page.
<div onClick={toggle}>{header}</div> // Accordion header as a non-interactive div
<button type="button" aria-expanded={isOpen} aria-controls={panelId} onClick={toggle}>{header}</button>
A div with a click handler is invisible to keyboard users (no Tab stop, no Enter/Space activation) and announces nothing to screen readers; a real <button> gets all of that natively, plus aria-expanded communicates open/closed state.
Why does a Modal render through createPortal into document.body instead of rendering inline where it's written in the component tree? A. Portals are required for React state to update correctly B. So the overlay's fixed positioning and stacking aren't affected by an ancestor's overflow: hidden or transform C. It makes the component render faster D. It is required for TypeScript to type-check the component
B. So the overlay's fixed positioning and stacking aren't affected by an ancestor's overflow: hidden or transform — An ancestor with overflow: hidden or a CSS transform creates a new containing/clipping context that would clip or mis-position a position: fixed overlay rendered inline; portaling to document.body avoids that entirely.
In an accessible Tabs implementation, why is only the active tab given tabIndex={0} while inactive tabs get tabIndex={-1}? A. To prevent inactive tabs from being styled B. So Tab key navigation moves past the tablist in one stop, while Arrow-Left/Right handle moving selection between individual tabs C. Because tabIndex controls the ARIA role of the element D. It is only a stylistic convention with no accessibility effect
B. So Tab key navigation moves past the tablist in one stop, while Arrow-Left/Right handle moving selection between individual tabs — Removing inactive tabs from the natural Tab order means pressing Tab moves focus past the whole tablist at once; Arrow keys are the WAI-ARIA-specified mechanism for moving selection within the tablist itself.
What does sideEffects: false in a component library's package.json enable for consumers? A. It disables all React side effects like useEffect in the library B. It lets a consumer's bundler tree-shake unused components out of the final bundle instead of pulling in the whole library C. It prevents the library from being published to npm D. It forces consumers to import every component individually
B. It lets a consumer's bundler tree-shake unused components out of the final bundle instead of pulling in the whole library — sideEffects: false tells bundlers that importing a module doesn't run code with observable effects outside itself, which is what allows dead-code elimination of unused exports (tree-shaking) during a consumer's build.
Build a Dropdown component with a trigger button (aria-haspopup="listbox", aria-expanded) and a floating role="listbox" panel of role="option" items, where ArrowDown/ArrowUp move an active-descendant highlight (via aria-activedescendant on the trigger) without moving real DOM focus, and Escape closes the panel and returns focus to the trigger.
Keep isOpen, highlightedId in state on the trigger. On ArrowDown/ArrowUp, move highlightedId to the next/previous option id (wrapping) and keep DOM focus on the trigger button. Render <button aria-haspopup='listbox' aria-expanded={isOpen} aria-activedescendant={highlightedId}> and a portal-or-inline <ul role='listbox'>{options.map(o => <li id={o.id} role='option' aria-selected={o.id===highlightedId}>{o.label}</li>)}</ul>. On Escape, setIsOpen(false) and call trigger.current?.focus().
Track the highlighted option's id in state, set it as aria-activedescendant on the trigger button (which keeps real focus), and update aria-selected/a highlight class on the matching role="option" element rather than calling .focus() on individual options.