Askr
UI & Components

Focus and Dismissal

Use focus and dismissal with the current published Askr packages.

  • @askrjs/ui0.0.13
  • @askrjs/themes0.0.13

How to use focus and dismissal

Focus and Dismissal should use the standard themed surface unless the application is deliberately implementing its own design system on top of @askrjs/ui.

  1. Import the themed component from @askrjs/themes/components; use @askrjs/ui directly only when building a custom visual system.
  2. Keep focus and dismissal labels, state, and application actions visible at the call site instead of styling internal DOM nodes.
  3. Verify keyboard behavior, focus movement or return, disabled state, and both standard themes.
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`.

See the Dialog, Popover, Dropdown, and Tooltip component pages for how these primitives get composed into full overlays. See Interaction Policies for how to read the `FOCUS_SCOPE_A11Y_CONTRACT` and `DISMISSABLE_LAYER_A11Y_CONTRACT` constants.