Companion to accessibility.md. Covers focus traps, initial focus, and return focus for overlays, dialogs, and large banners.
This spec applies to every component that overlays or interrupts the main content flow:
| Component | Traps focus? | Returns focus? | Initial focus target |
|---|---|---|---|
| Modal / confirmation dialog | Yes | Yes | First focusable element or primary action |
| Full-screen overlay | Yes | Yes | Close / dismiss button |
| Danger banner (persistent, page-blocking) | No — but manages focus on appear | Yes | Dismiss button |
| Toast (ephemeral) | No | No | N/A (announced via aria-live) |
| Contextual inline banner | No | No | N/A (in-flow content) |
| Term | Meaning |
|---|---|
| Focus trap | Keyboard focus is constrained to the focusable elements inside a container; Tab / Shift+Tab cycle within it. |
| Initial focus | The element that receives focus() when an overlay first renders. |
| Return focus | The element that receives focus() after the overlay is dismissed. |
| Trigger element | The button or link that caused the overlay to open. |
- Always for modal dialogs and confirmation prompts.
- Always for full-screen overlays that block interaction with the page behind them.
- Never for toasts, inline banners, or non-modal panels.
┌──────────────── Overlay ────────────────┐
│ │
│ [Close ✕] ← last focusable │
│ │
│ <content> │
│ │
│ [Cancel] [Confirm] ← bottom actions │
│ │
└─────────────────────────────────────────┘
Tab from [Confirm] → wraps to [Close ✕]
Shift+Tab from [Close ✕] → wraps to [Confirm]
Implementation guidance:
-
On mount, query all focusable elements inside the overlay container:
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' const focusables = overlay.querySelectorAll(FOCUSABLE) const first = focusables[0] const last = focusables[focusables.length - 1]
-
Add a
keydownlistener on the overlay root:function handleKeyDown(e: KeyboardEvent) { if (e.key === 'Tab') { if (e.shiftKey && document.activeElement === first) { e.preventDefault() last.focus() } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault() first.focus() } } if (e.key === 'Escape') { closeOverlay() } }
-
Clean up the listener on unmount.
-
Prefer a custom React hook (
useFocusTrap) so every overlay shares the same logic. Suggested signature:function useFocusTrap(containerRef: RefObject<HTMLElement>, isActive: boolean): void
All trapped overlays must close on Escape. This is both a WCAG requirement and a user expectation.
| Overlay type | Initial focus target | Rationale |
|---|---|---|
| Confirmation dialog (destructive action) | Cancel button | Prevent accidental destructive confirmation |
| Confirmation dialog (non-destructive) | Primary action button | Speed up the happy path |
| Form dialog / modal with inputs | First input field | Reduce keystrokes to begin data entry |
| Informational modal (no form) | Close button | Fastest path to dismiss |
| Danger banner (persistent) | Dismiss button | Draw attention to required acknowledgment |
// Inside the overlay component, after mount:
useEffect(() => {
if (isOpen && initialFocusRef.current) {
// Small delay ensures the DOM has painted and transition is underway
requestAnimationFrame(() => {
initialFocusRef.current?.focus()
})
}
}, [isOpen])- Use a dedicated
initialFocusRefrather than always focusing the first element. This allows per-dialog control. - If no explicit ref is set, fall back to the first focusable element inside the trap.
- Never auto-focus an element that would trigger a destructive action on Enter.
When any overlay closes, focus must return to the trigger element — the button or control the user activated to open the overlay.
// Store trigger on open
const triggerRef = useRef<HTMLElement | null>(null)
function openOverlay() {
triggerRef.current = document.activeElement as HTMLElement
setIsOpen(true)
}
function closeOverlay() {
setIsOpen(false)
// Restore after React re-renders
requestAnimationFrame(() => {
triggerRef.current?.focus()
})
}| Scenario | Behavior |
|---|---|
| Trigger element is unmounted while overlay is open | Focus the nearest logical ancestor or <main> |
| Overlay dismissed by route change | No explicit return focus; new page follows normal focus flow (skip-link → content) |
| Overlay dismissed by Escape | Same as button dismiss — return to trigger |
| Nested overlays (overlay opens overlay) | Each overlay stores its own trigger; closing inner returns to outer's last active element |
ConfirmDialog is a reusable design-system primitive for any destructive or irreversible action. It is not limited to bond withdrawals.
| Prop | Type | Default | Effect |
|---|---|---|---|
breakdown |
ConfirmDialogPenaltyBreakdown |
— | Renders the financial <dl> (bond withdrawal use case) |
description |
React.ReactNode |
— | Renders a generic content block when breakdown is omitted |
confirmPhrase |
string |
'CONFIRM' |
Word the user must type exactly to unlock the confirm button |
confirmHint |
string |
wallet/funds hint | Small-print below the type-to-confirm input |
confirmLabel |
string |
'Withdraw bond' |
Label on the confirm button |
breakdown takes priority — if both breakdown and description are supplied, the breakdown is shown.
<ConfirmDialog
open={isOpen}
title="Clear Draft"
description="All unsaved changes will be permanently deleted."
confirmPhrase="DELETE"
confirmHint="This action cannot be undone."
confirmLabel="Clear draft"
onConfirm={handleClear}
onCancel={() => setIsOpen(false)}
returnFocusRef={triggerRef}
/><ConfirmDialog
open
title="Confirm bond withdrawal"
subtitle={`Withdrawing bond #${id}.`}
breakdown={withdrawBreakdown}
onConfirm={confirmWithdraw}
onCancel={cancelWithdraw}
returnFocusRef={withdrawTriggerRef}
/>Confirmation dialogs that gate destructive actions behind a typed phrase implement the following focus and announcement behavior:
| State | Focus target | aria-live announcement |
|---|---|---|
| Dialog opens | Cancel button (per §4) | Dialog title + subtitle |
| User types exact phrase → button enabled | Confirm button | "Withdrawal enabled. Type CONFIRM to confirm." |
| User deletes/changes phrase → button disabled | Cancel button | "Withdrawal disabled. Type CONFIRM to enable." |
| Dialog closes (any path) | Trigger element (per §5) | — |
Implementation notes:
- The dialog uses an
aria-live="assertive"region (reused from the title/subtitle announcement) to communicate gating state changes. - Focus movement is performed via
requestAnimationFrameafter the state update to ensure the target element is interactive. - The initial focus-on-Cancel pattern is preserved on open; focus only shifts to Confirm when the user explicitly enables the action.
- Phrase comparison is case-sensitive (a separate concern if case-folding is ever needed).
Credence does not yet have a modal component. When one is built:
Attributes:
role="dialog"
aria-modal="true"
aria-labelledby="<heading-id>"
aria-describedby="<body-id>" (optional)
Focus: trap + initial focus + return focus (per §3–5)
Backdrop: click-to-close; inert background (aria-hidden="true" on #root or use <dialog>)
Recommended: Use the native <dialog> element with showModal(). It provides:
- Built-in focus trap
- Built-in Escape handling
- Built-in backdrop
- Inert background for free
If <dialog> is insufficient (e.g., animation needs), replicate its focus behavior with useFocusTrap.
Current behavior: Banners render inline with role="alert" or role="status". No focus management exists.
Proposed change for persistent danger banners:
- When a danger banner appears and is
dismissible:- Move focus to the dismiss button on mount.
- On dismiss, return focus to the element that was focused before the banner appeared.
- Non-dismissible danger banners: no focus change (the
role="alert"announcement is sufficient). info/success/warningbanners: no focus change (non-blocking, announced by live region).
// Banner.tsx — proposed focus behavior for danger + dismissible
const dismissRef = useRef<HTMLButtonElement>(null)
const previousFocus = useRef<HTMLElement | null>(null)
useEffect(() => {
if (severity === 'danger' && dismissible) {
previousFocus.current = document.activeElement as HTMLElement
requestAnimationFrame(() => dismissRef.current?.focus())
}
}, [severity, dismissible])
function handleDismiss() {
onDismiss?.()
requestAnimationFrame(() => previousFocus.current?.focus())
}Toasts are ephemeral and non-modal. They must not steal focus.
- Announced via the existing
aria-live="polite"container. - Dismiss buttons are focusable but focus is never moved to them programmatically.
- No changes needed to focus management.
If a slide-out drawer or full-screen overlay is added:
- Apply the same focus trap as dialogs (§3).
- Initial focus → close button.
- Background must become inert (
aria-hidden="true"orinertattribute on the root). - Return focus to trigger on close.
When a focus-trapping overlay is active, the rest of the page must be non-interactive.
Recommended approach (modern browsers):
// When overlay opens
document.getElementById('app-root')?.setAttribute('inert', '')
// When overlay closes
document.getElementById('app-root')?.removeAttribute('inert')Fallback (wider support):
document.getElementById('app-root')?.setAttribute('aria-hidden', 'true')The
inertattribute is supported in all modern browsers (Chrome 102+, Firefox 112+, Safari 15.5+). Given Credence's target audience (crypto-native users on modern browsers),inertis the preferred approach.
A shared useFocusTrap hook keeps behavior consistent across all overlay components.
interface UseFocusTrapOptions {
/** Ref to the container element that traps focus */
containerRef: RefObject<HTMLElement>
/** Whether the trap is currently active */
isActive: boolean
/** Optional ref to the element that should receive initial focus */
initialFocusRef?: RefObject<HTMLElement>
/** Whether to return focus to the trigger on deactivation (default: true) */
returnFocusOnDeactivate?: boolean
}
function useFocusTrap(options: UseFocusTrapOptions): voidBehavior summary:
- When
isActivebecomestrue: storedocument.activeElement, move focus toinitialFocusRef(or first focusable), attach Tab/Escape handlers. - While active: cycle Tab within container, close on Escape.
- When
isActivebecomesfalse: remove handlers, restore focus to stored element (ifreturnFocusOnDeactivate).
- Tab through every overlay — focus never escapes to the page behind
- Shift+Tab from the first focusable wraps to the last
- Tab from the last focusable wraps to the first
- Escape closes every trapped overlay
- After close, focus returns to the element that opened the overlay
- Danger banner (dismissible) focuses the dismiss button on appear
- After dismissing a danger banner, focus returns to the previously focused element
- Toasts do not steal focus
- Screen reader announces overlay title on open (
aria-labelledby) - Background is inert when overlay is visible (no Tab leakage)
- Unit test:
useFocusTraptraps and cycles focus within container - Unit test:
useFocusTraprestores focus on deactivation - Unit test: Escape key triggers close callback
- Integration test: Modal dialog focus lifecycle (open → trap → close → return)
- Integration test: Danger banner focus lifecycle
| Criterion | Requirement | How this spec satisfies it |
|---|---|---|
| 2.1.1 Keyboard | All functionality operable via keyboard | Focus trap ensures keyboard users stay within active overlay |
| 2.1.2 No Keyboard Trap | Users can leave any component via keyboard | Escape key always closes the overlay and releases the trap |
| 2.4.3 Focus Order | Focus order preserves meaning and operability | Initial focus is placed on the most logical element per overlay type |
| 2.4.7 Focus Visible | Focus indicator visible | Covered by existing :focus-visible styles in accessibility.md |
| 4.1.2 Name, Role, Value | Components have correct roles | role="dialog", aria-modal, aria-labelledby specified per component |
- Library vs. custom hook? Should Credence adopt a library like
focus-trap-reactor maintain a customuseFocusTrap? Custom keeps the bundle small; library handles more edge cases. <dialog>adoption? Native<dialog>simplifies implementation significantly. Confirm browser-support policy.- Animation transitions: If overlays have enter/exit CSS transitions, focus movement should wait for the entry animation to complete (
onTransitionEndorrequestAnimationFrame). - Nested overlays: Are nested overlays a pattern Credence will use? If not, the simpler single-layer trap is sufficient.
This is a design-only spec. Implementation will follow in a separate dev issue.