DEV Community

Cover image for Your Dialog Has role='dialog'. That Doesn't Make It Accessible.
venkatesh m
venkatesh m

Posted on

Your Dialog Has role='dialog'. That Doesn't Make It Accessible.

The Attribute Isn't the Behavior

Open any component library's Dialog implementation. You'll find role="dialog" and aria-modal="true" on the content panel. Check the box, ship it, call it accessible.

Now try using it with a keyboard.

Open the dialog. Press Tab. Where does focus go? If it goes behind the dialog to the page content, the dialog isn't accessible — a keyboard user is now interacting with elements they can't see, behind a modal overlay. Press Tab twelve more times. If focus never wraps back to the first element inside the dialog, it's not trapped. Press Escape. If the dialog doesn't close and return focus to the button that opened it, a keyboard user is stranded.

role="dialog" tells a screen reader "this is a dialog." It doesn't trap focus. It doesn't handle Escape. It doesn't restore focus on close. It doesn't prevent clicks outside from reaching the page behind. It's a label, not a behavior.

The gap between "has the right ARIA attributes" and "actually works for someone using a keyboard or screen reader" is where most component libraries cut corners. Not out of malice — out of complexity. A fully accessible modal dialog requires a focus trap with tabbable element detection, click-outside dismissal that doesn't false-positive on drag events, Escape key handling that doesn't leak to parent components, focus restoration to the trigger element, and initial focus placement with a priority chain. That's five independent behaviors converging on one component.

I built all of it from scratch for flintwork — a headless design system where the primitives handle behavior and accessibility with zero styling. No Radix import, no Headless UI dependency. Every hook, every edge case, hand-written and tested.

Here's what that taught me.


What a Screen Reader Actually Hears

While studying existing implementations — reading the WAI-ARIA authoring practices alongside Radix's source code — I spent time with VoiceOver on macOS testing dialog behavior. The difference between a properly built dialog and a half-built one is immediately obvious — not visually, but audibly.

A properly built dialog trigger announces: "Open settings, button, dialog popup." Three pieces of information from three attributes: the text content, the element role, and aria-haspopup="dialog" telling the user that activating this button will open a dialog.

When the dialog opens, VoiceOver announces: "Settings, dialog. Update your preferences." The title comes from aria-labelledby pointing to the dialog's heading. The description comes from aria-describedby. Without aria-labelledby, VoiceOver just says "dialog" — no context, no purpose. The user has to navigate around inside the dialog to figure out what it's for.

When the user presses Escape, the dialog closes and VoiceOver announces: "Open settings, button." Focus returned to the trigger. The user is back where they started. Without focus restoration, the user lands on <body> or the first focusable element on the page — they've lost their place entirely.

Every ARIA attribute is an audio experience. Once you hear the difference, writing aria-labelledby stops being a spec compliance checkbox and starts being "the thing that makes the dialog announce its purpose."


The Problem With Monolithic Components

A single-component Dialog looks clean at first:

<Dialog
  triggerText="Open"
  title="Confirm"
  onConfirm={handleConfirm}
/>
Enter fullscreen mode Exit fullscreen mode

Then requirements arrive. Custom trigger? Add a renderTrigger prop. Form content? Add children. Multiple action buttons? Add footer. Two close buttons? Now you need renderFooter. Twelve props later, half of them render functions, and you still can't put the close button where you want it.

Compound components solve this with composition:

<Dialog open={open} onOpenChange={setOpen}>
  <Dialog.Trigger>
    <button>Open settings</button>
  </Dialog.Trigger>
  <Dialog.Portal>
    <Dialog.Overlay />
    <Dialog.Content>
      <Dialog.Title>Settings</Dialog.Title>
      <Dialog.Description>
        Update your preferences.
      </Dialog.Description>
      <Dialog.Close>
        <button>Done</button>
      </Dialog.Close>
    </Dialog.Content>
  </Dialog.Portal>
</Dialog>
Enter fullscreen mode Exit fullscreen mode

The consumer controls the structure. The compound components handle the behavior. Dialog.Trigger automatically gets aria-haspopup="dialog" and aria-expanded. Dialog.Content automatically gets role="dialog" and aria-modal="true". The consumer doesn't think about ARIA. They get it for free.

Seven sub-components, but the complexity concentrates in one. Dialog.Content is where all three behavioral hooks converge — useFocusTrap for Tab cycling, useClickOutside for dismissal, and the Escape key handler. Every other sub-component either provides context, renders a portal, or attaches a click handler. Content does the heavy lifting.


Building the Focus Trap

The focus trap is the hardest piece. It's also the piece that makes the dialog actually accessible versus just labeled as accessible.

What "Tabbable" Means

Before you can trap focus, you need to know what focus can land on. The list is longer than most developers expect:

<a href>, <button> (not disabled), <input> (not disabled, not type="hidden"), <select> (not disabled), <textarea> (not disabled), <summary>, [tabindex="0"], <iframe>, <audio controls>, <video controls>, [contenteditable].

But matching the selector isn't enough. An element that matches button:not(:disabled) can still be untabbable if it or an ancestor has display: none, if it has visibility: hidden, if it's inside a closed <details> element (except the <summary>), if it has [inert] or lives inside an [inert] ancestor, or if it has tabindex="-1" (focusable via .focus() but skipped by Tab).

Each of those is a filter that runs on every candidate. The tabbable query re-runs on every Tab press — no caching — because content inside the dialog can change while it's open. A form that conditionally reveals a field, a loading state that resolves to a button, an error message with a retry link.

One early bug: <details> was in the selector, but <details open> matched alongside its <summary> child, returning three elements where the DOM only had two interactive ones. The fix was removing details from the selector entirely — <summary> is the interactive element users Tab to, <details> is just the container.

The Trap Lifecycle

Activation. Focus moves into the dialog. Priority chain: a specific element if provided → an element with [data-autofocus] → the first tabbable element → the container itself.

Active. Tab on the last element wraps to the first. Shift+Tab on the first wraps to the last. If focus somehow escapes — screen reader virtual cursor, programmatic focus change — a focusin guard pulls it back.

Deactivation. Focus returns to whatever was focused before the dialog opened. Usually the trigger button.

useEffect(() => {
  if (!enabled) return;
  const container = containerRef.current;
  if (!container) return;

  previouslyFocused.current = document.activeElement as HTMLElement;
  placeInitialFocus();

  document.addEventListener('keydown', handleKeyDown, true);
  document.addEventListener('focusin', handleFocusIn, true);

  return () => {
    document.removeEventListener('keydown', handleKeyDown, true);
    document.removeEventListener('focusin', handleFocusIn, true);
    const toRestore = previouslyFocused.current;
    if (toRestore?.isConnected) {
      toRestore.focus();
    }
  };
}, [enabled]);
Enter fullscreen mode Exit fullscreen mode

No requestAnimationFrame. An earlier version used rAF under the assumption that React might not have committed the portal to the DOM by the time the effect runs. This was wrong — useEffect fires after the browser has painted. The container and its children are guaranteed to exist. The rAF added unnecessary async complexity and broke under test frameworks with fake timers. Removing it simplified both the implementation and the tests.

The Tab Cycling Logic

function handleKeyDown(event: KeyboardEvent) {
  if (event.key !== 'Tab') return;
  const container = containerRef.current;
  if (!container) return;

  const tabbable = getTabbableElements(container);
  if (tabbable.length === 0) {
    event.preventDefault();
    return;
  }

  const active = document.activeElement as HTMLElement;
  const first = tabbable.at(0) ?? null;
  const last = tabbable.at(-1) ?? null;

  // Focus is outside the container entirely — pull it back in
  if (!container.contains(active)) {
    event.preventDefault();
    first?.focus();
    return;
  }

  if (event.shiftKey && active === first) {
    event.preventDefault();
    last?.focus();
  } else if (!event.shiftKey && active === last) {
    event.preventDefault();
    first?.focus();
  }
}
Enter fullscreen mode Exit fullscreen mode

The key insight: you only need to intercept Tab at the boundaries and when focus has escaped. When focus is on the last element and the user presses Tab, prevent the default and focus the first. When focus is on the first and the user presses Shift+Tab, focus the last. The !container.contains(active) check handles the case where focus escaped via screen reader virtual cursor before the focusin guard fired. Everywhere else, the browser's native Tab behavior works correctly within the container.


Building useClickOutside

Clicking outside the dialog should close it. Simple concept, subtle implementation.

Why mousedown, not click. A click event fires after mouseup. If a user presses inside the dialog, drags outside, and releases, a click fires outside the dialog. The dialog would incorrectly dismiss. mousedown captures intent at the moment of press. Same logic applies to touchstart for mobile.

Why capture phase. The listener uses addEventListener('mousedown', handler, true). Capture fires before the event reaches child elements. If a child inside the dialog calls stopPropagation(), a bubble-phase listener on document would never see the event. Capture guarantees the check runs regardless.

The handler ref pattern. The handler is stored in a ref so the effect doesn't re-attach listeners when the handler changes. The ref always holds the latest function — no stale closures — and the consumer doesn't need to memoize their callback with useCallback.

const handlerRef = useRef(handler);
handlerRef.current = handler;
Enter fullscreen mode Exit fullscreen mode

Roving Tabindex: Why Arrow Keys Matter

A tab bar with five triggers, all with tabindex="0", requires pressing Tab five times to get past it. Roving tabindex solves this. Only the active trigger gets tabindex="0". All others get tabindex="-1". Tab enters, lands on the active trigger, next Tab exits. Arrow keys move between triggers.

The hook handles orientation awareness (horizontal tabs ignore ArrowUp/Down, vertical menus ignore ArrowLeft/Right), loop wrapping, disabled item skipping, and Home/End keys.

The part that surprised me was bridging focus and selection. When the hook moves focus to a new tab trigger, onActiveChange fires. The callback reads document.activeElement.dataset.value and updates the selected tab — making arrow keys both move focus AND select. This is automatic activation, the WAI-ARIA default.

useRovingTabIndex(listRef, {
  orientation,
  loop: true,
  onActiveChange: () => {
    const focused = document.activeElement as HTMLElement;
    const value = focused?.dataset.value;
    if (value) onValueChange(value);
  },
});
Enter fullscreen mode Exit fullscreen mode

This works because .focus() is synchronous — document.activeElement updates immediately. But it's the kind of assumption that breaks silently if someone later makes the hook async. That's a comment in the code now.


The Compound Component Wiring

Dialog and Tabs both use the same pattern. The root is a pure context provider — no DOM output. Sub-components read from context. Object.assign enables dot notation:

export const Dialog = Object.assign(DialogRoot, {
  Trigger: DialogTrigger,
  Portal: DialogPortal,
  Overlay: DialogOverlay,
  Content: DialogContent,
  Title: DialogTitle,
  Description: DialogDescription,
  Close: DialogClose,
});
Enter fullscreen mode Exit fullscreen mode

Both components support controlled and uncontrolled usage via a useControllable hook. The mode detection checks value !== undefined. Not value !== null. Not !!value. Specifically undefined. This matters because null is a valid controlled value — the parent is explicitly saying "nothing selected right now." false is a valid controlled value — a dialog that's closed. 0 is a valid controlled value — a tabs component where the first tab is selected by index. The only signal that a component is uncontrolled is when the parent never passed value at all. In React, an omitted prop is undefined. Every other falsy value is an intentional choice by the consumer.

For Tabs, triggers and panels are linked by a string value prop — not by index. Indices break when you reorder tabs, conditionally render them, or add them dynamically. Strings survive structural changes. The ARIA cross-references (aria-controls on triggers, aria-labelledby on panels) are derived deterministically from a base id:

function getTriggerId(baseId: string, value: string) {
  return `${baseId}-trigger-${value}`;
}

function getPanelId(baseId: string, value: string) {
  return `${baseId}-panel-${value}`;
}
Enter fullscreen mode Exit fullscreen mode

No registration system where components mount and register their ids. No mount-order dependency — the trigger doesn't need to render before the panel for the ids to exist. Pure derivation from baseId + value. Both functions live in the context file and are called by Trigger (to set aria-controls={getPanelId(...)}) and Panel (to set aria-labelledby={getTriggerId(...)}).


The ARIA You Don't See

Every ARIA attribute is a real audio experience. This stopped being abstract after testing with VoiceOver.

On the trigger:

Attribute What the user hears
aria-haspopup="dialog" "dialog popup" after the button label
aria-expanded "expanded" / "collapsed"
aria-controls Used for virtual cursor navigation

On the dialog panel:

Attribute What the user hears
role="dialog" "dialog"
aria-modal="true" Constrains virtual cursor to dialog
aria-labelledby Reads the title text on open
aria-describedby Reads description after title

On tabs:

Element What the user hears
role="tablist" + role="tab" "Account, tab 1 of 3"
aria-selected "selected"
tabindex="0" on panel Tab from tablist lands on panel content

The tabindex="0" on the panel is easy to miss but important. After selecting a tab with arrow keys, pressing Tab should land on the panel content — not skip to some other focusable element elsewhere on the page. The panel itself needs to be a tab stop.

None of this is visible. All of it is load-bearing.


What Broke Along the Way

Three things broke in ways I didn't expect.

jsdom doesn't do layout. The focus trap's visibility check used el.offsetParent === null — the standard browser approach. jsdom doesn't implement offsetParent. It's always null. Every element was being filtered out as "hidden." The fix was replacing offsetParent with a getComputedStyle ancestor walk checking for display: none.

jsdom lies about tabIndex. In a real browser, a <button> without an explicit tabindex attribute has tabIndex === 0. In jsdom, it returns -1. A check like if (el.tabIndex < 0) return false silently filters out every button and input. The fix: only check el.tabIndex when el.hasAttribute('tabindex'). If the element matched the focusable selector, trust the selector.

React 18 vs 19 context syntax. The root component used <DialogContext value={context}> — React 19 syntax. React 18 requires <DialogContext.Provider value={context}>. Without .Provider, context is null and every compound component throws. A one-line fix, but Tabs used .Provider from the start — lesson learned.


What I'd Do Differently

Scroll lock. When the dialog opens, the page behind it should stop scrolling. I deferred this because the headless primitive shouldn't own the implementation — scroll lock involves document.body style manipulation that varies by browser and layout. But the primitive should at least provide a hook or callback for consumers to attach their own. That API gap is the first thing I'd add.

aria-describedby pointing to nothing. If the consumer renders Dialog.Content without Dialog.Description, aria-describedby still points to the generated id. Screen readers handle broken id references gracefully — they ignore them. But it's not clean. A v2 would conditionally add aria-describedby only when Description is mounted, which means a registration mechanism between Content and Description. I chose the simpler approach for v1.

Animation support. The compound component pattern gives consumers direct access to Overlay and Content, so they can animate with CSS transitions or any library. But there's no onExitComplete callback — no way to tell the primitive "don't unmount yet, the exit animation is still running." Solving this cleanly requires either a render-even-when-closed prop or an animation-aware state machine. Both add complexity. I'd solve it before shipping to npm.

var() chains in the token output. This carries over from the token pipeline article. When the styled layer wraps these headless primitives with token-driven CSS, the flat hex values in the generated output mean every semantic change requires a rebuild. var() chains would cascade at runtime. For a published design system, that's the right trade-off — and the first thing I'll revisit in Phase 3.


All three primitives, the hooks that power them, and 173 tests are in the flintwork repo. The next phase is a styled layer that consumes these primitives and applies token-driven CSS — connecting the design token pipeline to the components that use them.

The thing I keep coming back to: accessibility isn't a feature you add to components. It's the architecture of the component. The focus trap isn't a wrapper around the dialog — it's what makes the dialog a dialog. The roving tabindex isn't an enhancement to the tab bar — it's what makes keyboard navigation usable. When you build the behavior layer first, the accessibility comes with it. When you build the visual layer first and add accessibility later, it never quite fits.

Top comments (0)