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.
Typed aria-* props (since alpha.9)
Section titled “Typed aria-* props (since alpha.9)”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-.
Serialisation rules
Section titled “Serialisation rules”| Value | Serialised as | Notes |
|---|---|---|
"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 / null | attribute removed | Useful 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+.
Dynamic ARIA
Section titled “Dynamic ARIA”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);Removing an attribute reactively
Section titled “Removing an attribute reactively”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).
Focus management with ref()
Section titled “Focus management with ref()”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.value — not .current. Whisq refs are plain signals; React’s .current naming doesn’t apply. See Common Mistakes → .current instead of .value for the spelling trap.
Focus management across navigations
Section titled “Focus management across navigations”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.
Semantic HTML + ARIA together
Section titled “Semantic HTML + ARIA together”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 control | button({ onclick: … }, label) | div({ role: "button", tabindex: 0, onkeydown: … }) |
| Navigation | a({ href: … }, label) | span({ role: "link", onclick: () => navigate(…) }) |
| Named form region | form({ …, "aria-labelledby": "form-title" }) | div({ role: "form" }) |
| Accessible heading landmark | h1() – h6() | div({ role: "heading", "aria-level": 2 }) |
| Live status text | div({ 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.
When you DO need role + full ARIA
Section titled “When you DO need role + full ARIA”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.
Further reading
Section titled “Further reading”/api/elements/#aria-attributes-since-alpha9— the reference for the ARIA surface, including the serialisation table./api/ref/— ref shapes, callback form, and the.valuevs.currenttrap./api/h/— the untyped escape-hatch element factory.- WAI-ARIA Authoring Practices — upstream pattern reference for ARIA + keyboard behaviour by widget type.
- MDN ARIA reference — attribute-by-attribute semantics and serialisation rules.
Docs current to v0.1.0-alpha.9 . All releases →