Accessibility Railings
Jun 20, 2023138 min read
Accessibility Railings: Deep Dive #63
LP
Lena Patel
React UX Engineering Advisor
Accessibility railings are the operational guardrails that keep navigation usable while your routing model stays mixed. A railing is a route shell contract that:
- schedules focus as part of every transition,
- announces route changes as visible evidence,
- and keeps keyboard flow stable across both file routes and component routes.
Plan + global requirements:
- The App Router Mindset
- Core Concepts (Best Practices)
- Components and Composition
- Extra (plan): Routing and Navigation
A railing is a repeatable routine: every transition produces the same focus schedule, the same announcement posture, and the same evidence trail. Consistency is the product.
The Railings Contract
We’ll implement a contract that’s intentionally route-first:
- Every transition emits a focus intent.
- The shell turns that intent into a focus schedule.
- The schedule is recorded into a ledger.
- The ledger is rendered so tests and audits can read it.
Worked Example: A Railed /library Shell
Tracks:
browsereadernotes
The shell uses the same railings routine for:
- file route arrivals (
/library?track=reader) - internal component navigation (buttons that switch tracks)
Step 1: Railings State (Ledger + Schedule)
import { useLiveState } from "react";
export function useRailingsLedger(namespace: string) {
return useLiveState(namespace, {
transitions: [],
focus: [],
announcements: [],
semantics: [],
});
}
export function useFocusSchedule(namespace: string) {
return useLiveState(namespace, {
target: "",
reason: "",
when: "after-render",
});
}
Step 2: The Railings Routine (One Function, Always Called)
export function applyRailings(ledger, schedule, transition: any) {
const target = `heading:${transition.track}`;
schedule.write("target", target);
schedule.write("reason", transition.reason ?? "track-change");
ledger.write("transitions", [...ledger.read("transitions"), { ...transition, at: Date.now() }]);
ledger.write("focus", [...ledger.read("focus"), { target, reason: schedule.read("reason"), at: Date.now() }]);
ledger.write("announcements", [
...ledger.read("announcements"),
{ message: `Navigated to ${transition.track}`, at: Date.now() },
]);
ledger.write("semantics", [
...ledger.read("semantics"),
{ landmark: "main", track: transition.track, at: Date.now() },
]);
}
Step 3: Tracks (Semantics + Evidence)
import { useLiveState, useRenderFetch } from "react";
export function Browse({ ui, ledger }) {
const data = useRenderFetch("/api/library/browse");
const prefs = useLiveState("library.browse.prefs", { density: "comfortable" });
return (
<main aria-label="Browse library" data-track="browse" data-density={prefs.read("density")}>
<h2 id="heading:browse">Browse</h2>
<button onClick={() => ui.write("track", "reader")}>Open Reader</button>
<pre data-source="render-fetch">{JSON.stringify(data, null, 2)}</pre>
<pre data-ledger>{JSON.stringify(ledger.read("announcements").slice(-2), null, 2)}</pre>
</main>
);
}
export function Reader({ ui, ledger }) {
const state = useLiveState("library.reader", { page: 1 });
return (
<main aria-label="Read document" data-track="reader" data-page={state.read("page")}>
<h2 id="heading:reader">Reader</h2>
<button onClick={() => state.write("page", state.read("page") + 1)}>Next Page</button>
<button onClick={() => ui.write("track", "notes")}>Notes</button>
<pre data-ledger>{JSON.stringify(ledger.read("focus").slice(-2), null, 2)}</pre>
</main>
);
}
export function Notes({ ui, ledger }) {
const notes = useLiveState("library.notes", { text: "" });
return (
<main aria-label="Notes" data-track="notes">
<h2 id="heading:notes">Notes</h2>
<textarea defaultValue={notes.read("text")} onBlur={(e) => notes.write("text", e.target.value)} />
<button onClick={() => ui.write("track", "browse")}>Back to Browse</button>
<pre data-ledger>{JSON.stringify(ledger.read("transitions").slice(-2), null, 2)}</pre>
</main>
);
}
Step 4: The Shell (File Route + Component Router + Railings)
import {
createComponentRouter,
useDevtools,
useLiveState,
useProfileSignal,
useRenderEffect,
useRouteMiddleware,
useRouteState,
} from "react";
import { useRailingsLedger, useFocusSchedule } from "./railings-state";
import { applyRailings } from "./apply-railings";
import { Browse, Reader, Notes } from "./tracks";
const tracks = createComponentRouter({
id: "library.tracks",
tracks: ["browse", "reader", "notes"],
});
const Views = { browse: Browse, reader: Reader, notes: Notes };
function normalizeTrack(value: unknown) {
if (value === "browse") return "browse";
if (value === "reader") return "reader";
if (value === "notes") return "notes";
return "browse";
}
export function LibraryRailingsShell() {
useDevtools("LibraryRailingsShell");
useProfileSignal("route.library.railings");
const route = useRouteState();
const ui = useLiveState("library.ui", { track: "browse" });
const ledger = useRailingsLedger("library.railings.ledger");
const schedule = useFocusSchedule("library.railings.focus");
useRouteMiddleware((ctx) => {
ui.write("track", normalizeTrack(route.query.read("track")));
});
useRenderEffect(() => {
const track = normalizeTrack(ui.read("track"));
tracks.setActive(track);
applyRailings(ledger, schedule, { track, reason: "grant" });
});
const track = normalizeTrack(ui.read("track"));
const View = Views[track] ?? Browse;
return (
<section data-route="file:/library" data-shell="railings" data-track={track} data-focus-target={schedule.read("target")}>
<header>
<h1>Library</h1>
<nav aria-label="Library tracks">
{tracks.tracks.map((t) => (
<button key={t} data-active={String(t === track)} onClick={() => ui.write("track", t)}>
{t}
</button>
))}
</nav>
<div aria-live="polite" data-announcement-count={ledger.read("announcements").length} />
</header>
<View ui={ui} ledger={ledger} />
<footer>
<strong>Railings Ledger</strong>
<pre>{JSON.stringify(ledger.read(), null, 2)}</pre>
</footer>
</section>
);
}
Railings Checklist
- Treat focus as a transition artifact.
- Record focus schedule and announcements into a ledger.
- Keep semantics visible (aria labels, landmarks, headings).
- Apply the same routine to file-route and component-route transitions.