Skip to main content
Solid Primitives 2

Accessibility primitives — labeled field groups, ARIA graph helpers, and more.

StageCategoryVersionLast UpdatedDemo
2Inputs1.0.0-next.3 (next)Aug 13, 2026Demo →
Terminal window
npm i @solid-primitives/a11y@next

Reactive accessibility primitives. Programmatically announce changes to screen readers via ARIA live regions, track the user's prefers-reduced-motion preference as a reactive signal, and wire the ARIA ID graph that connects labels, descriptions, and error messages to form inputs — the foundation for accessible headless components.


createAnnounce / makeAnnounce

Programmatically sends messages to screen readers via ARIA live regions. Two visually-hidden <div> elements are appended to document.body — one aria-live="polite", one aria-live="assertive" — and removed on cleanup.

import { createAnnounce } from "@solid-primitives/a11y";

Usage

const announce = createAnnounce();
// Status update — screen reader waits for idle before reading
announce("3 results found");
// Urgent error — interrupts the screen reader immediately
announce("Session expired. Please sign in again.", "assertive");

Politeness levels

LevelBehaviourWhen to use
"polite" (default)Waits for the screen reader to finish its current sentenceStatus updates, confirmations, search result counts
"assertive"Interrupts immediatelyUrgent errors that require immediate attention

Prefer "polite" in almost all cases. "assertive" is disruptive and should be reserved for true errors.

makeAnnounce

Non-reactive base. Returns [announce, cleanup] so you can use it outside a Solid component — useful for notification services or stores.

import { makeAnnounce } from "@solid-primitives/a11y";
const [announce, cleanup] = makeAnnounce();
announce("File downloaded");
// later, when your app unmounts:
cleanup();

Definition

function createAnnounce(): Announce;
function makeAnnounce(): [announce: Announce, cleanup: () => void];
type Announce = (message: string, politeness?: AnnouncePoliteness) => void;
type AnnouncePoliteness = "polite" | "assertive";

createReducedMotion

Returns a reactive accessor that reflects the user's prefers-reduced-motion system preference. Updates automatically when the OS setting changes. Returns false on the server (SSR-safe).

import { createReducedMotion } from "@solid-primitives/a11y";

Usage

const prefersReduced = createReducedMotion();
return <div class={prefersReduced() ? "" : "animate-fade-in"}>Content</div>;
// Gate inline styles
const style = () => ({
transition: prefersReduced() ? "none" : "transform 0.3s ease",
});

When to apply

  • Disable CSS animations and transitions
  • Stop auto-playing carousels or slideshows
  • Remove parallax and scroll-triggered effects
  • Reduce motion in canvas/WebGL rendering

Testing locally

macOS: System Settings → Accessibility → Display → Reduce Motion

Windows: Settings → Ease of Access → Display → Show animations

Definition

function createReducedMotion(): Accessor<boolean>;

createFormControl

Creates the ARIA context for a labeled field group. Returns a FormControlContextValue directly — usable standalone or passed to <FormControlContext> so sub-components can consume it via useFormControl().

import {
createFormControl,
createFormControlInput,
FormControlContext,
useFormControl,
} from "@solid-primitives/a11y";

The sub-component pattern

The intended usage is a Root component that owns the context and a set of named sub-components that each register themselves on mount. This is the same pattern Kobalte uses internally for TextField, Checkbox, etc.

import { type Element, Show, onCleanup } from "solid-js";
import {
createFormControl,
createFormControlInput,
FormControlContext,
useFormControl,
} from "@solid-primitives/a11y";
// 1. Root — creates the ARIA graph and provides it via context
const TextFieldRoot = (props: {
id?: string;
validationState?: "valid" | "invalid";
required?: boolean;
disabled?: boolean;
children: Element;
}) => {
const ctx = createFormControl(props);
return <FormControlContext value={ctx}>{props.children}</FormControlContext>;
};
// 2. Label — registers its ID so the input can reference it
const TextFieldLabel = (props: { children: Element }) => {
const ctx = useFormControl();
const id = ctx.generateId("label");
onCleanup(ctx.registerLabel(id));
return <label id={id}>{props.children}</label>;
};
// 3. Input — reads context and spreads computed ARIA props
const TextFieldInput = (props: { placeholder?: string }) => {
const { fieldProps } = createFormControlInput();
const ctx = useFormControl();
return (
<input
id={fieldProps.id()}
placeholder={props.placeholder}
aria-labelledby={fieldProps.ariaLabelledBy()}
aria-describedby={fieldProps.ariaDescribedBy()}
aria-invalid={ctx.validationState() === "invalid" ? "true" : undefined}
aria-required={ctx.isRequired() ? "true" : undefined}
disabled={ctx.isDisabled() ?? false}
/>
);
};
// 4. Description — registers so it's included in aria-describedby
const TextFieldDescription = (props: { children: Element }) => {
const ctx = useFormControl();
const id = ctx.generateId("description");
onCleanup(ctx.registerDescription(id));
return <span id={id}>{props.children}</span>;
};
// 5. ErrorMessage — registers only while rendered (Show handles mount/unmount)
const ErrorMessageInner = (props: {
ctx: ReturnType<typeof createFormControl>;
children: Element;
}) => {
const id = props.ctx.generateId("error-message");
onCleanup(props.ctx.registerErrorMessage(id));
return (
<span id={id} role="alert">
{props.children}
</span>
);
};
const TextFieldErrorMessage = (props: { children: Element }) => {
const ctx = useFormControl();
return (
<Show when={ctx.validationState() === "invalid"}>
<ErrorMessageInner ctx={ctx}>{props.children}</ErrorMessageInner>
</Show>
);
};
// Usage
<TextFieldRoot id="email" validationState={fieldError() ? "invalid" : undefined} required>
<TextFieldLabel>Email address</TextFieldLabel>
<TextFieldInput placeholder="you@example.com" />
<TextFieldDescription>We'll never share your email.</TextFieldDescription>
<TextFieldErrorMessage>Enter a valid email address.</TextFieldErrorMessage>
</TextFieldRoot>;

When validationState is "invalid", the error message component mounts, registers its ID, and aria-describedby on the input automatically expands to include it. When the state clears, the component unmounts and onCleanup removes its ID from the graph.

Note on ErrorMessageInner: The inner component pattern is needed because when Show's when prop is typed as boolean, Solid 2.0 types function children as never. Using a named inner component avoids the type error while keeping onCleanup scoped to the conditional branch.

Props

PropTypeDescription
idMaybeAccessor<string>Base ID for the field group. Auto-generated if omitted.
nameMaybeAccessor<string>Form submission name. Falls back to id.
validationStateMaybeAccessor<"valid" | "invalid" | undefined>Sets data-valid / data-invalid and controls whether the error message ID is included in aria-describedby.
requiredMaybeAccessor<boolean | undefined>Sets data-required.
disabledMaybeAccessor<boolean | undefined>Sets data-disabled.
readOnlyMaybeAccessor<boolean | undefined>Sets data-readonly.

All props accept either a plain value or a reactive accessor () => value, so they compose naturally with signals.

Context value (FormControlContextValue)

MemberTypeDescription
name()Accessor<string>Resolved name (falls back to id)
validationState()Accessor<"valid" | "invalid" | undefined>Current validation state
isRequired()Accessor<boolean | undefined>Whether the field is required
isDisabled()Accessor<boolean | undefined>Whether the field is disabled
isReadOnly()Accessor<boolean | undefined>Whether the field is read-only
dataset()Accessor<FormControlDataSet>All data-* attribute values — spread onto any element that should reflect state
generateId(part)(part: string) => stringReturns "${baseId}-${part}" — use this to derive stable IDs for sub-elements
labelId()Accessor<string | undefined>Currently registered label element ID
fieldId()Accessor<string | undefined>Currently registered field element ID
descriptionId()Accessor<string | undefined>Currently registered description element ID
errorMessageId()Accessor<string | undefined>Currently registered error message element ID
registerLabel(id)(id: string) => () => voidRegisters a label ID; returns a cleanup function
registerField(id)(id: string) => () => voidRegisters the field ID; returns a cleanup function
registerDescription(id)(id: string) => () => voidRegisters a description ID; returns a cleanup function
registerErrorMessage(id)(id: string) => () => voidRegisters an error message ID; returns a cleanup function
getAriaLabelledBy(fieldId, ariaLabel, ariaLabelledBy)(fieldId, ariaLabel, ariaLabelledBy) => string | undefinedComputes the full aria-labelledby value
getAriaDescribedBy(ariaDescribedBy)(ariaDescribedBy) => string | undefinedComputes the full aria-describedby value

data-* attributes

dataset() returns an object where each active state is "" (empty string) and inactive states are undefined. This follows the HTML convention for boolean data attributes — you can target them in CSS with attribute presence selectors:

[data-invalid] {
border-color: red;
}
[data-disabled] {
opacity: 0.5;
}
[data-required] {
/* required styling */
}
[data-readonly] {
background: #f5f5f5;
}
// Spread onto any element that should reflect the field's state
<div {...ctx.dataset()}>...</div>

createFormControlInput

Reads from FormControlContext (must be called inside a <FormControlContext>) and returns computed ARIA props for the actual input element. Uses a split createEffect to register and deregister the field's ID reactively.

Props

PropTypeDescription
idMaybeAccessor<string>Override the generated field ID. Defaults to context.generateId("field").
aria-labelMaybeAccessor<string>Passed through to fieldProps.ariaLabel().
aria-labelledbyMaybeAccessor<string>Merged into the computed aria-labelledby chain.
aria-describedbyMaybeAccessor<string>Appended to the computed aria-describedby value.

Return value

{
fieldProps: {
id: () => string;
ariaLabel: () => string | undefined;
ariaLabelledBy: () => string | undefined;
ariaDescribedBy: () => string | undefined;
}
}

Usage

const TextFieldInput = (props: { placeholder?: string }) => {
const { fieldProps } = createFormControlInput({
"aria-label": props["aria-label"],
});
const ctx = useFormControl();
return (
<input
id={fieldProps.id()}
aria-labelledby={fieldProps.ariaLabelledBy()}
aria-describedby={fieldProps.ariaDescribedBy()}
aria-label={fieldProps.ariaLabel()}
aria-invalid={ctx.validationState() === "invalid" ? "true" : undefined}
aria-required={ctx.isRequired() ? "true" : undefined}
disabled={ctx.isDisabled() ?? false}
readonly={ctx.isReadOnly() ?? false}
/>
);
};

FormControlContext + useFormControl()

FormControlContext is a standard Solid context. Provide a value from createFormControl and consume it in any descendant with useFormControl().

// Provider
const ctx = createFormControl({ id: "my-field", required: true });
<FormControlContext value={ctx}>
<MyLabel />
<MyInput />
<MyDescription />
</FormControlContext>;
// Consumer (inside any descendant component)
const ctx = useFormControl();

useFormControl() throws a ContextNotFoundError if called outside a <FormControlContext>.


aria-labelledby chain

getAriaLabelledBy follows Kobalte's three-argument resolution logic. When both a visible label (labelId) and an explicit aria-label are present on the input, the field's own ID is appended to the chain. This ensures screen readers can announce all three — the visible label element, the field element itself, and the inline aria-label — in the correct order.

// No label registered, no aria-label → undefined
// Label registered, no aria-label → "field-label"
// Label registered + explicit aria-labelledby → "external-label field-label"
// Label registered + aria-label on input → "field-label field-field"

validationState and error messages

aria-describedby is used for error messages rather than aria-errormessage — the latter has poor support in VoiceOver and NVDA. The error message ID enters aria-describedby as soon as the error message element mounts (registers via registerErrorMessage). The conditional behaviour comes entirely from whether the error component is rendered — typically gated by <Show when={ctx.validationState() === "invalid"}>.


Standalone usage (without JSX context)

createFormControl can be used without the context provider — useful when building a single accessible field rather than a full headless component system:

function AccessibleField() {
const ctx = createFormControl({ id: "email" });
const [value, setValue] = createSignal("");
const error = () => (!value().includes("@") ? "Enter a valid email address" : null);
// Register sub-elements synchronously (onCleanup handles deregistration)
onCleanup(ctx.registerLabel("email-label"));
onCleanup(ctx.registerDescription("email-desc"));
const fieldId = "email-field";
const ErrorMessage = () => {
onCleanup(ctx.registerErrorMessage("email-error"));
return (
<span id="email-error" role="alert">
{error()}
</span>
);
};
return (
<div>
<label id="email-label" for={fieldId}>
Email
</label>
<input
id={fieldId}
type="email"
value={value()}
onInput={e => setValue(e.currentTarget.value)}
aria-labelledby={ctx.getAriaLabelledBy(fieldId, undefined, undefined)}
aria-describedby={ctx.getAriaDescribedBy(undefined)}
aria-invalid={error() ? "true" : undefined}
/>
<span id="email-desc">We'll never share your email.</span>
<Show when={error()}>
<ErrorMessage />
</Show>
</div>
);
}

TypeScript

Definition

function createFormControl(props: CreateFormControlProps): FormControlContextValue;
function createFormControlInput(props?: CreateFormControlInputProps): { fieldProps: FieldProps };
const FormControlContext: Context<FormControlContextValue | undefined>;
function useFormControl(): FormControlContextValue;
type CreateFormControlProps = {
id?: MaybeAccessor<string>;
name?: MaybeAccessor<string>;
validationState?: MaybeAccessor<"valid" | "invalid" | undefined>;
required?: MaybeAccessor<boolean | undefined>;
disabled?: MaybeAccessor<boolean | undefined>;
readOnly?: MaybeAccessor<boolean | undefined>;
};
type CreateFormControlInputProps = {
id?: MaybeAccessor<string>;
"aria-label"?: MaybeAccessor<string>;
"aria-labelledby"?: MaybeAccessor<string>;
"aria-describedby"?: MaybeAccessor<string>;
};
type FormControlDataSet = {
"data-valid"?: "" | undefined;
"data-invalid"?: "" | undefined;
"data-required"?: "" | undefined;
"data-disabled"?: "" | undefined;
"data-readonly"?: "" | undefined;
};
type FormControlContextValue = {
name: Accessor<string>;
validationState: Accessor<"valid" | "invalid" | undefined>;
isRequired: Accessor<boolean | undefined>;
isDisabled: Accessor<boolean | undefined>;
isReadOnly: Accessor<boolean | undefined>;
dataset: Accessor<FormControlDataSet>;
labelId: Accessor<string | undefined>;
fieldId: Accessor<string | undefined>;
descriptionId: Accessor<string | undefined>;
errorMessageId: Accessor<string | undefined>;
generateId: (part: string) => string;
registerLabel: (id: string) => () => void;
registerField: (id: string) => () => void;
registerDescription: (id: string) => () => void;
registerErrorMessage: (id: string) => () => void;
getAriaLabelledBy: (
fieldId: string | undefined,
ariaLabel: string | undefined,
ariaLabelledBy: string | undefined,
) => string | undefined;
getAriaDescribedBy: (ariaDescribedBy: string | undefined) => string | undefined;
};

Attribution

Adapted from Kobalte by Jeremy Lindblom et al. (MIT), which itself is based on React Aria by Adobe (Apache 2.0). See LICENSE for full attribution.


Changelog

See CHANGELOG.md

Solid Primitives 2High-quality reactive primitives for building applications in Solid2
Community
githubdiscord