Passing an axe-core audit is not the same thing as designing for accessibility. The outputs can look identical. The process that produced them is not.
Most component libraries retrofit accessibility. The ARIA roles get added, the keyboard navigation gets wired, the contrast ratios get verified. It passes. But the architecture was not designed around the constraint. It was designed first, then adjusted to meet it.
nuka-ui was built with WCAG 2.2 AA as a hard requirement from the first commit. Not a goal. A constraint. That distinction is in the README. This article is what that sentence means in practice: nine decisions from the codebase where accessibility as a non-negotiable constraint produced an outcome that a retrofit would not.
1. The hidden attribute instead of conditional rendering
The default React pattern for a dropdown is {open && <SelectContent />}. It is clean, idiomatic, and wrong for an accessible combobox.
The SelectTrigger has aria-controls pointing to the listbox ID. If the listbox is conditionally rendered, aria-controls references an element that does not exist when the dropdown is closed. The ARIA 1.2 spec technically permits this, but it creates a second problem: SelectItem components register their labels on mount so the trigger can display the selected option's label. If the listbox is not in the DOM, that registration never happens. The trigger has no label data on first render.
The fix is hidden={!open} on the listbox. Always in the DOM, removed from the accessibility tree when not needed, aria-controls always resolves.
<div
role="listbox"
id={listboxId}
hidden={!open}
aria-hidden={!open}
>
{children}
</div>
This decision cascades. Because the listbox is always mounted, the label registry must exist before the listbox is visible. SelectItem registers via useLayoutEffect into a Map stored in a ref. A registryVersion state counter increments on each registration, included in a useMemo dependency so SelectTrigger re-renders when items register.
One accessibility requirement. Three implementation consequences. The simpler approach would have worked visually. It would not have worked for screen readers.
2. aria-controls and aria-expanded are not redundant
SelectTrigger carries both aria-controls and aria-expanded. They answer different questions.
aria-expanded tells the user whether the popup is currently open. aria-controls tells the user which element this control manages. Both are required by the ARIA combobox pattern. Together they allow screen readers to announce the relationship between the trigger and the listbox without the user exploring the DOM. Removing either one degrades the experience even if the visual interface is correct.
The third ARIA attribute on the trigger is aria-activedescendant. When keyboard navigation highlights an option, this attribute points to that option's ID. Focus stays on the trigger. The screen reader announces which option is highlighted.
<button
role="combobox"
aria-haspopup="listbox"
aria-expanded={ctx.open}
aria-controls={ctx.listboxId}
aria-activedescendant={ariaActiveDescendant}
/>
One test from the suite worth noting:
it("aria-activedescendant is undefined when no option is highlighted", () => {
renderSelect();
expect(screen.getByRole("combobox")).not.toHaveAttribute(
"aria-activedescendant",
);
});
The test checks for undefined, not empty string. aria-activedescendant="" is invalid. The attribute must be absent when nothing is highlighted. TypeScript strictness helps: the value is typed as string | undefined, and undefined attributes are not rendered to the DOM.
3. nameFrom: author and the accessible name fallback chain
role="combobox" has nameFrom: author in the ARIA spec. This means the visible text inside the trigger does not contribute to the accessible name. A screen reader will not announce the displayed value unless an explicit accessible name is provided.
This surprises developers who test visually. The trigger displays the selected value. It looks labelled. It is not labelled from the screen reader's perspective.
SelectTrigger handles this with a fallback chain:
- If the trigger is inside a
FormField, theLabelcomponent providesaria-labelledbypointing to the label's ID. This takes priority andaria-labelis omitted. - If no
FormFieldis present,SelectTriggerderivesaria-labelfrom: the selected option label (from the registry), then theplaceholderprop, then a hardcoded fallback of"Select".
const ariaLabel = ariaLabelledBy
? undefined // aria-labelledby from FormField takes over
: (displayLabel ?? placeholder ?? "Select");
const ariaLabelledBy = formCtx.labelId || undefined;
The fallback to "Select" is a last resort that ensures no combobox ships without an accessible name, even if the consumer forgets to provide a label or placeholder. This chain exists because of a specific ARIA rule about this specific role. You only design it if you know the rule before you start.
4. Skeleton's hardcoded aria-hidden that cannot be overridden
Most components in nuka-ui accept spread props, including arbitrary ARIA overrides. Skeleton deliberately does not allow aria-hidden to be removed.
Skeleton is purely visual decoration. It conveys no information. Loading state announcements belong on the parent container via aria-busy="true", not on the skeleton elements. If a consumer passes aria-hidden={false}, they create a situation where a screen reader announces a meaningless animated rectangle as content.
The enforcement mechanism is prop ordering. aria-hidden="true" is placed after the ...props spread:
const Skeleton = React.forwardRef<HTMLDivElement, SkeletonProps>(
({ className, shape, ...props }, ref) => {
return (
<div
ref={ref}
{...props}
aria-hidden="true" // after spread: cannot be overridden
className={cn(skeletonVariants({ shape }), className)}
/>
);
},
);
The correct pattern puts aria-busy on the container:
<div aria-busy="true" aria-label="Loading posts">
<Skeleton shape="text" className="h-4 w-3/4" />
<Skeleton shape="text" className="h-4 w-1/2" />
</div>
Most component libraries give consumers maximum flexibility on the assumption they know what they are doing. The tradeoff is that consumers can also do the wrong thing. Skeleton has one job. The API reflects that.
5. Toast live region urgency tied to intent
Toast uses role="status" with aria-live set dynamically based on intent. Default, success, and warning toasts use aria-live="polite". Danger toasts use aria-live="assertive".
This is not a visual distinction. It changes when and how a screen reader interrupts the user.
aria-live="polite" waits for the user to finish what they are doing, then announces. aria-live="assertive" interrupts immediately. A confirmation toast ("Profile saved") should not interrupt a user who is mid-sentence in a form. A danger toast ("Payment failed") should.
<div
role="status"
aria-live={toastItem.intent === "danger" ? "assertive" : "polite"}
aria-atomic="true"
/>
The tests that enforce the split:
it("has aria-live=assertive for danger intent", () => {
render(<Toast toast={{ ...baseToast, intent: "danger" }} onDismiss={onDismiss} />);
expect(screen.getByRole("status")).toHaveAttribute("aria-live", "assertive");
});
it("has aria-live=polite for success intent", () => {
render(<Toast toast={{ ...baseToast, intent: "success" }} onDismiss={onDismiss} />);
expect(screen.getByRole("status")).toHaveAttribute("aria-live", "polite");
});
Most toast implementations use one aria-live setting for everything. The distinction here is tied directly to the semantic meaning of intent, which is also used to drive visual styling. The accessibility behavior and the visual behavior are derived from the same prop, which means they stay in sync.
6. Banner's required aria-label enforced at the TypeScript level
Banner uses role="region". A region landmark without an accessible name is worse than no landmark: screen reader users navigating by landmarks encounter an anonymous region with no context for what it contains.
The fix is obvious: require aria-label. The interesting part is how nuka-ui enforces it.
Rather than documenting the requirement or adding a runtime warning, aria-label is required at the TypeScript type level. BannerProps uses Omit to strip the optional aria-label from React.HTMLAttributes and re-declares it as required:
export interface BannerProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "aria-label">,
BannerVariantProps {
"aria-label": string; // required, not optional
onDismiss?: () => void;
action?: React.ReactNode;
}
A consumer who omits aria-label gets a TypeScript error at build time, not a failed audit at review time. The accessibility requirement became a compile-time contract.
This is distinct from Alert, which uses role="alert". Alert is an assertive live region for urgent, transient feedback. Banner is a persistent contextual landmark. The semantic distinction between the two is reflected in the APIs: Alert has no aria-label requirement because its role carries the identity. Banner does, because role="region" does not.
7. Tooltip vs. Popover: the semantic boundary in the API
Tooltip content has pointer-events-none and role="tooltip". Popover content has role="dialog" and receives focus on open.
These are not stylistic choices. They reflect a fundamental ARIA distinction between two types of supplemental content.
role="tooltip" is for non-interactive supplementary information. Triggered by hover and focus. The trigger describes itself with aria-describedby. Focus stays on the trigger. role="dialog" is for interactive regions. Triggered by click. Focus moves into the panel on open.
The structural difference:
// Tooltip: focus stays on trigger, content is non-interactive
const hover = useHover(context, { delay: { open: delay, close: 0 } });
const focus = useFocus(context);
const role = useRole(context, { role: "tooltip" });
// trigger gets aria-describedby, content has pointer-events-none
// Popover: focus moves into panel on open
const click = useClick(context);
const role = useRole(context, { role: "dialog" });
// trigger gets aria-expanded + aria-controls
// PopoverContent focuses first focusable child on open
The PopoverContent focus management:
React.useEffect(() => {
if (ctx.open) {
const frame = requestAnimationFrame(() => {
const focusable = contentRef.current?.querySelector<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
if (focusable) {
focusable.focus();
} else {
contentRef.current?.focus();
}
});
return () => cancelAnimationFrame(frame);
}
}, [ctx.open]);
The requestAnimationFrame is necessary because the portal has just been added to the DOM and layout has not completed. Attempting focus() synchronously inside useEffect is unreliable.
One additional constraint from WCAG 1.4.13: Tooltip opens on focus with no delay. The hover open has a configurable delay (default 600ms). Focus-triggered content must be immediate because the user has already made an explicit navigation action. Applying the hover delay to focus-open would violate that criterion.
The compound component pattern enforces the semantic separation structurally. A consumer cannot accidentally put interactive content inside a Tooltip because the content element has pointer-events-none. The boundary is not just documented. It is built in.
8. RadioGroup roving tabindex: one tab stop, arrow key navigation
RadioGroup implements the roving tabindex pattern. Only one radio in the group is in the tab order at a time (tabindex="0"). All others have tabindex="-1". Arrow keys move between radios and shift the tabindex="0".
This is the correct keyboard interaction model for radio groups per the ARIA Authoring Practices Guide. Tab navigates between form controls. Arrow keys navigate within a group. A group with three radios should cost the user one Tab key press, not three.
case "ArrowDown":
case "ArrowRight":
e.preventDefault();
moveFocus("next");
break;
case "ArrowUp":
case "ArrowLeft":
e.preventDefault();
moveFocus("prev");
break;
case "Home":
e.preventDefault();
moveFocus("first");
break;
case "End":
e.preventDefault();
moveFocus("last");
break;
The test that verifies Tab does not cycle within the group:
it("Tab does not cycle within the group", () => {
renderGroup({ defaultValue: "green" });
const radios = screen.getAllByRole("radio");
const focused = radios.find((r) => r.getAttribute("tabindex") === "0");
const unfocused = radios.filter((r) => r.getAttribute("tabindex") === "-1");
expect(focused).toBeDefined();
expect(unfocused).toHaveLength(2);
});
The visual radio indicator uses sr-only on the native <input type="radio"> rather than display: none or visibility: hidden. The native input remains in the accessibility tree and receives focus. The visual ring is a custom <span aria-hidden="true"> that mirrors the state. Hiding the input entirely would break keyboard navigation. This is the kind of decision that only comes up if you are thinking about accessibility before you start writing styles.
9. The color token rule: adjust lightness only, never chroma or hue
This one is about the design token system, not component code. It is one of the most direct examples of how the accessibility constraint changed an architectural decision.
nuka-ui uses oklch() for all color tokens. An oklch() value has three components: L (lightness), C (chroma), H (hue). Contrast ratio is determined by luminance, which is primarily a function of lightness.
When a color token fails a contrast check, the rule is: adjust L only. Never adjust C or H.
Adjusting chroma or hue to fix contrast changes the color. You get something that passes the checker but is no longer the color you chose. Fixing contrast by adjusting lightness keeps the hue and saturation intent intact.
The primary accent is oklch(44% 0.043 257): hue 257 (a blue-grey direction), chroma 0.043 (intentionally low for a muted slate aesthetic), lightness 44%. That lightness value produces 7.74:1 contrast on white, which is WCAG AAA. The lightness was chosen to clear that bar. The hue and chroma were never touched for contrast reasons.
This rule only works cleanly in oklch(). In hsl(), saturation and lightness interact in non-perceptually-uniform ways. Equal changes in L do not produce equal perceived changes in lightness across hues. oklch() is perceptually uniform: adjusting L produces consistent perceived results regardless of hue. The color space was chosen in part because it makes the accessibility constraint easier to enforce consistently.
The actual cost of the constraint
These are not exotic edge cases. Every dropdown, every loading state, every notification system, every color token in any component library runs into exactly these decisions. The difference is when you encounter them.
If accessibility is a constraint from the start, you design the registry pattern before you wire up the combobox because you know you will need it. You make aria-label required on Banner before any consumer uses the component. You choose oklch() before you write the first token because you know you will need to adjust contrast without changing color identity.
If accessibility is a retrofit, you discover these things in an audit and patch them. The patches are usually correct. But the architecture was not designed around them. Some of the decisions above cannot be cleanly retrofitted because they affect the component API or the token system structure. By the time you find them, you already have consumers.
nuka-ui's README says WCAG 2.2 AA is "a hard constraint, not a goal." This article is what that sentence costs.
The live Storybook is at https://ku5ic.github.io/nuka-ui/.
Source is at https://github.com/ku5ic/nuka-ui.
Top comments (0)