Composition and Accessibility
Composition and Accessibility: anatomy, keyboard behavior, state, and theming in Askr.
Example
import { Button, ThemeScope, ThemeToggle } from '@askrjs/themes/components';
<ThemeScope defaultTheme="system" storageKey="app-theme">
<Button>Save project</Button>
<ThemeToggle aria-label="Change color theme" />
</ThemeScope>Purpose
Compose the public primitives around explicit markup. Their supported contracts are component props, observable roles and ARIA relationships, keyboard behavior and focus ownership. Test those outcomes in your own composition.
Install and import
These primitives live in @askrjs/ui: import { FocusScope, DismissableLayer, VisuallyHidden } from '@askrjs/ui', or import them individually from @askrjs/ui/focus-scope, @askrjs/ui/dismissable-layer, and so on if you want narrower imports. They're building blocks other components — Dialog, Popover, Dropdown — already use internally, so you'll often meet them indirectly before you ever import one yourself.
Live examples
A typical modal composes FocusScope and DismissableLayer directly around its content: FocusScope traps tab order inside the dialog and restores focus to the trigger on close, while DismissableLayer listens for the escape key and outside pointer events to close it. Dialog itself is built this way internally, which is why wrapping your own overlay content in the same two primitives gets you the same behavior without writing a single keydown handler.
Anatomy
Every composable primitive accepts an asChild prop; when set to true, the primitive expects a single children element and merges its own props and behavior directly onto that element instead of rendering a wrapping div or span. That's the mechanism that lets you attach dismiss-on-escape behavior to, say, a <section> you already control, rather than accepting an extra DOM node you didn't ask for.
State model
These primitives don't hold shared or global state — each one owns just the flags relevant to its own job. FocusScope tracks trapped, loop, and restoreFocus as independent booleans; DismissableLayer tracks nothing persistent at all and just fires callbacks (onEscapeKeyDown, onDismiss) as events happen. There's no central registry to keep in sync, which is part of why they compose cleanly.
Keyboard and accessibility
FocusScope exposes trapped, loop and restoreFocus behavior. Verify Tab containment, edge wrapping and focus restoration on teardown through the rendered tree. VisuallyHidden provides labels that remain available to assistive technology.
Styling and tokens
These primitives emit the same data-slot, data-state, and data-disabled hooks used across the rest of the component catalog, so a themed layer can style them consistently without special-casing. Most of them render no visible chrome of their own — FocusScope and DismissableLayer are behavioral wrappers, not visual ones — so there's rarely CSS to write directly against them.
API
FocusScope's real props are trapped, loop, restoreFocus, plus standard id and tabIndex. DismissableLayer's real props are disabled, disableOutsidePointerEvents, onEscapeKeyDown, onPointerDownOutside, onInteractOutside, and onDismiss. VisuallyHidden takes only children beyond the asChild toggle — there's no visual configuration to expose because it's meant to be invisible by design.
Edge cases
When you nest dismissable layers — a dropdown inside a dialog, for instance — outside-click and escape handling needs to resolve from the innermost layer outward, so test that ordering explicitly rather than assuming it. asChild also requires exactly one child element; passing a fragment or multiple children where a single element is expected won't type-check, so keep the child a single real node.
Related pages
See ARIA and Ref Utilities for the lower-level attribute and ref helpers these primitives build on, and Focus and Dismissal for a deeper look at how FocusScope and DismissableLayer are used across overlay components specifically.