Askr
UI & Components

Composition and Accessibility

Use composition and accessibility with the current published Askr packages.

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

How to use composition and accessibility

Composition and Accessibility 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 composition and accessibility 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, ThemeScope, ThemeToggle } from '@askrjs/themes/components';

<ThemeScope defaultTheme="system" storageKey="app-theme">
  <Button>Save project</Button>
  <ThemeToggle aria-label="Change color theme" />
</ThemeScope>

Purpose

Askr's headless primitives are built to be composed rather than configured — instead of a dozen props controlling every visual detail, you assemble small, focused pieces like FocusScope and DismissableLayer around your own markup. Accessibility isn't bolted on afterward; each primitive ships a documented contract (role, ARIA attributes, data hooks) that you can inspect directly in its `*_A11Y_CONTRACT` export.

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's behavior is described by `FOCUS_SCOPE_A11Y_CONTRACT`, which declares `trapped`, `loop`, and `restoreFocus` as its supported features — trapping tab order inside the scope, wrapping focus at the edges, and returning focus to wherever it came from on unmount. VisuallyHidden's contract declares its strategy as `visually-hidden-but-screen-reader-visible` rendered on a `span`, which is exactly what you want for screen-reader-only labels that shouldn't take up layout space.

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.

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.