Across web, TUI, game UI, and mobile paradigms, focus management converges on:
- Focus is always a singleton. Exactly one node receives input at any time.
- Two levels of navigation. Nearly every system distinguishes between groups (Tab) from within groups (arrows).
- Scoping enables composition. Without focus scopes, every focusable element competes in a single flat order. Scopes let subtrees manage focus independently (dialogs, menus, panels).
- Restoration is universally needed. When a focus scope closes (modal dismiss, node removal), every system needs a strategy for where focus goes. Dominant pattern: remember what was focused before the scope activated.
- Focus lifecycle is tightly coupled to component lifecycle. When a focused node unmounts, focus must move somewhere — this is a source of bugs in every framework.
Focusable Areas: Regions that can receive keyboard input — elements with non-null tabindex, natively focusable elements (buttons, inputs, links), image map shapes, scrollable regions, and document viewports.
Tabindex Processing Model:
- Null/omitted: UA determines focusability per platform conventions
- Negative integer: Programmatically focusable via
.focus()but excluded from sequential tab navigation - Zero: Focusable and sequentially navigable; position follows DOM tree order
- Positive integer: Creates explicit ordering (discouraged by spec)
Focus Navigation Scopes: Each Document, shadow host, slot element, and showing popover is a scope owner. The flattened tabindex-ordered focus navigation scope is computed by recursively inlining child scope contents after their scope owners.
Shadow DOM: Shadow hosts are scope owners. delegatesFocus: true delegates
focus to the first focusable descendant.
Focus Events: focus/blur do not bubble. focusin/focusout do bubble.
Events fire along the focus chain.
Focus Navigation Start Point (per Sarah Higley): When a focused element is removed from the DOM, browsers maintain a "sequential focus navigation starting point" at the removed element's former position. Screen readers largely ignore this mechanism.
Roving Tabindex: Manages focus in composite widgets (radio groups, tablists, toolbars, trees, grids):
- Set
tabindex="0"on active element,tabindex="-1"on siblings - On arrow key: old element →
-1, new element →0, call.focus() - Browser automatically scrolls newly focused element into view
aria-activedescendant: Keeps DOM focus on container while logically pointing to active child. Critical limitations (per Sarah Higley):
- Purely a screen reader construct; no effect on keyboard events or DOM focus
- No automatic scroll-into-view
- VoiceOver Safari macOS essentially ignores it
- Mobile screen readers bypass it entirely
- Best suited for comboboxes only
Focus traps (modals/dialogs): Intercept Tab at boundaries and wrap. Requires initial focus, wrapping Tab/Shift+Tab at boundaries, and focus restoration on close.
The most ambitious standardization attempt:
- Arrow-key navigation within a declared subtree, guaranteed tab stop, automatic last-focused memory
- Crosses shadow DOM boundaries by default;
focusgroup=noneopts out - Independence principle: Nested
focusgroupexits ancestor's group; each element belongs to exactly one focusgroup - Values: empty (linear),
grid,manual-grid,none - Modifiers:
inline/block(direction),wrap/flow(boundary),no-memory - Grid navigation:
row-wrap,col-wrap,row-flow,col-flow
Three declarative props:
contain: Prevents focus from leaving scope; Tab wraps at boundariesrestoreFocus: Storesdocument.activeElementon mount; restores on unmountautoFocus: Focuses first focusable descendant on mount
useFocusManager() hook: focusNext({wrap}), focusPrevious({wrap}),
focusFirst(), focusLast().
Implementation uses sentinel nodes as boundary markers in the DOM.
- No keyboard event interception: Watches focus behavior via
focusevents, notkeydown data-focus-lock=[group-name]for scattered focus groups/shards (critical for portals)- Core
focus-lockpackage is 1.5kb
- Tab interception only at boundaries (native browser handles mid-scope)
TreeWalker-based element discovery- Container gets
tabIndex={-1}as fallback - Focus scope stack: parent paused when child activates, resumes on child unmount
- Collecting focusable elements is O(n), measured at 850ms on Android Chrome
- Proposed ID-based focus with propagating lookups through nested scopes
- Cross-platform abstraction independent of DOM
- Opt-in:
can_focus(defaultFalse) andcan_focus_children(defaultTrue) - Focus chain: Computed ordered list of all focusable widgets; Tab/Shift+Tab traverse it
can_focus_children=False: Skips children during Tab navigation (primitive containment)- Programmatic:
focus_next(),focus_previous(),widget.focus(),set_focus() - Events:
Focus,Blur,DescendantFocus,DescendantBlur - CSS:
:focuspseudo-class,has_focus_withinproperty - Scroll:
focus(scroll_visible=True)
useFocus()hook makes component focusable; returns{isFocused}useFocusManager():focusNext(),focusPrevious(),focus(id)- Focus order follows render order
- No focus scoping — focus is global
- Terminal-level only:
FocusMsg/BlurMsgfor window focus - Component focus is entirely DIY via
focusIndexinteger - Community
bubbletea-nav: flatFocusManagerwith Tab/Shift+Tab
- No built-in focus; community crates:
rat-focus(FocusFlag + FocusBuilder),ratatui-interact(FocusManager),focusable(derive macro)
All converge on: flat ordered list of focusable widgets, Tab/Shift+Tab navigation. None have built-in focus scoping or containment. Focus ordering is explicit (manual index) or render/mount order.
Four modes per Selectable:
- Automatic: Spatial proximity-based neighbor computation
- Explicit: Manual
selectOnUp/Down/Left/Rightper element - Horizontal/Vertical: Navigation restricted to one axis
- None: Disables navigation
Directional algorithm: collect navigable items, score by distance + direction alignment, select lowest score. Two NavLayers (Main, Menu). Window-level focus with cross-window navigation support.
XYFocusKeyboardNavigation: Auto, Enabled, Disabled — creates
directional areas.
Three strategies:
- Projection: Projects focused element's edge in navigation direction
- NavigationDirectionDistance: Extends bounding rect, finds closest to navigation axis
- RectilinearDistance: Manhattan distance scoring
TabFocusNavigation: Local (subtree tab indexes), Once (group tab stop),
Cycle (wrap within container).
@FocusState: Property wrapper tracking focus (Boolean or Hashable enum).focused($state, equals:): Binds view focus to state variable.defaultFocus(): Initial focus target when scope appears.focusSection(): Groups views for directional navigation without making section focusable
| Strategy | Description | Used By |
|---|---|---|
| DOM/tree order | Source order in document/tree | HTML default, Ink, Textual |
| Explicit tab order | Numeric index overrides tree order | HTML tabindex, WinUI |
| Roving within group | Arrows within group, Tab between groups | ARIA composites, focusgroup |
| Spatial/directional | Nearest element in pressed direction | WinUI, Unity, ImGui, tvOS |
| Scope-local | Tab order computed within scope | HTML focus scopes, WinUI Local |
| Cycle/wrap | Focus wraps at boundaries | WinUI Cycle, FocusScope contain |
| Once (group stop) | Container is single Tab stop; arrows internal | WinUI Once, ARIA composites |
| Render order | Component render/mount order | Ink, Bubble Tea |
| Custom/programmatic | Application logic determines order | All frameworks |
| System | Scoping Mechanism | Containment | Restoration | Nesting |
|---|---|---|---|---|
| DOM/HTML | Focus navigation scopes | No built-in trap | No built-in | Hierarchical via shadow DOM |
Open UI focusgroup |
focusgroup attribute |
Independent groups | Last-focused memory | Nested auto-exits parent |
| React Aria | <FocusScope> |
contain prop |
restoreFocus prop |
Parent paused |
| Radix | <FocusScope> |
loop prop |
Stores activeElement |
Focus scope stack |
| react-focus-lock | data-focus-lock |
Focus observation | returnFocus prop |
Focus shards |
| WinUI/UWP | XYFocusKeyboardNavigation |
Cycle mode |
No built-in | Inheritance |
| SwiftUI | @FocusState + sections |
Sections guide direction | .defaultFocus() |
Nestable |
| Textual | can_focus_children=False |
Prevents Tab entry | No built-in | Widget tree |
| ImGui | Window flags, modals | Modal windows | No built-in | NavLayer |
- DOM:
document.activeElementfalls back to<body>; navigation start point remembered - React:
onBlurdoes not fire on parent when React unmounts focused child - React Aria:
restoreFocusstores previous element, restores on unmount - Radix: Stores
activeElementbefore activation, restores on unmount - Textual:
blur()moves focus to next available widget
- Scroll into view: Browser auto-scrolls on
.focus()and roving tabindex; NOT onaria-activedescendant - Screen reader: Focus changes trigger announcement of focused element's name and role
- Focus visible:
:focus-visibledistinguishes keyboard from mouse/touch focus
- WHATWG HTML - The tabindex attribute
- W3C APG - Keyboard Interface
- Open UI focusgroup Explainer
- React Aria FocusScope
- React Focus Management RFC
- React Issue #16009
- react-focus-lock
- Radix UI FocusScope
- Sarah Higley - aria-activedescendant
- Sarah Higley - Focus navigation start point
- Textual Input Guide
- Ink
- Bubble Tea
- ImGui Navigation
- WinUI Focus Navigation
- Unity UI Navigation
- WICG CSS Spatial Navigation
- SwiftUI Focus - WWDC23
- Cloudscape Focus Principles