Focus and Dismissal
Focus and Dismissal: anatomy, keyboard behavior, state, and theming in Askr.
Example
import { Button, Dialog, DialogContent, DialogTitle, DialogTrigger } from '@askrjs/themes/components';
<Dialog>
<DialogTrigger asChild><Button>Open settings</Button></DialogTrigger>
<DialogContent>
<DialogTitle>Project settings</DialogTitle>
<ProjectSettings />
</DialogContent>
</Dialog>Purpose
FocusScope and DismissableLayer are the two headless primitives every overlay in @askrjs/ui (Dialog, Popover, Dropdown, Menu, Tooltip, HoverCard, Menubar) is built on. FocusScope handles trapping and restoring keyboard focus; DismissableLayer handles closing on outside interaction. They're exported and usable directly if you're building a custom overlay that isn't already covered by the shipped components.
Install and import
Both come from the root package: import { FocusScope, DismissableLayer } from '@askrjs/ui'. There are no themed counterparts in @askrjs/themes — these are pure interaction primitives with no visual output of their own, so nothing needs to be imported from the themes package for them to work.
Live examples
Wrapping a custom panel in <FocusScope trapped loop restoreFocus> keeps Tab cycling inside the panel and returns focus to the trigger element on unmount. Wrapping the same panel in <DismissableLayer onDismiss={() => setOpen(false)}> closes it on Escape or a pointerdown outside its bounds, calling onEscapeKeyDown, onPointerDownOutside, or onInteractOutside individually if you need finer-grained control before the shared onDismiss fires.
Anatomy
Both are single-element primitives with asChild support (FocusScopeAsChildProps, DismissableLayerAsChildProps), so you can attach their behavior to an existing element rather than have them render an extra wrapper <div>. They compose — a Dialog's content is typically inside both a FocusScope and a DismissableLayer at once, which is how it traps Tab while also closing on outside click.
State model
Neither primitive owns open/closed state itself — that stays with the consumer (or the overlay component wrapping them, like DialogContent). FocusScope's props (trapped, loop, restoreFocus) and DismissableLayer's callbacks are configuration, not state; you mount and unmount them in response to state that lives elsewhere.
Keyboard and accessibility
FocusScope's contract lists three boolean features — trapped, loop, and restoreFocus — all enabled by their corresponding props. DismissableLayer's contract fixes its INTERACTION_POLICY as 'background-dismissable' and its DISMISS_EVENTS as escape-key and outside-pointer; those two events are the entire dismissal surface, there's no configurable third trigger.
Styling and tokens
Both are behavior-only and render no visible chrome, so there's nothing for @askrjs/themes to style directly — any visual treatment (backdrop, panel border, shadow) belongs to whatever element you nest inside them, typically a themed Dialog or Popover content component.
API
FocusScopeOwnProps: trapped?, loop?, restoreFocus?, plus id? and tabIndex?. DismissableLayerOwnProps: disabled?, disableOutsidePointerEvents?, onEscapeKeyDown?(event: KeyboardEvent), onPointerDownOutside?(event: PointerEvent), onInteractOutside?(event: Event), and onDismiss?(). Both accept id and forward a ref to the underlying HTMLDivElement when not used with asChild.
Edge cases
Setting disableOutsidePointerEvents on DismissableLayer blocks pointer events from reaching elements behind the layer entirely, not just from triggering dismissal — useful for modal-style overlays but wrong for a non-modal popover that should still let the page behind it scroll or hover. Nesting multiple DismissableLayers (e.g. a Select inside a Dialog) means an outside click on the outer layer's trigger can trigger both layers' dismissal logic if you don't stop propagation in the inner one's onInteractOutside.
Related pages
See Dialog and Popover for complete overlay composition, Interaction Policies for keyboard and pointer behavior, and ARIA and Ref Utilities for asChild and ref ownership.