Skip to main content
OrchestKit v7.5.2 — 89 skills, 31 agents, 99 hooks · Claude Code 2.1.74+
OrchestKit
Skills

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.

Reference medium

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

RuleFileImpactWhen to Use
Skeleton Loadingrules/interaction-skeleton-loading.mdHIGHContent-shaped placeholders for async data
Infinite Scrollrules/interaction-infinite-scroll.mdCRITICALPaginated content with a11y and keyboard support
Progressive Disclosurerules/interaction-progressive-disclosure.mdHIGHRevealing complexity based on user need
Modal / Drawer / Inlinerules/interaction-modal-drawer-inline.mdHIGHChoosing overlay vs inline display patterns
Drag & Droprules/interaction-drag-drop.mdCRITICALReorderable lists with keyboard alternatives
Tabs Overflowrules/interaction-tabs-overflow.mdMEDIUMTab bars with 7+ items or dynamic tabs
Toast Notificationsrules/interaction-toast-notifications.mdHIGHSuccess/error feedback and notification stacking
Cognitive Load Thresholdsrules/interaction-cognitive-load-thresholds.mdHIGHEnforcing Miller's Law, Hick's Law, and Doherty Threshold with numeric limits
Form UXrules/interaction-form-ux.mdHIGHTarget sizing, label placement, error prevention, and smart defaults
Persuasion Ethicsrules/interaction-persuasion-ethics.mdHIGHDetecting dark patterns and applying ethical engagement principles

Total: 10 rules across 6 categories

Decision Table — Loading States

ScenarioPatternWhy
List/card content loadingSkeletonMatches content shape, reduces perceived latency
Form submissionSpinnerIndeterminate, short-lived action
File uploadProgress barMeasurable operation with known total
Image loadingBlur placeholderPrevents layout shift, progressive reveal
Route transitionSkeletonPreserves layout while data loads
Background syncNone / subtle indicatorNon-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

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

  1. Keyboard parity — Every mouse interaction MUST have a keyboard equivalent. No drag-only, no hover-only.
  2. Skeleton over spinner — Use content-shaped placeholders for data loading; reserve spinners for indeterminate actions.
  3. Native HTML first — Prefer &lt;dialog&gt;, <details>, role="feed" over custom implementations.
  4. Progressive enhancement — Features should work without JS where possible, then enhance with interaction.
  5. Announce state changes — Use aria-live regions to announce dynamic content changes to screen readers.
  6. 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

ResourceDescription
references/loading-states-decision-tree.mdDecision tree for skeleton vs spinner vs progress bar
references/interaction-pattern-catalog.mdCatalog of 15+ interaction patterns with when-to-use guidance
references/keyboard-interaction-matrix.mdKeyboard shortcuts matrix for all interactive patterns (WAI-ARIA APG)
  • ork:ui-components — shadcn/ui component patterns and CVA variants
  • ork:animation-motion-design — Motion library and View Transitions API
  • ork:accessibility — WCAG compliance, ARIA patterns, screen reader support
  • ork:responsive-patterns — Responsive layout and container query patterns
  • ork: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:

ElementMaxAction if exceeded
Top-level nav items7Group into categories
Dropdown options (no search)7Add search/filter input
Tab bar items5Add "More" overflow or switch pattern
Dashboard widgets per view7Add 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:

DelayRequired feedback
0–400msNo indicator needed (perceived as instant)
400ms–1sLoading indicator (spinner or inline)
> 1sProgress indicator + skeleton if layout changes
> 2sDeterminate 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 determinate progress (bar with %) when total is known; indeterminate (spinner) only as fallback
  • Never fire-and-forget mutations without visual acknowledgment

References:

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}`}>
        &#x2630;
      </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 KeyboardSensor alongside PointerSensor in useSensors
  • Use sortableKeyboardCoordinates for arrow key navigation within sortable lists
  • Announce drag start, position changes, and drop via aria-live="assertive"
  • Provide visual feedback during drag: opacity, shadow, or scale on 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:

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: &lt;input placeholder="Enter your email" /&gt; — 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 &lt;fieldset&gt; + &lt;legend&gt;.

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 &lt;select&gt; / radio for known options — constrain inputs to prevent errors at the source
  • Validate on blur, NOT keydown — keystroke validation fires while the user is still typing
  • Remember prior choices in multi-step flows (sessionStorage or URL state)
  • Context-aware defaults: country from locale, date format from region

References:

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 with aria-busy during fetches
  • Each item needs aria-posinset and aria-setsize (set -1 when 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 sessionStorage for back navigation restoration

Reference: https://www.w3.org/WAI/ARIA/apg/patterns/feed/

Choose the right overlay pattern: &lt;dialog&gt; 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"
          >
            &times;
          </button>
          <ProductDetail product={selected} />
        </aside>
      )}
    </div>
  )
}
// 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

ScenarioPatternWhy
Delete confirmationModal (&lt;dialog&gt;)Critical action, needs focus lock
Item detail viewDrawer (side panel)Preserves list context
Settings toggleInline expansionSimple, no overlay needed
Multi-field formDrawerSpace for inputs, closeable
Terms acceptanceModalMust acknowledge before proceeding
Image previewInline / lightboxQuick glance, no form inputs

Key rules:

  • Use native &lt;dialog&gt; element for modals — built-in showModal(), 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

#PatternDescriptionSignal to detect
1Confirmshaming"No" button text shames the userButton copy with "No thanks, I hate saving money"
2Roach motelEasy in, impossible outSignup = 1 click; cancel = contact support
3Hidden costsFees revealed only at final checkoutPrice increases on last step
4MisdirectionVisual design hides important infoImportant notice in small grey text near a bright CTA
5Trick questionsDouble negatives or confusing opt-in/out"Uncheck to not receive marketing"
6Disguised adsAds styled as content or navigationAd card identical to organic result card
7Forced continuityTrial auto-renews without clear noticeCredit card required for "free" trial, renewal buried
8Friend spamContact import then messages sent without consent"Invite friends" imports and emails them automatically
9Privacy zuckeringPre-checked data sharing / marketing boxesCheckbox defaulting to "Share with partners" = checked
10Bait-and-switchAdvertised price/feature changed post-commitmentPrice shown in ad differs from checkout price
11False urgencyCountdown timers on non-time-limited offers"Only 2 left!" on always-available item
12NaggingPersistent, dismissal-resistant upgrade promptsModal re-appears after every page load
13Visual 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:

  1. User is aware — the mechanism is transparent
  2. Action is freely reversible — easy to unsubscribe, undo, delete
  3. User benefits — the habit improves their outcome, not just retention metrics

Ethical engagement patterns:

PatternEthical useWhy it's acceptable
ReciprocityGive free tool/content before asking for emailUser receives genuine value first
Social proofShow real user counts / reviewsFactual, verifiable information
ProgressShow completion % in onboardingHelps user achieve their own goal
Variable rewardNotifications for genuinely relevant eventsUser 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 pattern

Key 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:

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

LevelPatternFrequencyExample
1Always visible90%+ of usersName, email, primary action
2<details> / accordion30-50% of usersPreferences, filters
3Wizard / multi-stepSetup flowsOnboarding, complex forms
4Contextual panelPower usersAdmin 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:

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

ScenarioPatternDuration
List / card dataSkeleton> 200ms
Form submissionSpinner (button)< 3s
File uploadProgress barVariable
Image loadBlur placeholderVariable
Route changeSkeleton> 300ms
Background taskSubtle indicatorN/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 @keyframes for subtle shimmer — never spinning skeleton
  • Show skeletons only for loads > 200ms — use startTransition or 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:

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">
          &larr;
        </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">
          &rarr;
        </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", and role="tabpanel" with aria-selected and aria-controls
  • Only the active tab has tabIndex=\{0\}; all others get tabIndex=\{-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 plugin scrollbar-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"
          >
            &times;
          </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

ContextPositionWhy
DesktopTop-rightAway from primary content, visible without scrolling
MobileBottom-centerReachable by thumb, full-width for readability
FormsTop of formNear 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-right on desktop, bottom-center on 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 sonner library — 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

PatternWhen to UseKey Elementa11y
Skeleton screenContent loading with known layoutanimate-pulse divs matching content shapearia-busy="true"
Inline spinnerButton/form submissionSpinner replacing button textaria-busy, disable button
Progress barMeasurable operations (upload, export)&lt;progress&gt; elementaria-valuenow, aria-valuemax
Optimistic updateLow-risk mutations (like, bookmark)Immediate UI change, rollback on errorAnnounce rollback

Scrolling & Pagination

PatternWhen to UseKey Elementa11y
Infinite scrollSocial feeds, image galleriesIntersectionObserver + sentinelrole="feed", aria-live
Load more buttonWhen footer must be reachableExplicit button below contentStandard button a11y
Virtual scroll1000+ items in a list@tanstack/react-virtualaria-rowcount, aria-rowindex
Cursor paginationAPI-driven listsCursor token, no page numbersAnnounce page changes

Disclosure & Expansion

PatternWhen to UseKey Elementa11y
TooltipShort helper text on hover/focustitle or custom tooltiprole="tooltip", aria-describedby
AccordionFAQ, settings sections<details> / <summary>Built-in with native HTML
Expandable rowTable row detailsInline expansion below rowaria-expanded on trigger
Collapsible sectionDashboard sectionsToggle headeraria-expanded, aria-controls

Overlays

PatternWhen to UseKey Elementa11y
Modal dialogConfirmations, critical actions&lt;dialog&gt; elementFocus trap, aria-modal
Drawer / Side panelDetail views, forms, settingsSlide-in panelrole="complementary"
PopoverContext menus, dropdownsPopover API or floating-uiaria-haspopup, focus management
LightboxImage/video previewFull-screen overlayFocus trap, Escape to close
Command palettePower user actions, searchCmd+K triggered overlayrole="combobox", aria-autocomplete

Direct Manipulation

PatternWhen to UseKey Elementa11y
Drag and dropReorder lists, kanban boards@dnd-kit/coreKeyboard arrows + Enter
Inline editQuick text/value editingClick-to-edit, Enter to savearia-label="Edit" on trigger
Resize handlesResizable panels, columnsDrag handle on edgeKeyboard resize with Shift+Arrow
Swipe actionsMobile list item actionsTouch gesture + button fallbackVisible button alternative
PatternWhen to UseKey Elementa11y
Tabs2-6 related viewsrole="tablist"Arrow key navigation
Scrollable tabs7+ tabsScroll arrows + overflow menuRoving tabindex
BreadcrumbsDeep hierarchies&lt;nav aria-label="Breadcrumb"&gt;aria-current="page"
Stepper / WizardMulti-step processesStep indicators + back/nextaria-current="step"

Feedback

PatternWhen to UseKey Elementa11y
Toast notificationSuccess/error feedbackAuto-dismiss (not errors)role="status" / role="alert"
Inline validationForm field errorsError below inputaria-invalid, aria-describedby
Empty stateNo content to displayIllustration + CTADescriptive text
ConfirmationDestructive actionsDialog with explicit action nameFocus on cancel button

Selection Guidelines

  1. Prefer native HTML&lt;dialog&gt;, <details>, &lt;progress&gt; over custom implementations
  2. Match complexity to task — Tooltip for a hint, modal for a critical decision
  3. Mobile-first — Ensure touch targets are 44x44px minimum, swipe actions have button fallbacks
  4. Keyboard always — Every pattern must be operable via keyboard alone
  5. Announce changes — Dynamic content changes need aria-live announcements

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

ComponentTabShift+Tab
Dialog (modal)Cycles within dialog (focus trap)Reverse cycle within dialog
Drawer (non-modal)Can leave drawer to pageCan return to drawer
TabsMoves focus to tab panelReturns to active tab
MenuEnters menu from triggerReturns to trigger
ToastSkipped unless focusable action presentSkipped

Tabs (role="tablist")

KeyAction
Arrow RightMove to next tab
Arrow LeftMove to previous tab
HomeMove to first tab
EndMove to last tab
TabMove focus into tab panel
Enter / SpaceActivate focused tab (manual activation mode)

Dialog (&lt;dialog&gt;)

KeyAction
EscapeClose dialog
TabCycle focus within dialog (trapped)
EnterActivate focused button
Shift+TabReverse cycle within dialog

Accordion (<details>)

KeyAction
Enter / SpaceToggle open/close
TabMove to next focusable element
Arrow DownNext accordion header (if grouped)
Arrow UpPrevious accordion header (if grouped)

Drag & Drop (@dnd-kit)

KeyAction
TabFocus drag handle
Enter / SpacePick up / drop item
Arrow UpMove item up one position
Arrow DownMove item down one position
Arrow LeftMove item left (grid layout)
Arrow RightMove item right (grid layout)
EscapeCancel drag, return to original position
KeyAction
Enter / SpaceOpen menu from trigger
Arrow DownNext menu item / open menu
Arrow UpPrevious menu item
HomeFirst menu item
EndLast menu item
EscapeClose menu, return focus to trigger
Character keyJump to item starting with character

Toast Notifications

KeyAction
Tab (if action present)Focus toast action button
EscapeDismiss focused toast
EnterActivate toast action (e.g., Undo)

Infinite Scroll (role="feed")

KeyAction
Page DownNext article in feed
Page UpPrevious article in feed
TabMove through focusable items within article
End(Optionally) trigger load more

Command Palette

KeyAction
Cmd/Ctrl + KOpen palette
Arrow DownNext item
Arrow UpPrevious item
EnterExecute selected command
EscapeClose palette
Backspace (empty)Go back to parent scope

General Principles

  1. Roving tabindex — In composite widgets (tabs, menus), only one item has tabIndex=\{0\}. Arrow keys move focus. Tab leaves the widget.
  2. Focus visible — All focusable elements must have a visible focus indicator (:focus-visible outline).
  3. Focus restoration — When closing overlays, return focus to the element that triggered the overlay.
  4. No keyboard traps — Users must always be able to navigate away from any component using Tab or Escape.
  5. 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 spinner

Pattern Comparison

PatternUse WhenDurationLayout ShiftPerceived Speed
SkeletonContent loading (lists, cards, profiles)> 200msNoneFast
Spinner (inline)Button actions, form submissions< 3sNoneNeutral
Spinner (overlay)Page-level blocking operations1-10sNoneSlow
Progress barFile upload, export, syncVariableNonePredictable
Blur placeholderImage loadingVariableNoneFast
ShimmerContent loading (alternative to pulse)> 200msNoneFast
NoneBackground sync, prefetchN/ANoneInvisible

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 &lt;progress&gt; 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-image before full image loads
  • Transition from blur to sharp with opacity animation
  • Set explicit width and height to prevent layout shift

Timing Thresholds

ThresholdAction
< 100msNo loading indicator needed
100-200msOptional subtle indicator
200ms-1sSkeleton or spinner
1-5sSkeleton + progress message
5-30sProgress bar with estimate
> 30sProgress 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
Edit on GitHub

Last updated on

On this page

Interaction PatternsQuick ReferenceDecision Table — Loading StatesQuick StartSkeleton LoadingInfinite Scroll with AccessibilityRule DetailsSkeleton LoadingInfinite ScrollProgressive DisclosureModal / Drawer / InlineDrag & DropTabs OverflowToast NotificationsCognitive Load ThresholdsForm UXPersuasion EthicsKey PrinciplesAnti-Patterns (FORBIDDEN)Detailed DocumentationRelated SkillsRules (10)Cognitive Load Thresholds — HIGHCognitive Load ThresholdsMiller's Law: 4±1 Working Memory Chunks (max 7)Hick's Law: Decision Time Grows Logarithmically with OptionsDoherty Threshold: Every Interaction Must Respond Within 400msDrag & Drop with Keyboard Alternatives — CRITICALDrag & Drop with Keyboard AlternativesForm UX — HIGHForm UXFitts's Law: Target Size and PlacementLabel Placement: Top-AlignedError Handling: Poka-Yoke (Mistake-Proofing) with React Hook Form + ZodInfinite Scroll with Accessibility — CRITICALInfinite Scroll with AccessibilityScroll Position RestorationModal vs Drawer vs Inline — HIGHModal vs Drawer vs InlineModal with Native &lt;dialog&gt;Decision MatrixPersuasion Ethics and Dark Patterns — HIGHPersuasion Ethics and Dark Patterns13 Dark Pattern Red Flags — Detect and RejectRefactoring a Dark Pattern into an Ethical AlternativeLegitimate Engagement: Hook Model (Ethical When User Benefits)Progressive Disclosure — HIGHProgressive DisclosureDisclosure Level GuideWizard PatternSkeleton Loading States — HIGHSkeleton Loading StatesDecision GuideSkeleton CompositionTabs Overflow Handling — MEDIUMTabs Overflow HandlingKeyboard NavigationToast Notifications — HIGHToast NotificationsPositioning GuideStackingReferences (3)Interaction Pattern CatalogInteraction Pattern CatalogLoading & WaitingScrolling & PaginationDisclosure & ExpansionOverlaysDirect ManipulationNavigationFeedbackSelection GuidelinesKeyboard Interaction MatrixKeyboard Interaction MatrixTab & Focus ManagementTabs (role="tablist")Dialog (&lt;dialog&gt;)Accordion (<details>)Drag & Drop (@dnd-kit)Menu / DropdownToast NotificationsInfinite Scroll (role="feed")Command PaletteGeneral PrinciplesTesting ChecklistLoading States Decision TreeLoading States Decision TreeDecision FlowPattern ComparisonImplementation GuidelinesSkeletonSpinnerProgress BarBlur PlaceholderTiming ThresholdsAnti-Patterns