A dialog is a focus contract
The reusable part of a modal was not its panel or backdrop but the temporary rules it established for naming, focus, dismissal, and return.
My first reusable dialog was a centered rectangle with an argument about z-index.
It rendered above the page, dimmed the background, accepted a title, and exposed an onClose callback. In a catalogue, it looked finished. In M31V, it opened a delete confirmation and immediately exposed how little the rectangle knew.
Keyboard focus stayed on the button behind the backdrop. Pressing Tab moved through the obscured page. A screen reader encountered the dialog content at its ordinary document position without a reliable announcement that context had changed. Closing the panel sometimes returned focus to the document body because the opening button had been removed during a list update.
The visible layer was modal. The interaction was not.
J05N's dialog work began when I stopped treating modality as a style and wrote it as a temporary focus contract. While the dialog is active, the application must identify a new context, move focus into it deliberately, keep keyboard movement coherent, prevent interaction with the obscured page, provide an understandable dismissal path, and place focus somewhere logical when the context ends.
Every part depends on the task. The reusable component could enforce the mechanics, but it could not choose them from appearance alone.
A backdrop has no semantics
The early implementation added a fixed element across the viewport, then placed a panel above it:
page
backdrop
panelPointer users could not click the covered page. That was the entirety of its modality.
Keyboard focus does not participate in paint order. It followed the document's focusable elements, including links and controls behind the panel. Assistive technology navigation could encounter background content because visual opacity does not remove anything from the accessibility tree. Programmatic scripts could still focus obscured controls.
This produced a split interface. One user perceived an interruption requiring a decision. Another continued through two contexts whose visual relationship was hidden.
The backdrop remained useful as visual communication. It indicated that the previous page was temporarily unavailable and preserved spatial context. It could also provide a pointer dismissal surface when the task allowed that behavior.
It could not establish the contract.
We separated the layers conceptually:
visual layer: panel, backdrop, stacking, viewport placement
semantic layer: dialog identity, name, description
focus layer: entry, containment, return
policy layer: dismissal and destructive-action rulesThe component needed all four, and no CSS property could infer the last three.
The dialog needed a name before it needed focus
Moving focus into an unnamed region gives the user a location without an explanation.
J05N required every dialog to have an accessible name. The preferred path referenced a visible heading with aria-labelledby on the element carrying role="dialog". An explicit label was available for rare cases without a visible title, but the catalogue warned that a visual dialog with no heading often had an information-design problem.
<div role="dialog" aria-labelledby="delete-title">
<h2 id="delete-title">Delete this draft?</h2>
<!-- description and actions -->
</div>A concise description could be connected with aria-describedby when hearing it as one announcement helped. Longer or structured content did not become one enormous description. Lists, tables, and multiple paragraphs were easier to navigate as content after focus entered at an appropriate place.
The name described the context, not only the action button. “Delete this draft?” was more useful than “Confirmation.” “Edit notification rules” was more useful than “Dialog.”
The component generated stable relationships but did not invent the language. It produced a development warning when neither a visible title reference nor explicit label existed. A runtime fallback such as “Dialog” would make the warning disappear without making the experience meaningful.
Naming became the first required prop because it made the temporary context announceable before any interaction began.
Initial focus was a design decision
The common rule “focus the first control” is attractive because it is easy to implement. It is not always correct.
Consider three dialogs:
- A short confirmation with Cancel and Delete.
- A long explanation followed by one Continue button.
- A form for editing a notification rule.
In the destructive confirmation, initial focus on Cancel can reduce accidental activation and makes the safe option immediately available. In the long explanation, focusing the button may scroll the heading out of view and cause the content to be announced without structure. Focusing a static heading with tabindex="-1" can establish context while leaving normal Tab movement to the first control. In the form, focusing the first meaningful empty field may support the task.
J05N represented these as strategies rather than guessing from child order:
initialFocus: safeAction
initialFocus: heading
initialFocus: firstField
initialFocus: explicitElementThe default was a focusable element inside the dialog chosen from its known structure. Destructive actions were never the silent default. The implementation waited until the dialog content was mounted, then verified that the target remained connected and focusable.
If the requested target could not receive focus, the dialog container or heading acted as a fallback. Failure to find a focus target did not send focus back into the page while the modal remained visible.
Initial focus expressed how the task should begin, which made it part of product design rather than library housekeeping.
Containment followed focus, not a list captured once
The first focus trap queried all focusable descendants when the dialog opened and wrapped Tab from the last to the first. It worked until the dialog changed.
A validation error added a linked help action. A pending request disabled a button. An expandable section introduced new controls. The captured list no longer matched the rendered interface, so Tab could skip controls or become stuck on an element that could not receive focus.
We recalculated eligible tab stops for each Tab boundary rather than treating the opening DOM as permanent. The query considered ordinary links, buttons, inputs, selects, textareas, and elements intentionally placed in the tab order while excluding disabled, hidden, and detached nodes.
Positive tabindex values were not supported. They create a separate ordering system that becomes difficult to reconcile with DOM order and dynamic content. The dialog contract assumed source order described the meaningful sequence.
The keyboard behavior was:
Tab on final eligible control -> first eligible control
Shift+Tab on first eligible control -> final eligible control
focus somehow moves outside -> restore to most recent valid point insideThe last rule handled scripts and dynamic updates, not only key presses. A global focus listener observed while the topmost modal was active. It avoided fighting focus intentionally moving to browser chrome or a newly opened nested dialog.
Containment meant maintaining one coherent keyboard context, not intercepting every Tab key blindly.
The page behind the dialog had to become unavailable
Focus wrapping alone did not prevent a screen reader or script from reaching the background. In 2017, native inert support was not a dependable cross-browser foundation, and the newer aria-modal state was still too uneven across assistive-technology combinations to carry the whole implementation.
J05N used a conservative legacy technique. The dialog lived in a dedicated top-level portal container outside the application root. While a modal was active, sibling application regions received aria-hidden="true", and pointer interaction was blocked by the overlay. The implementation stored previous attribute values and restored them precisely on close.
The structure mattered:
<body>
<div id="application"><!-- temporarily hidden from AT --></div>
<div id="dialog-root"><!-- active dialog, never inside hidden tree --></div>
</body>Placing the dialog inside an ancestor marked aria-hidden would hide the dialog itself. A portal was therefore not only a solution to stacking contexts; it established a safe accessibility-tree boundary.
We still applied role="dialog" and the naming relationships. Where aria-modal="true" was tested and appropriate, it could communicate intent, but the interaction had to be genuinely modal regardless of attribute support.
Marking something modal while allowing keyboard or pointer interaction outside it is worse than an incomplete hint. It tells assistive technology that content is unavailable while application behavior contradicts the claim.
The semantic declaration followed implemented behavior, not aspiration.
Escape was policy, not an unconditional convenience
Most modal dialogs should close on Escape. That predictable exit is valuable. The first component therefore called onClose for every Escape press and every backdrop click.
M31V exposed the flaw when a dialog contained an unsaved text edit. Escape discarded the draft immediately. Another dialog started a publish operation that could not be canceled merely by removing the panel. Closing the view and canceling the effect were different actions.
J05N defined dismissal reasons:
escape
backdrop
closeButton
cancelAction
completedAction
routeChangeThe application supplied a dismissal policy. The dialog requested closure with a reason; it did not assume the request was accepted. A dirty form could replace Escape with a nested discard confirmation or keep the current dialog open and explain why. A pending non-cancelable operation could allow the panel to close only if its outcome remained available elsewhere.
This was not permission to disable every exit. A modal without a reachable close or cancel path can trap a user. When dismissal was restricted, the task needed a visible and keyboard-accessible way forward, and the restriction needed a concrete domain reason.
Backdrop clicks were especially contextual. An incidental help dialog might treat them like cancel. A destructive confirmation should not interpret a stray pointer event as agreement or silent discard.
The reusable contract standardized the vocabulary. Domain code retained authority over whether the context could end.
Return focus completed the interaction
Closing the first dialog removed its DOM and allowed the browser to place focus on the body. Visually, the user returned to the page. Keyboard position did not.
J05N captured the invoking element before opening, but returning focus was not always as simple as calling .focus() on it.
The invoker could disappear. Deleting a row removed its Delete button. A route change replaced the whole view. A list refresh re-rendered an equivalent control as a different node. Returning to an element hidden by a newly opened dialog would violate the new context.
The application could provide a return strategy:
return to invoking element if still valid
otherwise focus the next logical row
otherwise focus the collection heading
otherwise focus the page's main headingFor an Add Item dialog, successful completion could focus the newly inserted item rather than the button that opened the dialog. For a canceled edit, the invoker remained appropriate. The difference followed workflow, not component preference.
Focus return happened after the close update committed and the destination existed. It did not rely on a timeout chosen by feel. The dialog manager queued the return against the completed state transition.
An interaction that opens a new context owns the responsibility for ending that context intelligibly. Focus return was not cleanup; it was the final step of the task.
Nested dialogs required a stack
I wanted to forbid nested dialogs because they are difficult to explain and often indicate an overloaded workflow. One legitimate case remained: an editing dialog could request confirmation before discarding unsaved changes.
Without a stack, opening the confirmation caused both dialogs to listen for Escape and focus events. One key press could close both. The lower dialog's focus trap could pull focus away from the upper one. Closing the upper dialog returned focus to a button that was no longer the correct point inside the lower context.
J05N introduced a dialog manager with explicit layers:
dialog stack
layer 1: edit rule, suspended
layer 2: discard changes, activeOnly the top layer handled keyboard containment and dismissal. Lower layers remained visible where appropriate but unavailable for interaction. Closing the confirmation restored focus to the control inside the edit dialog that had initiated it, then reactivated the lower contract.
The manager also assigned stacking order and background accessibility state. The application page was hidden once at the base; a suspended lower dialog was hidden from the active accessibility context while the upper one operated.
Nested modality stayed rare and documented. The stack made the rare case deterministic instead of allowing independent components to compete for global focus.
Rendering dialogs near the document root avoided clipping and hidden stacking contexts. It also separated the dialog DOM from the component that requested it.
React's event system preserved useful logical propagation through portals only in later versions; in our 2017 React 15 implementation, the dialog manager could not assume every event behaved as though the DOM remained inside the caller. We passed explicit action callbacks and context rather than relying on incidental bubbling across the boundary.
The portal root also needed to exist during server rendering. The initial page did not render an open modal for our main flows, but the structure had to match when the browser took over. A route that truly required a dialog on first response needed an accessible non-script baseline or a carefully matched portal representation.
Ownership stayed with the application component that controlled open. The manager coordinated global effects—focus, page availability, scroll behavior, and stack—but did not become a hidden store of business state.
This split prevented an imperative global showDialog() API from accumulating arbitrary payloads and making route cleanup unknowable. A declarative owner could remove the dialog when its state changed; the manager supplied the cross-document mechanics that no local subtree could implement alone.
Preventing pointer interaction with the page was not sufficient if the page scrolled behind a fixed dialog. When the modal closed, the user could return to a different place.
The first scroll lock set overflow: hidden on the body. On desktop browsers, the disappearing scrollbar changed the viewport width and shifted the layout. On mobile Safari, body scrolling and fixed positioning behaved differently enough that the rule was unreliable.
J05N treated scroll state as a resource to save and restore. The implementation recorded the page position, applied the narrowest platform-specific lock, compensated for scrollbar space where necessary, and restored the exact position after close. Nested dialogs incremented a lock count rather than saving a new baseline.
We tested at zoomed text sizes and with dialog content taller than the viewport. The dialog surface needed its own scrolling region without hiding the title and available close path. Initial focus on a control near the bottom could not scroll the heading permanently out of sight before the user understood the context.
This work belonged to the visual and focus contract together. A dialog that traps keyboard focus inside an unscrollable region is not usable merely because every element exists.
The layout adapted to available inline and block size instead of assuming a centered fixed-height card.
The M31V delete dialog originally accepted title, message, and confirmLabel. That seemed generic until I examined the actual states.
Deleting an unpublished draft was reversible for a short period. Removing a published document had routing and link consequences. Discarding local unsaved changes was not a server deletion at all. A generic “Are you sure?” component could make all three look equally consequential while hiding the information needed to decide.
J05N provided a dialog shell and an alert-dialog variant for brief interrupting decisions. The application provided:
- The object and consequence in plain language.
- Whether the result was reversible.
- The least destructive action.
- Any prerequisite such as typing a name.
- Operation state and outcome evidence.
The destructive action never gained focus by being first in DOM order. Buttons followed a consistent visual and source order, with the safe path easy to reach. Color reinforced meaning but did not carry it alone.
When the operation began, the dialog did not close optimistically if the outcome could be unknown. It showed the operation receipt or moved to a durable status location. Modal focus behavior could not compensate for ambiguous effect semantics.
The shared contract made the decision accessible. It did not turn every domain consequence into the same confirmation sentence.
J05N added tests for the invariants it could observe reliably:
- The dialog had a role and accessible name relationship.
- Opening moved focus inside.
- Tab and Shift+Tab remained within the active layer.
- Escape emitted the correct dismissal reason under policy.
- Background regions gained and later restored accessibility state.
- Closing returned focus according to strategy.
- Nested dialogs suspended the lower layer.
- Removed focus targets fell back without throwing.
Browser tests were necessary because a shallow component render could not prove focus movement. We exercised actual Tab sequences and dynamic content changes.
Manual scenarios remained part of the contract. I navigated with only a keyboard, listened with a screen reader, increased zoom, tested long content, and opened the dialog after the initiating element was near the viewport edge. I checked whether the announcement made sense, not merely whether an attribute existed.
Automated accessibility checks found missing labels and some structural mistakes. They could not decide whether initial focus supported the task, whether a description was intelligible as one announcement, or whether focus return matched the new workflow state.
The catalogue documented those scenarios so reuse included a test method rather than a promise that the component made accessibility automatic.
The API named the contract
The resulting component API became more explicit and, in useful ways, less convenient:
open
labelledBy or label
describedBy when appropriate
initialFocus strategy
returnFocus strategy
dismissal policy
onDismissRequest(reason)
childrenBackdrop, panel width, and transition remained styling concerns. Destructive confirmation was a composition with its own content rules. Application state controlled whether the dialog existed.
Development warnings covered missing names, no viable focus target, an aria-hidden ancestor, and a modal marked active while background pointer interaction remained possible. The manager retained a stack receipt useful for diagnosing which layer owned focus.
The API could not guarantee a good dialog. It made the decisions difficult to omit accidentally.
That was the standard I began to use for J05N components. Shared code should encode the parts of a pattern that are stable, consequential, and easy for every local implementation to get subtly wrong. It should expose domain choices rather than bury them under defaults.
The panel became the least interesting part
By the end of the work, the dialog still rendered a rectangle over a backdrop. Its CSS was shorter than the focus manager and less important than the documented behaviors.
The reusable value was a transition between contexts:
remember current place
-> identify and announce the new context
-> move focus to an intentional start
-> keep interaction inside the active context
-> negotiate dismissal
-> restore a logical place in the resulting interfaceCalling that sequence a focus trap understates it. Trapping describes only the middle and makes the behavior sound adversarial. A good modal temporarily narrows the interface because the task requires a decision, then returns the user without losing their place.
The original component had treated accessibility as attributes added to a finished surface. J05N reversed the order. Naming, focus, containment, dismissal, and return defined the component. Styling expressed that contract visually.
A dialog is not the panel that appears. It is the promise that, for a short time, one context will become the whole interaction—and that the application will guide the user into it and back out again without making them reconstruct where they were.