Every interactive element on the web answers the same hard questions. What happens on Tab. What happens on Shift-Tab, Escape, arrow keys, Home and End. What a screen reader announces. What the focus ring looks like and when it appears. Most application teams answer these questions dozens of times, once per component, and get them slightly wrong each time.
The Hraness answer is to refuse to answer them at all. @hraness/ui builds every interactive primitive on react-aria and react-aria-components, pinned at 3.50.0 and 1.19.0, and treats that choice as a custody decision rather than an implementation detail. Focus management, keyboard interaction, and accessible naming belong to the dependency. The kit owns presentation and API shape. Product code owns content and domain behavior. Nobody in product code writes a keydown handler to make a button work.
the custody decision
A custody decision is a deliberate statement about who is responsible for a behavior for the lifetime of the code, not about who typed it first. Saying “React Aria owns focus” means three concrete things. Product code does not implement roving tabindex, focus trapping, or escape-to-close; it composes components that already do. Bugs in interaction are reported against the dependency and fixed there once, for every product. And the kit’s APIs are shaped so that the accessible path is the only path, which means the decision cannot be quietly undone by a product on a deadline.
This is different from “we use React Aria.” The kit could wrap the primitives and still leave naming, labeling, and tooltip wiring as optional props that products fill in when they remember. Instead the required accessible inputs are part of the type signature, so a control that cannot name itself does not compile.
what the dependency owns
The action family in src/actions.tsx is the clearest read on the split. Button, Link, LinkButton, IconButton, IconLink, ToggleButton, and CopyButton all delegate their semantics to the corresponding React Aria Components:
import {
Button as AriaButton,
Link as AriaLink,
ToggleButton as AriaToggleButton,
} from "react-aria-components";
IconButton is an AriaButton underneath, which means press semantics, keyboard activation, disabled and pending states, and focus behavior arrive with the dependency. ToggleButton gets pressed-state semantics from AriaToggleButton rather than an aria-pressed string a product might forget. Collection and overlay components (menus, selects, dialogs, tabs, tooltips) come from the same source, so the focus trap in a dialog and the type-ahead in a list box are the library’s problem, exercised across the whole React ecosystem rather than inside one monorepo.
The lower-level hooks carry their share too. Knob is built on useMove from react-aria and Slider from react-aria-components: the pointer-drag math, the keyboard increments, and the screen-reader value announcements are library behavior, while the kit supplies the visual track and the value formatting. Even the focus ring is handled once, globally: the shared reset paints the outline on :focus-visible so the ring appears for keyboard users and stays quiet for pointer users, without product involvement.
the kit’s own contracts
Custody handed to a dependency does not finish the job, because the most common accessibility failure in component libraries is not broken focus management; it is an anonymous control. A square icon that does something is only accessible if it can say what it does. So the kit makes naming structural.
Icon-only controls require an accessible name through aria-label or aria-labelledby, expressed in the prop types rather than in a documentation request: IconButtonProps includes an AccessibleIconName union that makes one of the two required and the other never, and the values are checked non-blank at render. IconButton also owns its tooltip: when the name comes through aria-labelledby the tooltip prop is required by type, and when it comes through aria-label the tooltip defaults to the label, so a bare icon without an explanation is not expressible. Two corollaries fall out of that ownership and are written down as rules: never wrap an IconButton or IconLink in another Tooltip (the control already provides one, and stacking produces duplicate announcements), and never use the native title attribute, which the prop type omits outright because title is inconsistently announced and unreachable on touch and keyboard. Products are likewise directed away from generic Button and LinkButton for icon-only use, because the generic API does not demand the name.
The same shape repeats across the kit. Field components take visible labels as props rather than trusting a product to associate a <label> correctly. Dialog content is wired to its title through aria-labelledby by the component, not by the caller. The pattern is consistent enough to state once: if a piece of accessible wiring can be made a prop type, it is a prop type; if it can be internal to the component, it is internal; what is left for the product is only the human-authored text itself.
what the product still owns
The custody split only works because the product side of it is small and explicit. A product supplies three things to a control: its content (the label text, the icon choice, the tooltip copy), its domain behavior (what pressing the button does in the application), and its placement (where in the layout it sits, expressed through class names and documented custom properties). Everything else, the tabindex, the keymap, the announcement, the pressed and disabled states, arrives already decided.
That division is worth stating plainly because it is where accessibility work actually lives after the primitives are chosen. When a review asks whether a control is accessible, the product-level question is never “did we handle the keyboard.” It is “did we write an honest label” and “does this control exist in the right order in the document.” Those are content questions with human answers, and they are the questions product teams are good at. The kit’s job is to make them the only questions left.
presentation without re-behavior
What remains for the kit and for products is presentation, and the presentation layer is designed so that styling never requires touching behavior. Components are styled with StyleX, compiled to static CSS, and expose their state to product code through the React Aria render-prop surface and data-* state attributes rather than through callbacks a product could use to reimplement interaction. A product that wants a pressed toggle to look different styles [data-pressed] or supplies a render-prop class; it does not observe pointer events.
That separation is what keeps the custody decision stable over time. The usual way accessibility erodes is not a bad actor; it is a product that needed a slightly different look and ended up replacing the component with a div and an onClick. When the visual surface is fully parameterizable through class names, documented custom properties like --jelly-fill, and render props, there is no remaining reason to replace the component. The escape hatch is a style hook, not a behavior rewrite.
when the browser is enough
The honest version of this lesson includes the cases where the dependency is deliberately not used. Several kit components render plain semantic HTML because the browser already provides the correct contract and a wrapper would only add risk: DataTable renders a real table, Breadcrumbs and Pagination render native navigation markup, SkipLink renders an ordinary anchor to a landmark, and KeyHint renders a kbd. CheckboxField still ends at an actual <input type="checkbox"> under its styled surface, so form semantics, labels, and submission come free. Links are anchors that work without JavaScript; the Link component exists to style them consistently, not to intercept navigation.
The shared footer carries the same posture to the organization level: the mailing signup is a real form that posts to the account service and succeeds with JavaScript disabled, honeypot and all. Progressive enhancement is not a fallback story bolted on afterward; it is the same custody rule recognizing that form submission already belongs to the platform.
The rule underneath is consistent in both directions: behavior belongs to whichever layer already implements it correctly and durably. For routing, forms, and document structure that layer is the browser itself. For focus, keyboard, and announcement inside composite widgets it is React Aria. Product code never holds that custody at all, which is the whole point: the accessible answer is the default answer, and the only way to get a different one is to leave the kit.
sources
- ui:
src/actions.tsxfor the action family overAriaButton/AriaLink/AriaToggleButton,src/reset.cssfor the:focus-visibleoutline, andsrc/index.tsfor the exported primitive surface. - React Aria and React Aria Components: the pinned interaction layer (
react-aria3.50.0,react-aria-components1.19.0) that owns focus, keyboard, and naming semantics. - design-kit: compositions over the primitives, including the gallery that exercises them.
- The monorepo behind this site (projects): the design-system runbook recording the icon-naming, tooltip-ownership, and
title-attribute rules.