Adding accessibility controls to a Phoenix LiveView app

Luca ·

Accessibility is one of those words that gets thrown around so much that it loses meaning. Before getting into the implementation, it’s worth stopping for a moment and defining what the term actually covers — because if you don’t know what you’re solving, the solution looks arbitrary.

What accessibility means, briefly

Web accessibility is the practice of building websites that everyone can use — including the people who interact with the web differently than the assumed default of “sighted, mouse-using, keyboard-fluent, English-speaking, no cognitive load, perfect colour vision, no motion sensitivity.” That default is a fiction. Roughly one in six people lives with some form of disability, and many more move in and out of that category temporarily — a sprained wrist, an eye infection, a migraine, a moving train.

The Web Content Accessibility Guidelines (WCAG, currently version 2.2) codify what “accessible” means in concrete terms. The four principles are easy to remember as POUR:

  • Perceivable — users can perceive the content. Images need alt text. Colour can’t be the only signal. Text must have sufficient contrast. Audio/video needs captions.
  • Operable — users can operate the controls. Everything must work via keyboard, not just mouse. Animations must be pausable. Time limits must be adjustable.
  • Understandable — users can understand both the content and how the interface works. Language is declared. Errors are explained, not just flagged. Behaviour is predictable.
  • Robust — content keeps working across assistive technologies (screen readers, switch controls, voice input) and degrades gracefully.

In practice this translates into hundreds of concrete success criteria, ranked at three conformance levels (A, AA, AAA). The realistic target for most products is AA — the level required by most legislation (the European Accessibility Act, the US ADA Section 508, etc.).

But there’s a gap between conforming to WCAG at the markup level and being usable for someone whose specific needs the page doesn’t naturally accommodate. That’s where an in-page accessibility widget comes in: it doesn’t replace good base markup, but it gives the user agency to adapt the page to their device, their lighting, their eyesight, their attention budget on a given day.

That’s what this post is about — building one such widget in Phoenix LiveView.


What I built

A floating button at the bottom-left of every page (FAB — floating action button) that opens a slide-up panel with three sections:

  • Text — Larger text (4 levels: 100/115/130/150 %), Line height (3 levels), Text alignment (default/justify), Readable font (toggle).
  • Visual — Contrast (3 levels), Grayscale, Hide images, Pause animations.
  • Orientation — Highlight links, Page structure (a modal listing every <h1>–<h4> and every ARIA landmark on the current page; clicking jumps to it).

Plus two footer actions — Hide (dismiss the FAB for this session) and Reset (clear every saved setting). Plus an Alt+A keyboard shortcut that opens the panel from anywhere on the page, including when the FAB is hidden.

The widget lives on every page of the site — the authenticated app, the public landing page, the blog, the admin backoffice. It works for logged-in users and anonymous visitors alike. It doesn’t require a database column, a migration, or a server round-trip.


The architecture

The whole thing is two Phoenix function components in a single module:

SlashFeedWeb.Accessibility.init_script/1    # rendered inside <head>
SlashFeedWeb.Accessibility.widget/1         # rendered at end of <body>

The first is a tiny inline <script> in <head> that applies any saved preferences to <html> before the page paints. The second renders the FAB, the panel, and the page-structure modal, with a colocated <script> that wires up the interactivity.

The actual styling is driven entirely by data-a11y-* attributes on the <html> element. The CSS attribute-targets those values:

html[data-a11y-text-size="2"] { font-size: 18px; }

html[data-a11y-grayscale="1"] body { filter: grayscale(1); }

html[data-a11y-pause-animations="1"] *,
html[data-a11y-pause-animations="1"] *::before,
html[data-a11y-pause-animations="1"] *::after {
  animation-duration: 0.01ms !important;
  transition-duration: 0.01ms !important;
  scroll-behavior: auto !important;
}

Three deliberate properties of this design:

  1. No JavaScript runs during initial paint. The CSS sees the attribute and applies the override immediately. The page renders once, in the user’s chosen configuration. No FOUC.
  2. No server round-trip. Preferences live in localStorage under slashfeed:a11y. The server doesn’t know or care what each user has chosen — every page is rendered identically, with the personalisation happening client-side.
  3. Predictable CSS specificity. Every override is html[data-a11y-*] selector, which beats most utility classes but stays overridable by !important where really necessary (animation pausing).

That third point matters more than it sounds. The temptation when building a “theme system” is to scatter conditional classes throughout the markup. Don’t. Putting the state on one root element and letting CSS cascade it is dramatically easier to reason about, easier to test, and cheaper to render.


Phoenix-specific: where to mount this

Phoenix 1.8 has three independent root layouts in SlashFeed:

  • Layouts — the authenticated user app (sidebar + content)
  • PublicLayouts — the landing page, blog, login pages
  • AdminLayouts — the backoffice

Every page goes through exactly one of these. To make the widget truly site-wide, both function components have to be rendered from all three root templates:

<head>
  <%!-- … --%>
  <link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
  <%!-- Accessibility init must run BEFORE first paint to avoid FOUC --%>
  <SlashFeedWeb.Accessibility.init_script />
  <script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}>
  </script>
  <%!-- … --%>
</head>
<body>
  {@inner_content}
  <SlashFeedWeb.Accessibility.widget />
</body>

That’s it. No live_session to update, no on_mount hook, no per-LiveView wiring. Because the widget is pure server-rendered HTML + inline JS, it works in every LiveView and every non-LiveView controller view alike. The widget itself never re-renders — it just sits there, listens for clicks, and updates <html> via DOM mutations.

This is a deliberate choice. The first version I sketched used a LiveView with push_events and round-tripped each toggle through a handle_event. That had three problems:

  1. Latency — every toggle waited for the WebSocket round-trip.
  2. Coupling — the widget needed a live socket to function, which means the public landing page (which sometimes doesn’t bother connecting LiveView) couldn’t use it.
  3. Complexity — three layers of state (server, socket, DOM) that all had to stay synchronised.

Throwing all that out and making it pure client-side made the implementation about a third the size and dramatically easier to test.


The settings, and why each one matters

Each setting maps to a specific class of WCAG success criterion. I’m listing them here not to lecture but because picking what to expose is the hardest design decision in this whole feature — it’s easy to build a widget with 30 toggles and end up with something nobody can navigate.

  • Larger text (100/115/130/150 %) — WCAG 1.4.4 Resize Text. The page must be usable at 200 % zoom. We scale via font-size on <html> so every rem unit cascades automatically. The four levels cover the realistic range without needing a continuous slider.
  • Line height (normal/1.6/2.0) — WCAG 1.4.12 Text Spacing. Scoped to body and prose so it doesn’t break tight UI elements (badges, buttons, segmented controls).
  • Text alignment (default/justify) — only inside long-form prose (.reader-prose, .blog-prose, <main> p, <article> p). Never align navigation, buttons, badges, or table cells.
  • Readable font — swaps the default monospace for a system sans-serif stack with a slight letter-spacing bump. Monospace stays for code blocks. The dyslexia-research literature on dedicated fonts (OpenDyslexic, Atkinson Hyperlegible) is mixed, but a clean sans-serif with relaxed kerning helps a meaningful slice of users — and bundling no extra web fonts keeps the cold-load budget honest.
  • Contrast (high / max) — WCAG 1.4.6 Contrast (Enhanced). Level 1 boosts ink/paper contrast and forces underlines on links. Level 2 is pure-black-on-pure-white (or inverted in dark mode) with link underlines and stronger font weight.
  • Grayscale — full-viewport filter: grayscale(1). The widget itself is excluded so the orange FAB stays recognisable.
  • Hide imagesdisplay: none on img, picture, video, iframe. Inline SVG icons are deliberately kept because they convey UI affordances, not content.
  • Pause animations — WCAG 2.2.2 / 2.3.3 Pause, Stop, Hide & Animation from Interactions. Globally sets animation-duration: 0.01ms and transition-duration: 0.01ms. Also: the prefers-reduced-motion: reduce media query is honoured project-wide as a baseline, so OS-level “Reduce Motion” works automatically even without the toggle.
  • Highlight links — WCAG 1.4.1 Use of Colour. Adds an underline plus dashed outline to every <a> (excluding buttons and the FAB). Important because the default link colour can be hard to distinguish for some forms of colour blindness.
  • Page structure — opens a modal listing every <h1>–<h4> and every ARIA landmark on the page. Clicking an item smooth-scrolls to it. This is a poor person’s version of what screen readers expose natively (the rotor on VoiceOver, the heading list on NVDA), made available to sighted users who’d benefit from a quick page outline.

The interesting JavaScript bits

Most of the JS is uninteresting — wire up clicks, write to localStorage, set attributes, done. Three pieces are worth showing.

Restore before first paint

The init script in <head> runs synchronously, before the page starts painting. It’s tiny on purpose — only try/catch-wraps a single JSON.parse and then sets attributes:

(() => {
  try {
    const raw = localStorage.getItem("slashfeed:a11y");
    if (!raw) return;
    const s = JSON.parse(raw);
    const root = document.documentElement;
    const set = (k, v) => { if (v && v !== "0") root.setAttribute("data-a11y-" + k, v); };
    set("text-size", s["text-size"]);
    set("line-height", s["line-height"]);
    set("text-align", s["text-align"]);
    set("readable-font", s["readable-font"]);
    set("contrast", s["contrast"]);
    set("grayscale", s["grayscale"]);
    set("hide-images", s["hide-images"]);
    set("pause-animations", s["pause-animations"]);
    set("highlight-links", s["highlight-links"]);
  } catch (e) { /* corrupted localStorage is non-fatal */ }
})();

The try/catch matters. localStorage is a surprising number of things — it’s sometimes disabled (private browsing, locked-down environments), the JSON can be corrupted by a stale schema, the value can be quota-exceeded. Any of those throws, and if you don’t catch, the rest of the page’s CSS doesn’t render. So the whole init is wrapped in a fail-quiet block: if anything goes wrong, the user sees the default styling, which is still perfectly usable.

The Alt+A escape hatch

The “Hide” footer button used to persist fab-hidden: "1" to localStorage — meaning once you hid the FAB, it was gone forever until you cleared site data. That’s a real bug. The fix has two parts:

  1. Hide is session-scoped. A plain JS variable, not localStorage. Reload = FAB returns.
  2. Alt+A toggles the panel from anywhere. A global keydown handler that, importantly, also un-hides the FAB if it had been hidden during this session.
document.addEventListener("keydown", (e) => {
  if (e.key === "Escape" && !panel.classList.contains("hidden")) {
    closePanel();
    return;
  }
  if (e.altKey && (e.key === "a" || e.key === "A")) {
    e.preventDefault();
    if (fabHiddenForSession) showFab();
    if (panel.classList.contains("hidden")) openPanel();
    else closePanel();
  }
});

The principle: the user can never lose access to their accessibility controls. There are always two recovery paths — a page reload, or a keypress. That’s not paranoia, it’s a basic property: any feature that exists to help disabled users must be impossible for non-disabled users to accidentally take away from them.

Page structure walker

The “Page structure” modal scans the document for headings and landmarks at the moment it’s opened. The trick is filtering out the widget’s own DOM:

const skip = (el) => el.closest("#a11y-panel,#a11y-fab,#a11y-structure,#a11y-backdrop");

const headings = Array.from(document.querySelectorAll("h1, h2, h3, h4"))
  .filter((h) => !skip(h) && h.textContent.trim());

const landmarks = Array.from(
  document.querySelectorAll(
    "main, nav, aside, header, footer, [role='main'], [role='navigation']"
  )
).filter((l) => !skip(l));

Without that skip filter, the widget’s own <h2 id="a11y-panel-title">Accessibility</h2> would show up in every page’s outline — which is both noisy and misleading. The lesson generalises: any DOM-walking utility that runs from inside the same DOM it’s walking should explicitly exclude itself.


Testing

The widget has 19 tests, split across three layers:

  1. Component-level unit tests — render Accessibility.init_script/1 and Accessibility.widget/1 via Phoenix.LiveViewTest.rendered_to_string/1 and assert the resulting HTML contains every required id, ARIA attribute, and setting label. These catch regressions where someone removes a feature without realising it.
  2. Regression tests — explicit refute assertions against the broken prior state: the FAB must not be at right-4, the panel must not be at left-1/2 -translate-x-1/2, the init script must not call setAttribute("data-a11y-fab-hidden"). These force-encode the bugs I’ve fixed so they can’t quietly come back.
  3. Integration tests — fetch a real route from each root layout (/, /admin/login, /users/log-in) via ConnCase and assert the FAB markup is present and the init script is in <head>. These guarantee the widget reaches every layout.

I didn’t write Wallaby browser tests for this. The interesting behaviours (localStorage round-trip, keyboard shortcuts, click-on-FAB-opens-panel) are exactly the kind of thing the project’s Wallaby setup struggles with — the WebSocket-disabled mode that Wallaby uses doesn’t reliably surface phx-click events, and localStorage assertions across page loads are fragile. The unit + integration coverage already pins everything that matters.


What I deliberately didn’t build

A few things I considered and rejected. They’re worth listing because they would all sound reasonable in a feature spec — the value of a design doc is partly the road not taken.

A server-side “accessibility profile” stored per-user in the database. Settings are cosmetic and per-device. A user might want high-contrast on their work laptop but not on their phone. localStorage is the right granularity. If real users ever ask for cross-device sync, the path is a users.accessibility_preferences JSONB column hydrated from the widget via push_event — easy to add later, premature to add now.

A bundled dyslexia font (OpenDyslexic, Atkinson Hyperlegible). ~80 KB of woff2 across the cold-load budget for a feature most users won’t enable. The current “Readable font” toggle uses a tuned system sans-serif with letter-spacing, which handles the same use case at zero extra payload. If someone has a specific request, the swap is one line of CSS.

A “reading guide” / focus ruler. The line-following horizontal bar some readers offer. Real-world A/B data on these is weak, and they actively interfere with text selection and copy/paste. Adding it later is another data attribute + another CSS rule; nothing about the architecture would have to change.

A per-page accessibility audit. Out of scope for a user-facing widget. The right tool is Lighthouse or axe-core running in CI, which is separate work and lives at the development layer, not the runtime layer.


What I’d do differently if I started over

Build the data-a11y-* attribute scheme first, the UI second. I wrote the panel HTML before I’d fully thought through the CSS data model, and ended up rewriting both. The clean approach is: define the contract between CSS and JS (what attributes exist, what values they take, what each one does) before drawing a single button.

Wire up the keyboard shortcut earlier. I added Alt+A after the user reported that “Hide” was irrecoverable. With hindsight, the keyboard shortcut should have been in the first commit — it’s the kind of accessibility-of-the-accessibility-widget consideration that’s easy to forget if you’re focused on the visual design.

Be more aggressive about scoping. I had to walk back the line-height override after it broke segmented controls and badges. The first version targeted body; the second is scoped specifically to body text and prose. A useful rule when adding global style overrides: scope as narrowly as you can, and widen only when concrete need shows up. The opposite direction is much harder.


A widget like this is not a substitute for good base markup. The page still needs proper headings, alt text, sufficient default contrast, keyboard reachability, ARIA where ARIA helps and silence where it doesn’t. The widget complements those — it lets the user push the page further than the default in whichever specific direction they need. The two together do a lot more than either alone.

The full implementation is open in lib/slashfeed_web/components/accessibility.ex, the CSS is in assets/css/app.css, and the design doc with the full settings reference, ARIA notes, and extension guide is at docs/accessibility.md.

SlashFeed is a self-hosted RSS reader I’m building in Elixir/Phoenix. Find the previous posts in this build-log on the blog index.