Interaction Patterns
UI interaction design patterns for skeleton loading, infinite scroll with accessibility, progressive disclosure, modal/drawer/inline selection, drag-and-drop with keyboard alternatives, tab overflow handling, and toast notification positioning. Use when implementing loading states, content pagination, disclosure patterns, overlay components, reorderable lists, or notification systems.
Primary Agent: frontend-ui-developer
Interaction Patterns
Codifiable UI interaction patterns that prevent common UX failures. Covers loading states, content pagination, disclosure patterns, overlays, drag-and-drop, tab overflow, and notification systems — all with accessibility baked in.
Quick Reference
| Rule | File | Impact | When to Use |
|---|---|---|---|
| Skeleton Loading | rules/interaction-skeleton-loading.md | HIGH | Content-shaped placeholders for async data |
| Infinite Scroll | rules/interaction-infinite-scroll.md | CRITICAL | Paginated content with a11y and keyboard support |
| Progressive Disclosure | rules/interaction-progressive-disclosure.md | HIGH | Revealing complexity based on user need |
| Modal / Drawer / Inline | rules/interaction-modal-drawer-inline.md | HIGH | Choosing overlay vs inline display patterns |
| Drag & Drop | rules/interaction-drag-drop.md | CRITICAL | Reorderable lists with keyboard alternatives |
| Tabs Overflow | rules/interaction-tabs-overflow.md | MEDIUM | Tab bars with 7+ items or dynamic tabs |
| Toast Notifications | rules/interaction-toast-notifications.md | HIGH | Success/error feedback and notification stacking |
| Cognitive Load Thresholds | rules/interaction-cognitive-load-thresholds.md | HIGH | Enforcing Miller's Law, Hick's Law, and Doherty Threshold with numeric limits |
| Form UX | rules/interaction-form-ux.md | HIGH | Target sizing, label placement, error prevention, and smart defaults |
| Persuasion Ethics | rules/interaction-persuasion-ethics.md | HIGH | Detecting dark patterns and applying ethical engagement principles |
Total: 10 rules across 6 categories
Decision Table — Loading States
| Scenario | Pattern | Why |
|---|---|---|
| List/card content loading | Skeleton | Matches content shape, reduces perceived latency |
| Form submission | Spinner | Indeterminate, short-lived action |
| File upload | Progress bar | Measurable operation with known total |
| Image loading | Blur placeholder | Prevents layout shift, progressive reveal |
| Route transition | Skeleton | Preserves layout while data loads |
| Background sync | None / subtle indicator | Non-blocking, low priority |
Quick Start
Skeleton Loading
function CardSkeleton() {
return (
<div className="animate-pulse space-y-3">
<div className="h-48 w-full rounded-lg bg-muted" />
<div className="h-4 w-3/4 rounded bg-muted" />
<div className="h-4 w-1/2 rounded bg-muted" />
</div>
)
}
function CardList({ items, isLoading }: { items: Item[]; isLoading: boolean }) {
if (isLoading) {
return (
<div className="grid grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<CardSkeleton key={i} />
))}
</div>
)
}
return (
<div className="grid grid-cols-3 gap-4">
{items.map((item) => <Card key={item.id} item={item} />)}
</div>
)
}Infinite Scroll with Accessibility
function InfiniteList({ fetchNextPage, hasNextPage, items }: Props) {
const sentinelRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => { if (entry.isIntersecting && hasNextPage) fetchNextPage() },
{ rootMargin: "200px" }
)
if (sentinelRef.current) observer.observe(sentinelRef.current)
return () => observer.disconnect()
}, [fetchNextPage, hasNextPage])
return (
<div role="feed" aria-busy={isFetching}>
{items.map((item) => (
<article key={item.id} aria-posinset={item.index} aria-setsize={-1}>
<ItemCard item={item} />
</article>
))}
<div ref={sentinelRef} />
{hasNextPage && (
<button onClick={() => fetchNextPage()}>Load more items</button>
)}
<div aria-live="polite" className="sr-only">
{`Showing ${items.length} items`}
</div>
</div>
)
}Rule Details
Skeleton Loading
Content-shaped placeholders that match the final layout. Use skeleton for lists, cards, and text blocks.
Load:
rules/interaction-skeleton-loading.md
Infinite Scroll
Accessible infinite scroll with IntersectionObserver, screen reader announcements, and "Load more" fallback.
Load:
rules/interaction-infinite-scroll.md
Progressive Disclosure
Reveal complexity progressively: tooltip, accordion, wizard, contextual panel.
Load:
rules/interaction-progressive-disclosure.md
Modal / Drawer / Inline
Choose the right overlay pattern: modal for confirmations, drawer for detail views, inline for simple toggles.
Load:
rules/interaction-modal-drawer-inline.md
Drag & Drop
Drag-and-drop with mandatory keyboard alternatives using @dnd-kit/core.
Load:
rules/interaction-drag-drop.md
Tabs Overflow
Scrollable tab bars with overflow menus for dynamic or numerous tabs.
Load:
rules/interaction-tabs-overflow.md
Toast Notifications
Positioned, auto-dismissing notifications with ARIA roles and stacking.
Load:
rules/interaction-toast-notifications.md
Cognitive Load Thresholds
Miller's Law (max 7 items per group), Hick's Law (max 1 primary CTA), and Doherty Threshold (400ms feedback) with specific, countable limits.
Load:
rules/interaction-cognitive-load-thresholds.md
Form UX
Fitts's Law touch targets (44px mobile), top-aligned labels, Poka-Yoke error prevention with blur-only validation, and smart defaults.
Load:
rules/interaction-form-ux.md
Persuasion Ethics
13 dark pattern red flags to detect and reject, the Hook Model ethical test (aware, reversible, user-benefits), and EU DSA Art. 25 compliance.
Load:
rules/interaction-persuasion-ethics.md
Key Principles
- Keyboard parity — Every mouse interaction MUST have a keyboard equivalent. No drag-only, no hover-only.
- Skeleton over spinner — Use content-shaped placeholders for data loading; reserve spinners for indeterminate actions.
- Native HTML first — Prefer
<dialog>,<details>,role="feed"over custom implementations. - Progressive enhancement — Features should work without JS where possible, then enhance with interaction.
- Announce state changes — Use
aria-liveregions to announce dynamic content changes to screen readers. - Respect scroll position — Back navigation must restore scroll position; infinite scroll must not lose user's place.
Anti-Patterns (FORBIDDEN)
- Spinner for content loading — Spinners give no spatial hint. Use skeletons matching the content shape.
- Infinite scroll without Load More — Screen readers and keyboard users cannot reach footer content. Always provide a button fallback.
- Modal for browsable content — Modals trap focus and block interaction. Use drawers or inline expansion for browsing.
- Drag-only reorder — Excludes keyboard and assistive tech users. Always provide arrow key + Enter alternatives.
- Toast without ARIA role — Toasts are invisible to screen readers. Use
role="status"for success,role="alert"for errors. - Auto-dismiss error toasts — Users need time to read errors. Never auto-dismiss error notifications.
Detailed Documentation
| Resource | Description |
|---|---|
| references/loading-states-decision-tree.md | Decision tree for skeleton vs spinner vs progress bar |
| references/interaction-pattern-catalog.md | Catalog of 15+ interaction patterns with when-to-use guidance |
| references/keyboard-interaction-matrix.md | Keyboard shortcuts matrix for all interactive patterns (WAI-ARIA APG) |
Related Skills
ork:ui-components— shadcn/ui component patterns and CVA variantsork:animation-motion-design— Motion library and View Transitions APIork:accessibility— WCAG compliance, ARIA patterns, screen reader supportork:responsive-patterns— Responsive layout and container query patternsork:performance— Core Web Vitals and runtime performance optimization
Rules (10)
Cognitive Load Thresholds — HIGH
Cognitive Load Thresholds
Three cognitive science laws with specific, countable thresholds. Apply these as checks during component design — if any count exceeds the limit, restructure before shipping.
Miller's Law: 4±1 Working Memory Chunks (max 7)
Incorrect:
// 11 top-level nav items — exceeds 7, forces chunking in working memory
<nav>
<a href="/home">Home</a>
<a href="/products">Products</a>
<a href="/pricing">Pricing</a>
<a href="/blog">Blog</a>
<a href="/docs">Docs</a>
<a href="/changelog">Changelog</a>
<a href="/status">Status</a>
<a href="/community">Community</a>
<a href="/about">About</a>
<a href="/careers">Careers</a>
<a href="/contact">Contact</a>
</nav>Correct:
// 5 top-level items + grouped overflow — stays within 7, categories aid recall
<nav>
<a href="/home">Home</a>
<a href="/products">Products</a>
<a href="/pricing">Pricing</a>
<a href="/docs">Docs</a>
<DropdownMenu label="Company">
<a href="/blog">Blog</a>
<a href="/about">About</a>
<a href="/careers">Careers</a>
<a href="/contact">Contact</a>
</DropdownMenu>
</nav>Thresholds to enforce:
| Element | Max | Action if exceeded |
|---|---|---|
| Top-level nav items | 7 | Group into categories |
| Dropdown options (no search) | 7 | Add search/filter input |
| Tab bar items | 5 | Add "More" overflow or switch pattern |
| Dashboard widgets per view | 7 | Add scroll sections or collapse groups |
Hick's Law: Decision Time Grows Logarithmically with Options
Incorrect:
// 4 CTAs — decision paralysis, diluted primary action
<div className="flex gap-3">
<Button variant="primary">Save</Button>
<Button variant="secondary">Save as Draft</Button>
<Button variant="secondary">Export PDF</Button>
<Button variant="outline">Preview</Button>
</div>Correct:
// 1 primary + 1 secondary + overflow for rare actions
<div className="flex gap-3">
<Button variant="primary">Save</Button>
<Button variant="secondary">Save as Draft</Button>
<DropdownMenu label="More actions">
<DropdownItem>Export PDF</DropdownItem>
<DropdownItem>Preview</DropdownItem>
</DropdownMenu>
</div>Rules: 1 primary CTA per view maximum. 2 secondary CTAs maximum. Use overflow menus for the rest.
Doherty Threshold: Every Interaction Must Respond Within 400ms
Incorrect:
// No feedback during save — blank wait of unknown duration
async function handleSave() {
await api.save(data) // Could take 2s — user gets nothing
toast('Saved!')
}Correct:
// Optimistic update at 0ms, skeleton at 200ms, reconcile after API
const [optimisticItems, addOptimistic] = useOptimistic(
items,
(state, newItem) => [...state, { ...newItem, pending: true }]
)
async function handleSave(newItem: Item) {
startTransition(() => {
addOptimistic(newItem) // Instant — user sees result immediately
})
await api.save(newItem) // Reconcile in background
}Thresholds:
| Delay | Required feedback |
|---|---|
| 0–400ms | No indicator needed (perceived as instant) |
| 400ms–1s | Loading indicator (spinner or inline) |
| > 1s | Progress indicator + skeleton if layout changes |
| > 2s | Determinate progress bar if measurable |
Key rules:
- Optimistic updates: reflect expected result at 0ms, reconcile after API responds
- Skeleton: display within 200ms of navigation trigger — never show blank screen first
- Use
determinateprogress (bar with %) when total is known;indeterminate(spinner) only as fallback - Never fire-and-forget mutations without visual acknowledgment
References:
- https://www.nngroup.com/articles/response-times-3-important-limits/ (Doherty Threshold)
- https://en.wikipedia.org/wiki/Hick%27s_law
- https://en.wikipedia.org/wiki/The_Magical_Number_Seven,_Plus_or_Minus_Two (Miller's Law)
Drag & Drop with Keyboard Alternatives — CRITICAL
Drag & Drop with Keyboard Alternatives
Drag-and-drop MUST have a keyboard alternative: arrow keys to navigate, Enter/Space to pick up and drop, Escape to cancel. Use @dnd-kit/core for React. Announce all state changes via aria-live.
Incorrect:
// Drag-only — no keyboard support, no screen reader announcements
function SortableList({ items, onReorder }: Props) {
return (
<div>
{items.map((item) => (
<div
key={item.id}
draggable
onDragStart={(e) => e.dataTransfer.setData("id", item.id)}
onDrop={(e) => {
const draggedId = e.dataTransfer.getData("id")
onReorder(draggedId, item.id)
}}
onDragOver={(e) => e.preventDefault()}
>
{item.name}
</div>
))}
</div>
)
}Correct:
// @dnd-kit with keyboard support and screen reader announcements
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core"
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
function SortableItem({ item }: { item: Item }) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: item.id })
return (
<div
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
className={isDragging ? "opacity-50 shadow-lg" : ""}
{...attributes}
{...listeners}
>
<span className="cursor-grab" aria-label={`Reorder ${item.name}`}>
☰
</span>
{item.name}
</div>
)
}
function SortableList({ items, onReorder }: Props) {
const [announcement, setAnnouncement] = useState("")
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
if (over && active.id !== over.id) {
onReorder(active.id as string, over.id as string)
setAnnouncement(`Moved item to position ${items.findIndex((i) => i.id === over.id) + 1}`)
}
}
return (
<>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={items} strategy={verticalListSortingStrategy}>
{items.map((item) => (
<SortableItem key={item.id} item={item} />
))}
</SortableContext>
</DndContext>
<div aria-live="assertive" className="sr-only">
{announcement}
</div>
</>
)
}Key rules:
- Always register
KeyboardSensoralongsidePointerSensorinuseSensors - Use
sortableKeyboardCoordinatesfor arrow key navigation within sortable lists - Announce drag start, position changes, and drop via
aria-live="assertive" - Provide visual feedback during drag:
opacity,shadow, orscaleon the dragged item - Use
@dnd-kit/core— not HTML5 drag API which has no keyboard support - Escape must cancel the drag and return the item to its original position
References:
- https://docs.dndkit.com/
- https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/ (WAI-ARIA keyboard patterns)
Form UX — HIGH
Form UX
Cognitive science principles for form design: Fitts's Law for target acquisition, top-aligned labels for fastest completion, Poka-Yoke error prevention, and smart defaults. Each principle maps to a specific, testable implementation rule.
Fitts's Law: Target Size and Placement
Incorrect:
// Small inline submit on mobile — tiny target, wrong position for LTR reading
<div className="flex items-center gap-2">
<Input name="email" />
<button className="px-2 py-1 text-sm">Go</button> {/* ~32px touch target */}
</div>Correct:
// Full-width on mobile, 44px min touch target, primary action at completion position
<form className="space-y-4">
<Input name="email" label="Email" />
<div className="flex justify-end gap-3">
{/* Destructive action: smaller, positioned left (away from primary) */}
<button type="button" className="text-sm text-destructive px-3 py-2">
Cancel
</button>
{/* Primary action: full-width on mobile, bottom-right on desktop */}
<button
type="submit"
className="w-full sm:w-auto min-h-[44px] sm:min-h-[36px] px-6 py-2 bg-primary text-white rounded"
>
Submit
</button>
</div>
</form>Touch target rules:
- Mobile: minimum 44×44px (Apple HIG / WCAG 2.5.5)
- Desktop: minimum 24×24px
- Destructive actions (Delete, Cancel): smaller targets, position away from primary
- Primary action: bottom-right for LTR layouts (natural completion position)
Label Placement: Top-Aligned
Incorrect: <input placeholder="Enter your email" /> — placeholder disappears on focus, fails a11y.
Correct:
// Explicit <label>, hint via aria-describedby, mark optional (not required)
<div className="space-y-1">
<label htmlFor="email" className="block text-sm font-medium">Email</label>
<input id="email" type="email" className="w-full border rounded px-3 py-2"
aria-describedby="email-hint" />
<p id="email-hint" className="text-xs text-muted-foreground">We'll send your receipt here</p>
</div>
<div className="space-y-1">
<label htmlFor="phone" className="block text-sm font-medium">
Phone <span className="text-muted-foreground font-normal">(optional)</span>
</label>
<input id="phone" type="tel" className="w-full border rounded px-3 py-2" />
</div>Key rules: top-aligned labels are fastest (NNG eye-tracking); never placeholder-only; mark optional fields not required ones; group with <fieldset> + <legend>.
Error Handling: Poka-Yoke (Mistake-Proofing) with React Hook Form + Zod
Incorrect:
// Keystroke validation + blame-the-user error messages
<input
onChange={(e) => {
if (!e.target.value.includes('@')) setError('Invalid email') // Fires while typing!
}}
/>
{error && <p className="text-red-500">Invalid email</p>} {/* No field name, no fix suggestion */}Correct:
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
const schema = z.object({
email: z.string().email('Enter a valid email address (e.g. name@company.com)'),
phone: z.string().regex(/^\+?[\d\s-]{7,}$/, 'Enter a phone number (e.g. +1 555 0100)').optional(),
})
type FormValues = z.infer<typeof schema>
function ContactForm() {
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<FormValues>({
resolver: zodResolver(schema),
})
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
<div className="space-y-1">
<label htmlFor="email" className="block text-sm font-medium">Email</label>
{/* Use correct input type — enables mobile keyboard, browser validation hints */}
<input
id="email"
type="email"
{...register('email')}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
className="w-full border rounded px-3 py-2 aria-[invalid=true]:border-destructive"
/>
{errors.email && (
// Blame the system, not the user; name field + cause + fix
<p id="email-error" role="alert" className="text-sm text-destructive">
{errors.email.message}
</p>
)}
</div>
<button type="submit" disabled={isSubmitting} className="w-full sm:w-auto px-6 py-2 bg-primary text-white rounded disabled:opacity-50">
{isSubmitting ? 'Sending…' : 'Send message'}
</button>
</form>
)
}Error message formula: "[Field] — [cause] — [fix]"
- "We couldn't verify this email — check for typos or try a different address" (system blame, fix given)
- NOT: "Invalid email" (user blame, no fix)
Key rules (Smart Defaults — Postel's Law):
- Pre-fill sensible defaults — never start from zero
- Use
<select>/ radio for known options — constrain inputs to prevent errors at the source - Validate on
blur, NOTkeydown— keystroke validation fires while the user is still typing - Remember prior choices in multi-step flows (
sessionStorageor URL state) - Context-aware defaults: country from locale, date format from region
References:
- https://www.nngroup.com/articles/web-form-design/ (NNG form research)
- https://react-hook-form.com/docs (React Hook Form)
- https://zod.dev (Zod validation)
- https://www.w3.org/WAI/WCAG21/Understanding/target-size.html (WCAG 2.5.5 Touch Targets)
Infinite Scroll with Accessibility — CRITICAL
Infinite Scroll with Accessibility
Infinite scroll MUST include screen reader announcements via aria-live, a visible "Load more" button fallback, scroll position preservation on back navigation, and prevention of keyboard traps.
Incorrect:
// Infinite scroll with no accessibility — keyboard users are trapped,
// screen readers announce nothing, footer is unreachable
function ItemList({ items, loadMore }: Props) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) loadMore()
})
if (ref.current) observer.observe(ref.current)
return () => observer.disconnect()
}, [loadMore])
return (
<div>
{items.map((item) => <div key={item.id}>{item.name}</div>)}
<div ref={ref} />
</div>
)
}Correct:
// Accessible infinite scroll — aria-live, load-more button, role="feed"
function ItemList({ items, loadMore, hasMore, isFetching }: Props) {
const sentinelRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && hasMore && !isFetching) loadMore()
},
{ rootMargin: "200px" }
)
if (sentinelRef.current) observer.observe(sentinelRef.current)
return () => observer.disconnect()
}, [loadMore, hasMore, isFetching])
return (
<>
<div role="feed" aria-busy={isFetching} aria-label="Item list">
{items.map((item, index) => (
<article
key={item.id}
aria-posinset={index + 1}
aria-setsize={hasMore ? -1 : items.length}
tabIndex={0}
>
<ItemCard item={item} />
</article>
))}
</div>
<div ref={sentinelRef} aria-hidden="true" />
{hasMore && (
<button
onClick={() => loadMore()}
disabled={isFetching}
className="mx-auto mt-4 block"
>
{isFetching ? "Loading..." : "Load more items"}
</button>
)}
<div aria-live="polite" className="sr-only">
{isFetching
? "Loading more items"
: `Showing ${items.length} items`}
</div>
</>
)
}Scroll Position Restoration
// Preserve scroll position on back navigation
useEffect(() => {
const key = `scroll-${location.pathname}`
const saved = sessionStorage.getItem(key)
if (saved) window.scrollTo(0, parseInt(saved, 10))
return () => {
sessionStorage.setItem(key, String(window.scrollY))
}
}, [location.pathname])Key rules:
- Use
role="feed"on the container witharia-busyduring fetches - Each item needs
aria-posinsetandaria-setsize(set-1when total unknown) - Always provide a visible "Load more" button below the sentinel — never rely solely on auto-load
- Use
rootMargin: "200px"on IntersectionObserver to pre-fetch before the user reaches the end - Announce item count changes via
aria-live="polite"— never"assertive" - Preserve scroll position in
sessionStoragefor back navigation restoration
Reference: https://www.w3.org/WAI/ARIA/apg/patterns/feed/
Modal vs Drawer vs Inline — HIGH
Modal vs Drawer vs Inline
Choose the right overlay pattern: <dialog> modal for confirmations and critical actions, drawer (side panel) for detail views and forms, inline expansion for simple toggles and previews.
Incorrect:
// Modal for browsing content — blocks page interaction, traps focus unnecessarily
function ProductList({ products }: Props) {
const [selected, setSelected] = useState<Product | null>(null)
return (
<>
{products.map((p) => (
<button key={p.id} onClick={() => setSelected(p)}>{p.name}</button>
))}
{selected && (
<div className="fixed inset-0 bg-black/50 z-50">
<div className="bg-white p-6 max-w-lg mx-auto mt-20 rounded">
<ProductDetail product={selected} />
<button onClick={() => setSelected(null)}>Close</button>
</div>
</div>
)}
</>
)
}Correct:
// Drawer for detail views — preserves page context, slides in from side
function ProductList({ products }: Props) {
const [selected, setSelected] = useState<Product | null>(null)
const closeRef = useRef<HTMLButtonElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
// Move focus into drawer on open
useEffect(() => {
if (selected) closeRef.current?.focus()
}, [selected])
return (
<div className="flex">
<div className="flex-1">
{products.map((p) => (
<button
key={p.id}
ref={selected?.id === p.id ? triggerRef : undefined}
onClick={() => setSelected(p)}
>
{p.name}
</button>
))}
</div>
{selected && (
<aside
role="complementary"
aria-label="Product details"
className="w-96 border-l p-6 overflow-y-auto"
>
<button
ref={closeRef}
onClick={() => { setSelected(null); triggerRef.current?.focus() }}
aria-label="Close panel"
>
×
</button>
<ProductDetail product={selected} />
</aside>
)}
</div>
)
}Modal with Native <dialog>
// Use native <dialog> for confirmations — built-in focus trap and Escape handling
function DeleteConfirmation({ onConfirm, onCancel }: Props) {
const dialogRef = useRef<HTMLDialogElement>(null)
useEffect(() => {
dialogRef.current?.showModal()
}, [])
return (
<dialog
ref={dialogRef}
onClose={onCancel}
className="rounded-lg p-6 backdrop:bg-black/50"
>
<h2>Delete this item?</h2>
<p>This action cannot be undone.</p>
<div className="flex gap-3 mt-4">
<button onClick={onCancel}>Cancel</button>
<button onClick={onConfirm} className="bg-destructive text-white">
Delete
</button>
</div>
</dialog>
)
}Decision Matrix
| Scenario | Pattern | Why |
|---|---|---|
| Delete confirmation | Modal (<dialog>) | Critical action, needs focus lock |
| Item detail view | Drawer (side panel) | Preserves list context |
| Settings toggle | Inline expansion | Simple, no overlay needed |
| Multi-field form | Drawer | Space for inputs, closeable |
| Terms acceptance | Modal | Must acknowledge before proceeding |
| Image preview | Inline / lightbox | Quick glance, no form inputs |
Key rules:
- Use native
<dialog>element for modals — built-inshowModal(), focus trap, Escape to close - Modal = critical actions only (delete, confirm, acknowledge). Never for browsing.
- Drawer = detail views, forms, settings panels. Preserves page context.
- Inline = simple toggles, previews, small expansions. No overlay, no focus trap.
- Always provide Escape key to close overlays and a visible close button
- Return focus to the trigger element when closing any overlay
Reference: https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/
Persuasion Ethics and Dark Patterns — HIGH
Persuasion Ethics and Dark Patterns
The line between legitimate engagement and manipulation is testable: ethical patterns benefit the user and are reversible; dark patterns deceive or trap. Detect and reject the 13 red flags below before shipping.
13 Dark Pattern Red Flags — Detect and Reject
| # | Pattern | Description | Signal to detect |
|---|---|---|---|
| 1 | Confirmshaming | "No" button text shames the user | Button copy with "No thanks, I hate saving money" |
| 2 | Roach motel | Easy in, impossible out | Signup = 1 click; cancel = contact support |
| 3 | Hidden costs | Fees revealed only at final checkout | Price increases on last step |
| 4 | Misdirection | Visual design hides important info | Important notice in small grey text near a bright CTA |
| 5 | Trick questions | Double negatives or confusing opt-in/out | "Uncheck to not receive marketing" |
| 6 | Disguised ads | Ads styled as content or navigation | Ad card identical to organic result card |
| 7 | Forced continuity | Trial auto-renews without clear notice | Credit card required for "free" trial, renewal buried |
| 8 | Friend spam | Contact import then messages sent without consent | "Invite friends" imports and emails them automatically |
| 9 | Privacy zuckering | Pre-checked data sharing / marketing boxes | Checkbox defaulting to "Share with partners" = checked |
| 10 | Bait-and-switch | Advertised price/feature changed post-commitment | Price shown in ad differs from checkout price |
| 11 | False urgency | Countdown timers on non-time-limited offers | "Only 2 left!" on always-available item |
| 12 | Nagging | Persistent, dismissal-resistant upgrade prompts | Modal re-appears after every page load |
| 13 | Visual interference | "Wrong" choice made visually prominent | "Accept All" = bright button; "Manage" = grey link |
Refactoring a Dark Pattern into an Ethical Alternative
Incorrect (confirmshaming + visual interference):
// Cookie banner: shames the "no" option, buries the ethical choice
<div className="fixed bottom-0 p-4 bg-white shadow-lg">
<p>We use cookies to improve your experience.</p>
<div className="flex gap-3 mt-3">
<button className="px-6 py-2 bg-primary text-white font-bold rounded">
Accept All Cookies
</button>
{/* Low-contrast, small — visually penalizes user for choosing privacy */}
<span className="text-xs text-gray-400 underline cursor-pointer">
No thanks, I don't mind a worse experience
</span>
</div>
</div>Correct (equal prominence, neutral copy):
// Cookie banner: equal visual weight, neutral language, clear choices
<div className="fixed bottom-0 p-4 bg-white border-t shadow-lg" role="dialog" aria-label="Cookie preferences">
<p className="text-sm">We use analytics cookies to improve this site. You can opt out at any time in Settings.</p>
<div className="flex gap-3 mt-3">
{/* Equal visual weight — user's choice is not penalised */}
<button
onClick={acceptAll}
className="px-4 py-2 bg-primary text-white rounded"
>
Accept analytics
</button>
<button
onClick={declineAll}
className="px-4 py-2 border border-border rounded" // Same size, neutral style
>
Decline
</button>
<button
onClick={openPreferences}
className="px-4 py-2 text-sm underline"
>
Manage preferences
</button>
</div>
</div>Legitimate Engagement: Hook Model (Ethical When User Benefits)
The Hook Model (Trigger → Action → Variable Reward → Investment) is ethical when:
- User is aware — the mechanism is transparent
- Action is freely reversible — easy to unsubscribe, undo, delete
- User benefits — the habit improves their outcome, not just retention metrics
Ethical engagement patterns:
| Pattern | Ethical use | Why it's acceptable |
|---|---|---|
| Reciprocity | Give free tool/content before asking for email | User receives genuine value first |
| Social proof | Show real user counts / reviews | Factual, verifiable information |
| Progress | Show completion % in onboarding | Helps user achieve their own goal |
| Variable reward | Notifications for genuinely relevant events | User opted in and gets real value |
The Ethical Line — 3-question test:
1. Is the user aware of what's happening? YES → proceed | NO → dark pattern
2. Can the user easily reverse the action? YES → proceed | NO → dark pattern
3. Does the user benefit (not just the company)? YES → proceed | NO → dark patternKey rules:
- Cancellation must be as easy as signup — if signup = 1 click, cancellation must be self-serve
- Countdown timers: only use when the deadline is real and server-enforced
- Pre-selected checkboxes: opt-in (beneficial to user) is acceptable; opt-out (data sharing) is a dark pattern
- Copy for "no" options must be factual and neutral — never shame, never minimize
- EU Digital Services Act Art. 25 and FTC guidelines prohibit deceptive patterns — regulatory risk is real
References:
- https://www.deceptive.design (Dark Patterns Hall of Shame — 13-pattern taxonomy)
- https://nirandfar.com/hooked/ (Hook Model — ethical engagement framework)
- https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX%3A32022R2065 (EU DSA Art. 25)
Progressive Disclosure — HIGH
Progressive Disclosure
Reveal complexity progressively based on frequency of use. Four levels: tooltip (1-click), accordion (<details>), wizard (multi-step), and contextual panel (side drawer).
Incorrect:
// All options visible at once — overwhelming for new users
function SettingsPage() {
return (
<form className="space-y-4">
<Input label="Display name" />
<Input label="Email" />
<Input label="Phone" />
<Input label="Timezone" />
<Select label="Language" />
<Select label="Date format" />
<Input label="SMTP host" />
<Input label="SMTP port" />
<Input label="API key" />
<Input label="Webhook URL" />
<Input label="Custom domain" />
<Checkbox label="Enable 2FA" />
<Checkbox label="Email notifications" />
<Checkbox label="SMS notifications" />
</form>
)
}Correct:
// Progressive disclosure — common settings visible, advanced behind <details>
function SettingsPage() {
return (
<form className="space-y-6">
{/* Level 1: Always visible — used by 90%+ of users */}
<section>
<h2>Profile</h2>
<Input label="Display name" />
<Input label="Email" />
</section>
{/* Level 2: Accordion — used by 30-50% of users */}
<details>
<summary className="cursor-pointer font-medium">
Preferences
</summary>
<div className="mt-3 space-y-3">
<Select label="Timezone" />
<Select label="Language" />
<Select label="Date format" />
</div>
</details>
{/* Level 3: Accordion — used by < 10% of users */}
<details>
<summary className="cursor-pointer font-medium">
Advanced Settings
</summary>
<div className="mt-3 space-y-3">
<Input label="SMTP host" />
<Input label="SMTP port" />
<Input label="API key" type="password" />
<Input label="Webhook URL" />
<Input label="Custom domain" />
</div>
</details>
</form>
)
}Disclosure Level Guide
| Level | Pattern | Frequency | Example |
|---|---|---|---|
| 1 | Always visible | 90%+ of users | Name, email, primary action |
| 2 | <details> / accordion | 30-50% of users | Preferences, filters |
| 3 | Wizard / multi-step | Setup flows | Onboarding, complex forms |
| 4 | Contextual panel | Power users | Admin settings, API config |
Wizard Pattern
// Multi-step wizard for complex setup flows
function OnboardingWizard() {
const [step, setStep] = useState(1)
return (
<div role="group" aria-label={`Step ${step} of 3`}>
<nav aria-label="Progress">
<ol className="flex gap-2">
{[1, 2, 3].map((s) => (
<li key={s} aria-current={s === step ? "step" : undefined}>
Step {s}
</li>
))}
</ol>
</nav>
{step === 1 && <BasicInfoStep />}
{step === 2 && <PreferencesStep />}
{step === 3 && <ReviewStep />}
</div>
)
}Key rules:
- Use native
<details>/<summary>for simple accordions — zero JS, built-in a11y - Group settings by frequency of use: common (visible) > occasional (accordion) > rare (panel)
- Never hide primary actions behind disclosure — only secondary and advanced options
- Wizard steps must show progress (
aria-label="Step 2 of 4") and allow back navigation - Limit visible options to 5-7 items per group (Miller's Law)
References:
- https://www.nngroup.com/articles/progressive-disclosure/
- https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/ (WAI-ARIA disclosure pattern)
Skeleton Loading States — HIGH
Skeleton Loading States
Use skeleton placeholders that match the shape of content being loaded. Reserve spinners for indeterminate actions and progress bars for measurable operations.
Incorrect:
// Spinner for content loading — gives no spatial hint, feels slower
function UserProfile({ isLoading, user }: Props) {
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<Spinner size="lg" />
</div>
)
}
return <ProfileCard user={user} />
}Correct:
// Skeleton matching content shape — preserves layout, reduces perceived latency
function ProfileSkeleton() {
return (
<div className="animate-pulse flex items-center gap-4 p-4">
<div className="h-16 w-16 rounded-full bg-muted" /> {/* Avatar */}
<div className="flex-1 space-y-2">
<div className="h-4 w-1/3 rounded bg-muted" /> {/* Name */}
<div className="h-3 w-1/2 rounded bg-muted" /> {/* Email */}
<div className="h-3 w-2/3 rounded bg-muted" /> {/* Bio */}
</div>
</div>
)
}
function UserProfile({ isLoading, user }: Props) {
if (isLoading) return <ProfileSkeleton />
return <ProfileCard user={user} />
}Decision Guide
| Scenario | Pattern | Duration |
|---|---|---|
| List / card data | Skeleton | > 200ms |
| Form submission | Spinner (button) | < 3s |
| File upload | Progress bar | Variable |
| Image load | Blur placeholder | Variable |
| Route change | Skeleton | > 300ms |
| Background task | Subtle indicator | N/A |
Skeleton Composition
// Reusable skeleton primitives — cn() from shadcn/ui: import { cn } from "@/lib/utils"
function Skeleton({ className }: { className?: string }) {
return <div className={cn("animate-pulse rounded bg-muted", className)} />
}
// Compose to match any content shape
function TableRowSkeleton() {
return (
<tr>
<td><Skeleton className="h-4 w-24" /></td>
<td><Skeleton className="h-4 w-32" /></td>
<td><Skeleton className="h-4 w-16" /></td>
</tr>
)
}Key rules:
- Skeleton shapes must match the content they replace — same height, width, and position
- Use
animate-pulse(Tailwind) or CSS@keyframesfor subtle shimmer — never spinning skeleton - Show skeletons only for loads > 200ms — use
startTransitionor delay to avoid flash - Match skeleton count to expected content count (e.g., 6 card skeletons for a 6-item grid)
- Never nest spinners inside skeletons — pick one pattern per loading context
References:
- https://web.dev/articles/ux-basics-skeleton-screens
- https://www.w3.org/WAI/ARIA/apg/patterns/feed/ (aria-busy pattern for loading states)
Tabs Overflow Handling — MEDIUM
Tabs Overflow Handling
When tab bars contain 7+ items or dynamic tabs, use scrollable tabs with arrow indicators or an overflow dropdown menu. Always use role="tablist" with proper ARIA.
Incorrect:
// Horizontal overflow with no indicators — hidden tabs are undiscoverable
function TabBar({ tabs, activeTab, onSelect }: Props) {
return (
<div className="flex overflow-x-auto">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => onSelect(tab.id)}
className={activeTab === tab.id ? "border-b-2 border-primary" : ""}
>
{tab.label}
</button>
))}
</div>
)
}Correct:
// Scrollable tabs with arrow buttons and overflow menu
function TabBar({ tabs, activeTab, onSelect }: Props) {
const scrollRef = useRef<HTMLDivElement>(null)
const [showLeft, setShowLeft] = useState(false)
const [showRight, setShowRight] = useState(false)
const updateArrows = useCallback(() => {
const el = scrollRef.current
if (!el) return
setShowLeft(el.scrollLeft > 0)
setShowRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1)
}, [])
useEffect(() => {
updateArrows()
const el = scrollRef.current
el?.addEventListener("scroll", updateArrows)
return () => el?.removeEventListener("scroll", updateArrows)
}, [updateArrows])
const scroll = (dir: "left" | "right") => {
scrollRef.current?.scrollBy({
left: dir === "left" ? -200 : 200,
behavior: "smooth",
})
}
return (
<div className="relative flex items-center">
{showLeft && (
<button onClick={() => scroll("left")} aria-label="Scroll tabs left"
className="absolute left-0 z-10 bg-gradient-to-r from-white">
←
</button>
)}
<div
ref={scrollRef}
role="tablist"
className="flex overflow-x-auto gap-1 px-8 scrollbar-none"
/* Hide scrollbar: .scrollbar-none { scrollbar-width: none; -webkit-overflow-scrolling: touch; } */
>
{tabs.map((tab) => (
<button
key={tab.id}
role="tab"
aria-selected={activeTab === tab.id}
aria-controls={`panel-${tab.id}`}
tabIndex={activeTab === tab.id ? 0 : -1}
onClick={() => onSelect(tab.id)}
className="whitespace-nowrap px-4 py-2"
>
{tab.label}
</button>
))}
</div>
{showRight && (
<button onClick={() => scroll("right")} aria-label="Scroll tabs right"
className="absolute right-0 z-10 bg-gradient-to-l from-white">
→
</button>
)}
</div>
)
}Keyboard Navigation
// Arrow key navigation within tablist (WAI-ARIA Tabs pattern)
function handleTabKeyDown(e: React.KeyboardEvent, tabs: Tab[], activeTab: string, onSelect: (id: string) => void) {
const currentIndex = tabs.findIndex((t) => t.id === activeTab)
let nextIndex = currentIndex
if (e.key === "ArrowRight") nextIndex = (currentIndex + 1) % tabs.length
if (e.key === "ArrowLeft") nextIndex = (currentIndex - 1 + tabs.length) % tabs.length
if (e.key === "Home") nextIndex = 0
if (e.key === "End") nextIndex = tabs.length - 1
if (nextIndex !== currentIndex) {
e.preventDefault()
onSelect(tabs[nextIndex].id)
}
}Key rules:
- Show scroll arrows only when content overflows — check
scrollWidth > clientWidth - Use
role="tablist",role="tab", androle="tabpanel"witharia-selectedandaria-controls - Only the active tab has
tabIndex=\{0\}; all others gettabIndex=\{-1\}(roving tabindex) - Arrow keys navigate between tabs; Tab key moves focus to the panel content
- For 10+ tabs, add an overflow dropdown menu ("More...") showing hidden tabs
- Hide scrollbar with CSS
scrollbar-width: none(or Tailwind pluginscrollbar-none) but keep scroll arrows visible
Reference: https://www.w3.org/WAI/ARIA/apg/patterns/tabs/
Toast Notifications — HIGH
Toast Notifications
Position toasts bottom-center on mobile and top-right on desktop. Auto-dismiss success after 5s but never auto-dismiss errors. Use role="status" for informational toasts and role="alert" for errors.
Incorrect:
// Toast with no ARIA role — screen readers never announce it
// Auto-dismisses errors — users cannot read the message
function showToast(message: string) {
const toast = document.createElement("div")
toast.textContent = message
toast.className = "fixed bottom-4 right-4 bg-black text-white p-4 rounded"
document.body.appendChild(toast)
setTimeout(() => toast.remove(), 3000) // All toasts auto-dismiss, including errors
}Correct:
// Accessible toast system with proper ARIA roles and auto-dismiss logic
type ToastType = "success" | "error" | "info" | "warning"
interface Toast {
id: string
message: string
type: ToastType
}
function ToastContainer({ toasts, onDismiss }: Props) {
return (
<div
className="fixed top-4 right-4 z-50 flex flex-col gap-2
max-sm:top-auto max-sm:bottom-4 max-sm:right-4 max-sm:left-4"
>
{toasts.map((toast) => (
<div
key={toast.id}
role={toast.type === "error" ? "alert" : "status"}
aria-live={toast.type === "error" ? "assertive" : "polite"}
className={cn(
"flex items-center gap-3 rounded-lg p-4 shadow-lg",
toast.type === "error" && "bg-destructive text-white",
toast.type === "success" && "bg-green-600 text-white",
toast.type === "info" && "bg-blue-600 text-white",
)}
>
<span className="flex-1">{toast.message}</span>
<button
onClick={() => onDismiss(toast.id)}
aria-label="Dismiss notification"
>
×
</button>
</div>
))}
</div>
)
}
// Auto-dismiss logic — NEVER auto-dismiss errors
function useToast() {
const [toasts, setToasts] = useState<Toast[]>([])
const addToast = useCallback((message: string, type: ToastType = "info") => {
const id = crypto.randomUUID()
setToasts((prev) => [...prev, { id, message, type }])
if (type !== "error") {
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id))
}, 5000)
}
}, [])
const dismiss = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id))
}, [])
return { toasts, addToast, dismiss }
}Positioning Guide
| Context | Position | Why |
|---|---|---|
| Desktop | Top-right | Away from primary content, visible without scrolling |
| Mobile | Bottom-center | Reachable by thumb, full-width for readability |
| Forms | Top of form | Near the action that triggered the toast |
Stacking
// Stack toasts with newest on top, max 3 visible
const visibleToasts = toasts.slice(-3)Key rules:
- Use
role="status"+aria-live="polite"for success/info toasts - Use
role="alert"+aria-live="assertive"for error toasts - Auto-dismiss success/info after 5 seconds; NEVER auto-dismiss errors
- Position:
top-righton desktop,bottom-centeron mobile (use media query) - Stack max 3 visible toasts; dismiss oldest when limit is exceeded
- Every toast must have a visible dismiss button (not just auto-dismiss)
- Consider using
sonnerlibrary — built-in a11y, stacking, and swipe-to-dismiss
Reference: https://www.w3.org/WAI/ARIA/apg/patterns/alert/
References (3)
Interaction Pattern Catalog
Interaction Pattern Catalog
A catalog of UI interaction patterns with when-to-use guidance. Organized by interaction type.
Loading & Waiting
| Pattern | When to Use | Key Element | a11y |
|---|---|---|---|
| Skeleton screen | Content loading with known layout | animate-pulse divs matching content shape | aria-busy="true" |
| Inline spinner | Button/form submission | Spinner replacing button text | aria-busy, disable button |
| Progress bar | Measurable operations (upload, export) | <progress> element | aria-valuenow, aria-valuemax |
| Optimistic update | Low-risk mutations (like, bookmark) | Immediate UI change, rollback on error | Announce rollback |
Scrolling & Pagination
| Pattern | When to Use | Key Element | a11y |
|---|---|---|---|
| Infinite scroll | Social feeds, image galleries | IntersectionObserver + sentinel | role="feed", aria-live |
| Load more button | When footer must be reachable | Explicit button below content | Standard button a11y |
| Virtual scroll | 1000+ items in a list | @tanstack/react-virtual | aria-rowcount, aria-rowindex |
| Cursor pagination | API-driven lists | Cursor token, no page numbers | Announce page changes |
Disclosure & Expansion
| Pattern | When to Use | Key Element | a11y |
|---|---|---|---|
| Tooltip | Short helper text on hover/focus | title or custom tooltip | role="tooltip", aria-describedby |
| Accordion | FAQ, settings sections | <details> / <summary> | Built-in with native HTML |
| Expandable row | Table row details | Inline expansion below row | aria-expanded on trigger |
| Collapsible section | Dashboard sections | Toggle header | aria-expanded, aria-controls |
Overlays
| Pattern | When to Use | Key Element | a11y |
|---|---|---|---|
| Modal dialog | Confirmations, critical actions | <dialog> element | Focus trap, aria-modal |
| Drawer / Side panel | Detail views, forms, settings | Slide-in panel | role="complementary" |
| Popover | Context menus, dropdowns | Popover API or floating-ui | aria-haspopup, focus management |
| Lightbox | Image/video preview | Full-screen overlay | Focus trap, Escape to close |
| Command palette | Power user actions, search | Cmd+K triggered overlay | role="combobox", aria-autocomplete |
Direct Manipulation
| Pattern | When to Use | Key Element | a11y |
|---|---|---|---|
| Drag and drop | Reorder lists, kanban boards | @dnd-kit/core | Keyboard arrows + Enter |
| Inline edit | Quick text/value editing | Click-to-edit, Enter to save | aria-label="Edit" on trigger |
| Resize handles | Resizable panels, columns | Drag handle on edge | Keyboard resize with Shift+Arrow |
| Swipe actions | Mobile list item actions | Touch gesture + button fallback | Visible button alternative |
Navigation
| Pattern | When to Use | Key Element | a11y |
|---|---|---|---|
| Tabs | 2-6 related views | role="tablist" | Arrow key navigation |
| Scrollable tabs | 7+ tabs | Scroll arrows + overflow menu | Roving tabindex |
| Breadcrumbs | Deep hierarchies | <nav aria-label="Breadcrumb"> | aria-current="page" |
| Stepper / Wizard | Multi-step processes | Step indicators + back/next | aria-current="step" |
Feedback
| Pattern | When to Use | Key Element | a11y |
|---|---|---|---|
| Toast notification | Success/error feedback | Auto-dismiss (not errors) | role="status" / role="alert" |
| Inline validation | Form field errors | Error below input | aria-invalid, aria-describedby |
| Empty state | No content to display | Illustration + CTA | Descriptive text |
| Confirmation | Destructive actions | Dialog with explicit action name | Focus on cancel button |
Selection Guidelines
- Prefer native HTML —
<dialog>,<details>,<progress>over custom implementations - Match complexity to task — Tooltip for a hint, modal for a critical decision
- Mobile-first — Ensure touch targets are 44x44px minimum, swipe actions have button fallbacks
- Keyboard always — Every pattern must be operable via keyboard alone
- Announce changes — Dynamic content changes need
aria-liveannouncements
Keyboard Interaction Matrix
Keyboard Interaction Matrix
Keyboard shortcuts and interaction patterns for all interactive components, aligned with WAI-ARIA Authoring Practices Guide (APG).
Tab & Focus Management
| Component | Tab | Shift+Tab |
|---|---|---|
| Dialog (modal) | Cycles within dialog (focus trap) | Reverse cycle within dialog |
| Drawer (non-modal) | Can leave drawer to page | Can return to drawer |
| Tabs | Moves focus to tab panel | Returns to active tab |
| Menu | Enters menu from trigger | Returns to trigger |
| Toast | Skipped unless focusable action present | Skipped |
Tabs (role="tablist")
| Key | Action |
|---|---|
| Arrow Right | Move to next tab |
| Arrow Left | Move to previous tab |
| Home | Move to first tab |
| End | Move to last tab |
| Tab | Move focus into tab panel |
| Enter / Space | Activate focused tab (manual activation mode) |
Dialog (<dialog>)
| Key | Action |
|---|---|
| Escape | Close dialog |
| Tab | Cycle focus within dialog (trapped) |
| Enter | Activate focused button |
| Shift+Tab | Reverse cycle within dialog |
Accordion (<details>)
| Key | Action |
|---|---|
| Enter / Space | Toggle open/close |
| Tab | Move to next focusable element |
| Arrow Down | Next accordion header (if grouped) |
| Arrow Up | Previous accordion header (if grouped) |
Drag & Drop (@dnd-kit)
| Key | Action |
|---|---|
| Tab | Focus drag handle |
| Enter / Space | Pick up / drop item |
| Arrow Up | Move item up one position |
| Arrow Down | Move item down one position |
| Arrow Left | Move item left (grid layout) |
| Arrow Right | Move item right (grid layout) |
| Escape | Cancel drag, return to original position |
Menu / Dropdown
| Key | Action |
|---|---|
| Enter / Space | Open menu from trigger |
| Arrow Down | Next menu item / open menu |
| Arrow Up | Previous menu item |
| Home | First menu item |
| End | Last menu item |
| Escape | Close menu, return focus to trigger |
| Character key | Jump to item starting with character |
Toast Notifications
| Key | Action |
|---|---|
| Tab (if action present) | Focus toast action button |
| Escape | Dismiss focused toast |
| Enter | Activate toast action (e.g., Undo) |
Infinite Scroll (role="feed")
| Key | Action |
|---|---|
| Page Down | Next article in feed |
| Page Up | Previous article in feed |
| Tab | Move through focusable items within article |
| End | (Optionally) trigger load more |
Command Palette
| Key | Action |
|---|---|
| Cmd/Ctrl + K | Open palette |
| Arrow Down | Next item |
| Arrow Up | Previous item |
| Enter | Execute selected command |
| Escape | Close palette |
| Backspace (empty) | Go back to parent scope |
General Principles
- Roving tabindex — In composite widgets (tabs, menus), only one item has
tabIndex=\{0\}. Arrow keys move focus. Tab leaves the widget. - Focus visible — All focusable elements must have a visible focus indicator (
:focus-visibleoutline). - Focus restoration — When closing overlays, return focus to the element that triggered the overlay.
- No keyboard traps — Users must always be able to navigate away from any component using Tab or Escape.
- Skip links — Provide "Skip to main content" links for keyboard users navigating past repeated headers/nav.
Testing Checklist
- All interactive elements reachable via Tab key
- Arrow keys work within composite widgets (tabs, menus, drag handles)
- Escape closes overlays and cancels drag operations
- Focus returns to trigger element after overlay closes
- No keyboard traps — Tab always moves focus forward
- Focus indicator visible on all focused elements
- Screen reader announces state changes via
aria-live
Reference: https://www.w3.org/WAI/ARIA/apg/
Loading States Decision Tree
Loading States Decision Tree
Decision Flow
Is the operation measurable (known total)?
├── YES → Is total > 5 seconds?
│ ├── YES → Progress bar with percentage + time estimate
│ └── NO → Progress bar with percentage only
└── NO → Is it loading content that has a known shape?
├── YES → Does the content area have a defined layout?
│ ├── YES → Skeleton matching layout shape
│ └── NO → Content placeholder (gray box)
└── NO → Is it a user-initiated action (button click, form submit)?
├── YES → Is expected duration < 1 second?
│ ├── YES → Button spinner (inline)
│ └── NO → Overlay spinner with message
└── NO → Is it a background task?
├── YES → Subtle indicator (status bar, badge)
└── NO → Full-area spinnerPattern Comparison
| Pattern | Use When | Duration | Layout Shift | Perceived Speed |
|---|---|---|---|---|
| Skeleton | Content loading (lists, cards, profiles) | > 200ms | None | Fast |
| Spinner (inline) | Button actions, form submissions | < 3s | None | Neutral |
| Spinner (overlay) | Page-level blocking operations | 1-10s | None | Slow |
| Progress bar | File upload, export, sync | Variable | None | Predictable |
| Blur placeholder | Image loading | Variable | None | Fast |
| Shimmer | Content loading (alternative to pulse) | > 200ms | None | Fast |
| None | Background sync, prefetch | N/A | None | Invisible |
Implementation Guidelines
Skeleton
- Match the shape of the content being loaded (height, width, border-radius)
- Use
animate-pulse(Tailwind) for subtle animation - Show skeleton only after 200ms delay to avoid flash for fast loads
- Match skeleton count to expected item count
Spinner
- Inline spinner: replace button text, keep button dimensions
- Overlay spinner: center in container, add semi-transparent backdrop
- Always include
aria-busy="true"on the loading container - Use
role="status"with screen reader text: "Loading..."
Progress Bar
- Show percentage and/or time estimate for operations > 5s
- Use
<progress>element for semantic HTML - Update smoothly — avoid jumps larger than 10%
- Show indeterminate state if total is temporarily unknown
Blur Placeholder
- Generate low-res blur hash at build time (BlurHash, LQIP)
- Apply as CSS
background-imagebefore full image loads - Transition from blur to sharp with
opacityanimation - Set explicit
widthandheightto prevent layout shift
Timing Thresholds
| Threshold | Action |
|---|---|
| < 100ms | No loading indicator needed |
| 100-200ms | Optional subtle indicator |
| 200ms-1s | Skeleton or spinner |
| 1-5s | Skeleton + progress message |
| 5-30s | Progress bar with estimate |
| > 30s | Progress bar + cancel button |
Anti-Patterns
- Spinner for content loading — Gives no spatial hint; use skeleton instead
- Flash of loading state — Show skeleton only after 200ms delay
- Progress bar jumping backward — Never decrease progress; use indeterminate if uncertain
- Multiple loading indicators — One loading indicator per visual region, not per component
- Loading without timeout — Always set a timeout and show error/retry after 30s
Implement
Full-power feature implementation with parallel subagents. Use when implementing, building, or creating features.
Issue Progress Tracking
Auto-updates GitHub issues with commit progress. Use when starting work on an issue, tracking progress during implementation, or completing work with a PR.
Last updated on