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.
Auto-activated — this skill loads automatically when Claude detects matching context.
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.
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.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 using parallel subagents for backend, frontend, testing, and security. Coordinates architecture design, code generation, test coverage, and quality verification in a single workflow with worktree isolation. Chains with /ork:cover for test generation and /ork:verify for validation. Use when implementing features, building new capabilities, or creating full-stack functionality.
Issue Progress Tracking
GitHub issue workflow ceremony using gh CLI — labels issues as in-progress, creates feature branches (issue/N-description), commits with issue references, posts progress comments, and links PRs with Closes #N. Keeps issues in sync with development work. Use when starting work on an issue, tracking progress, or completing work with a PR.
Last updated on