Skip to content
Whisq v0.1.0-alpha.9

Accessibility

Accessibility in Whisq comes from four decisions that compose: reach for the right semantic element first, let the typed aria-* surface fill the gap when semantics aren’t enough, use ref() + onMount for focus management, and drop to h(tag, { … }) when you need an attribute the typed props don’t cover.

Every element accepts typed aria-* props via an indexed signature on BaseProps:

// The type on every element's props:
[key: `aria-${string}`]: ReactiveProp<string | boolean | undefined>;

That means all three of these typecheck, on every element:

button({ "aria-label": "Close dialog" }, "×");
button({ "aria-expanded": true }, "Menu");
button({ "aria-expanded": () => menuOpen.value }, "Menu");

No need to pick between “known ARIA names” and the untyped data-* escape hatch — the typed surface covers every attribute whose name starts with aria-.

ValueSerialised asNotes
"polite" / any string"polite"Strings pass through verbatim — use these for enum-shaped attrs (aria-live, aria-sort, etc.).
true"true"Correct per ARIA spec — alpha.9 fix.
false"false"Correct per ARIA spec — "false" is not equivalent to “missing” for predicate attrs.
undefined / nullattribute removedUseful for conditional removal — see “Dynamic ARIA” below.

Pre-alpha.9 note: true used to serialise to the empty string aria-expanded="", which is invalid per the ARIA spec — it’s not equivalent to aria-expanded="true". Alpha.9 routes aria-* through a dedicated branch in the prop applier that produces the correct string form (whisqjs/whisq#128, whisqjs/whisq#129). If you’re seeing the wrong shape in DOM, check you’re on alpha.9+.

Pass a function to make any aria-* prop reactive — ReactiveProp<T> is T | (() => T), so a getter fits anywhere a static value fits:

const menuOpen = signal(false);
const status = signal<"loading" | "ready" | "error">("loading");
button({
"aria-expanded": () => menuOpen.value,
"aria-controls": "main-menu",
onclick: () => menuOpen.value = !menuOpen.value,
}, "Menu");
div({ role: "status", "aria-live": "polite" }, () => status.value);

Return undefined (or null) from the getter to drop the attribute. This is the idiomatic way to express “this attribute doesn’t apply right now”:

// Visible elements don't need aria-hidden at all; set it only when hiding.
div({ "aria-hidden": () => visible.value ? undefined : true });

Don’t try to “remove” an attribute by setting it to false — that serialises to "false", which is a different accessibility meaning for most predicate-shaped ARIA attrs (e.g. aria-hidden="false" is explicit “not hidden”, which is distinct from the default of no attribute at all).

A ref() gives you a reactive handle to the DOM node. For focus work, pair it with onMount so the node exists before you call .focus():

import { component, input, onMount } from "@whisq/core";
import { ref } from "@whisq/core";
const AutoFocusInput = component(() => {
const el = ref<HTMLInputElement>();
// Focus inside onMount — the node is guaranteed to be attached to the DOM
// by the time this callback runs, so `.focus()` has something to focus.
onMount(() => {
el.value?.focus();
});
return input({ ref: el, placeholder: "Search…" });
});

el.valuenot .current. Whisq refs are plain signals; React’s .current naming doesn’t apply. See Common Mistakes → .current instead of .value for the spelling trap.

For a multi-view app, set focus from the component that owns the newly-mounted view:

import { component, h2, div } from "@whisq/core";
import { ref, onMount } from "@whisq/core";
const DialogTitle = component((props: { children: string }) => {
const heading = ref<HTMLHeadingElement>();
onMount(() => heading.value?.focus());
// tabindex="-1" makes the element programmatically focusable without
// adding it to the tab order.
return h2({ ref: heading, tabindex: -1 }, props.children);
});

Same shape for routed pages — the top-of-route component takes a ref on its heading (or a skip-link target) and focuses it on mount.

Reading a ref in an event handler (untracked)

Section titled “Reading a ref in an event handler (untracked)”

When an event handler needs the element but shouldn’t re-run on ref changes (e.g. you just want to blur on Escape), read with .peek():

input({
ref: el,
onkeydown: (e) => {
if (e.key === "Escape") el.peek()?.blur();
},
});

Inside effects or reactive getters, read with .value — that’s what subscribes the consumer.

Prefer a real semantic element over a role=-annotated generic element. button() already carries role, keyboard handling (Enter and Space fire onclick), focusability, and the correct disabled semantics — a div({ role: "button" }) has none of those for free and needs a full keyboard handler, explicit tabindex, and a visual focus style to reach parity.

When you need…Reach for…Not…
Click or keyboard-activated controlbutton({ onclick: … }, label)div({ role: "button", tabindex: 0, onkeydown: … })
Navigationa({ href: … }, label)span({ role: "link", onclick: () => navigate(…) })
Named form regionform({ …, "aria-labelledby": "form-title" })div({ role: "form" })
Accessible heading landmarkh1()h6()div({ role: "heading", "aria-level": 2 })
Live status textdiv({ role: "status", "aria-live": "polite" }, …)div({}, …) + DOM poking

The role-on-div patterns on the right do work with the typed ARIA surface, but you’d be re-implementing semantics the browser gives you for free with the left-hand shapes. Use role when there isn’t a semantic element for the job (tablists, trees, listboxes) and pair it with the full ARIA surface the pattern requires.

Building a custom widget (e.g. a tablist, a combobox) means role plus several aria-* attributes in concert. The typed surface handles all of them:

// A minimal tab control — role on the container, aria-selected on each tab,
// all using the typed surface.
const selected = signal<"overview" | "billing">("overview");
div({ role: "tablist" },
button({
role: "tab",
"aria-selected": () => selected.value === "overview",
"aria-controls": "panel-overview",
onclick: () => selected.value = "overview",
}, "Overview"),
button({
role: "tab",
"aria-selected": () => selected.value === "billing",
"aria-controls": "panel-billing",
onclick: () => selected.value = "billing",
}, "Billing"),
);

Follow the WAI-ARIA Authoring Practices patterns for which aria-* attributes a given role needs — the typed surface will accept any of them.

Escape hatch — h(tag, { "custom-attr": … })

Section titled “Escape hatch — h(tag, { "custom-attr": … })”

When an attribute isn’t in the typed surface at all — a custom data attribute beyond data-*, a non-ARIA accessibility attribute added to a new spec, or a web-component property — drop to h(). It accepts any prop key with the same runtime semantics as the typed element functions: function values become reactive getters, on* keys become event listeners, everything else sets an attribute. The only difference from button(), div(), etc. is that TypeScript imposes no constraint on the key name.

import { h } from "@whisq/core";
// A new accessibility attribute not yet on BaseProps.
h("button", { "aria-description": "Saves and closes the dialog" }, "Save");
// A web-component attribute.
h("my-widget", { "data-track": "pricing-cta", "theme-variant": "dark" });
// Reactive shape on a non-standard attribute — treated as a reactive getter,
// not stringified via setAttribute.
h("my-widget", { "selection-index": () => currentIndex.value });

The typed element functions (button, div, input, …) are thin wrappers around h() that add TypeScript coverage for the well-known prop shapes. Dropping to h() gives you the same runtime behaviour with a looser type — use it when TypeScript’s strictness is getting in your way, not as a default.

See /api/h/ for the full signature and semantics.

Docs current to v0.1.0-alpha.9 . All releases →