Askr documentation
UI & Components

ARIA and Ref Utilities

ARIA and Ref Utilities: anatomy, keyboard behavior, state, and theming in Askr.

Example

import { Button, Input } from '@askrjs/themes/components';
import { SearchIcon } from '@askrjs/lucide';

<>
  <label htmlFor="project-search">Search projects</label>
  <Input id="project-search" name="query" />
  <Button aria-label="Run search"><SearchIcon aria-hidden="true" /></Button>
</>

Purpose

Two patterns repeat across almost every component in @askrjs/ui: the `asChild` prop for rendering onto a caller-supplied element instead of the component's default tag, and a shared `Ref<T>` type for forwarding refs through that polymorphism. This page covers both, plus VisuallyHidden, the one component whose entire purpose is an accessibility utility rather than an interactive widget.

Install and import

`asChild` and `ref` aren't separately imported — they're just props defined on every component's prop union, e.g. `ButtonProps = ButtonNativeProps | ButtonAsChildProps`. `VisuallyHidden` is a normal component import: `import { VisuallyHidden } from '@askrjs/ui'`.

Live examples

`<Button asChild><a href="/docs">Docs</a></Button>` renders an anchor with button press semantics instead of a nested `<button><a>...</a></button>`, which keeps the DOM valid and avoids nested interactive elements. `<VisuallyHidden>Loading results</VisuallyHidden>` renders text that's readable by screen readers but not visible on screen, commonly paired with a spinner or icon-only control that needs an accessible name.

Anatomy

Every polymorphic component ships two prop types — a native variant (e.g. `ButtonNativeProps`, with `asChild?: false` and a native `type` attribute) and an as-child variant (`ButtonAsChildProps`, with `asChild: true`, a required single-element `children`, and `type?: never`) — combined into one discriminated union. `VisuallyHidden` follows the exact same split: `VisuallyHiddenSpanProps` for the default `<span>` and `VisuallyHiddenAsChildProps` for rendering onto something else.

State model

Neither `asChild` nor ref forwarding carries state — they're purely about which DOM element a component's behavior attaches to. State-bearing props like `checked` or `value` work identically regardless of which branch of the union you're in.

Keyboard and accessibility

When `asChild` renders onto a non-natively-interactive element (a `<div>` or `<a>` standing in for a button), the component compensates by adding `aria-disabled` and `tabindex="-1"` in place of the native `disabled` attribute, and by ensuring the element is keyboard-focusable — this is exactly the branch `BUTTON_A11Y_CONTRACT.DISABLED_ATTRIBUTES.asChild` documents. `VisuallyHidden` content stays in the accessibility tree and the tab order (when focusable) even though it's clipped from the visual viewport.

Styling and tokens

`asChild` doesn't add any wrapper markup for a theme to accidentally pick up — the component's `data-*` attributes and classes land directly on the element you provided as `children`. `VisuallyHidden` uses the standard clip-to-1px-and-hide-overflow technique rather than `display: none`, which is what keeps it perceivable to assistive tech while invisible on screen.

API

The shared `Ref<T>` type comes from `@askrjs/askr/foundations/utilities` and is used as `ref?: Ref<HTMLButtonElement>` (native branch) or `ref?: Ref<Element>` (asChild branch — narrower than the native branch's specific element type, but still the base DOM `Element` type, not `unknown`). `JSXElement`, from `@askrjs/askr/foundations/structures`, types the single required child every `asChild` variant expects.

Edge cases

`asChild` requires exactly one child element — passing text, a fragment, or multiple children where a single element is expected will not compose correctly, since the component clones props onto that one element rather than wrapping a list. Because the asChild branch types `ref` as the base `Ref<Element>` rather than the native branch's specific `Ref<HTMLButtonElement>`, you lose the specific-element-type checking on the ref target (methods/properties unique to the concrete element type won't be visible without a cast); that's a real trade-off of the polymorphism, not an oversight to work around.

See Interaction Policies for how each component's `*_A11Y_CONTRACT` documents the asChild-vs-native attribute differences shown here. See Icon Contract for another small, focused accessibility convention that layers on top of components like Button.