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:

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:

  1. Every transition emits a focus intent.
  2. The shell turns that intent into a focus schedule.
  3. The schedule is recorded into a ledger.
  4. The ledger is rendered so tests and audits can read it.

Worked Example: A Railed /library Shell

Tracks:

  • browse
  • reader
  • notes

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.