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
Disabled native controls use the disabled attribute. When asChild targets an element without native disabled behavior, verify aria-disabled, tab order and suppressed activation. VisuallyHidden keeps its content available to assistive technology while removing it from visual layout.
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.
Related pages
See Interaction Policies for native and asChild behavior and how to test the rendered attributes. See Icon Contract for accessible icon labeling.