Skip to content

Article

Not an overlay: building an honest accessibility panel

Why one-line accessibility widgets fail, and what a preferences panel should do instead — built by a colour-blind designer who codes.

There’s a small button in the bottom-left corner of this site that says Accessibility. Open it and you get a panel of display settings: text size, contrast, motion, spacing, a simpler font, underlined links, a stronger focus ring, and an “Easy reading” mode that turns the whole page into a calm, single column.

I almost didn’t build it. Most accessibility widgets make me uneasy — and as a colour-blind designer, I’m supposed to be their target audience. This post is about why, and about the version I ended up building instead: a panel that doesn’t pretend to be something it isn’t.

The trouble with overlays

You’ve probably seen the pitch: add one line of JavaScript and your website becomes accessible and compliant, automatically. These “overlays” inject a toolbar, try to repair the page on the fly, and often add a badge promising compliance.

Disabled users and accessibility practitioners have objected to them for years. The automatic fixes are guesswork, they can clash with the screen readers and settings people already rely on, and the promise lets site owners skip the real work. In 2025 the US Federal Trade Commission required accessiBe to pay US$1 million over claims that its widget could make any website compliant (Adrian Roselli has a good write-up).

The problem isn’t that a panel exists. It’s what the panel claims to be. A widget on an inaccessible site is lipstick.

The principle: accessible first, preferences on top

So I set three rules before writing any code:

  1. The site must work without the panel. Semantic HTML, keyboard support, documented contrast, reduced-motion paths. The panel is never the fix.
  2. The visitor’s own settings win. Many people have already told their system what they need: zoom, reduced motion, increased contrast, forced colours. By default, the site follows those. The panel only lets you override them for this site.
  3. Nothing leaves the device. Settings are stored in the browser and never sent anywhere — not even to analytics.

The panel’s first line says it plainly: settings for this site, saved on this device, and your system settings are respected either way.

The audit came first

Before building a single control, I went through my own site as if it were a client’s. It was humbling.

  • Motion was everywhere. Pinned scroll galleries, parallax, a WebGL light effect, glass letters that warp, a blinking clock, typing dots in the contact chat. The reduced-motion checks existed, but they were scattered across dozens of files, each reading the system setting in its own way.
  • Icons vanished in forced colours. I draw most icons as CSS masks painted with background-color. Windows High Contrast replaces background colours, so every one of those icons simply disappeared.
  • Type sizes were hard-coded in places. You can’t offer a text-size setting when a dozen components ignore the type scale.
  • My accent font is decorative. Cropaso is wide, all caps and has stylised digits. It’s part of the brand, but it is not easy to read in long stretches, and its half-filled zero confused people in small labels.

Fixing those came first: a mixin for forced-colour icons, every font size moved onto tokens, and numbers in small labels set in Syne instead of Cropaso. Only then did the panel make sense.

What the panel does

Every control that maps to a system preference is tri-state — Auto follows your device, the other options override it.

  • Text size: 100, 115, 130, 150%. Type only, not spacing or layout. Body text scales fully; big display type scales by a third as much, so headlines grow without breaking the page.
  • Contrast: Auto, More, Standard. More turns muted text fully white, strengthens hairlines, replaces glass and blur with solid surfaces, and removes the background photo and decorative effects.
  • Motion: Auto, Reduce, Full. Reduce stops everything the audit found — including the WebGL light and the pinned galleries, which fall back to the static layouts phones already use. It switches live, without a reload.
  • Comfortable spacing. The text-spacing values from WCAG 1.4.12: line height 1.5, wider letter and word spacing, more room between paragraphs.
  • Simpler font. Swaps Syne and Cropaso for Atkinson Hyperlegible Next, designed by the Braille Institute for legibility. It only downloads when you switch it on.
  • Underline links. Links found by shape, not colour — the setting I’d want myself.
  • Stronger focus. A thicker ring with a dark halo on every focusable element.
  • Easy reading. A preset that switches on the calmer settings above and restacks the page into one narrow, left-aligned column: no decorative images, no effects, no carousels, every accordion open. Close to reading plain, static HTML. Turn it off and everything goes back.

And a Reset button, next to a link to the accessibility statement.

What I left out, on purpose

  • Colour filters and inversion. They wreck photos, and the operating system does them better.
  • A “dyslexia font”. The evidence that fonts like OpenDyslexic help is weak. I offer a simpler font as a legibility preference and make no claims beyond that.
  • Text-to-speech, reading rulers, custom cursors. Assistive technology and the OS already do these, and do them better than a website can.
  • Compliance badges. For the reasons above.

Designing the panel itself

A settings panel that isn’t accessible would be a special kind of irony, so it follows its own rules:

  • Solid background, not glass. Text in the panel is white on #141414 (18.4:1); secondary text is 13:1; control borders are 5.5:1 — above the 3:1 WCAG asks of interface components.
  • Set in the legible font, whatever the page is using.
  • Real form controls. The multi-option settings are radio groups in a fieldset with a legend, so arrow keys and screen readers work natively. The on/off settings are buttons with role="switch".
  • Never colour alone. The chosen option fills solid, turns bold and gains a tick. Every switch shows the word On or Off next to its track. I can’t tell some colours apart, so I design states I can read by shape and words.
  • Large targets. Every option is at least 44px tall. On phones, the panel becomes a bottom sheet.

How it’s built

No framework, no dependencies. A small preferences module, a few Sass mixins and a lot of deleting.

One attribute per preference

Each setting is mirrored as a data attribute on <html>. Neutral values (“Auto”, “Off”, 100%) remove the attribute, so the system settings apply:

<html data-text-scale="1.3" data-contrast="more" data-motion="reduce" data-links="underline">

No flash on load

If the settings were applied by the main JavaScript bundle, you’d see the page flash in the wrong size and colours first. A tiny inline script in the <head> reads storage and sets the attributes before the first paint:

<script>
  try {
    var a = JSON.parse(localStorage.getItem('a11y') || '{}'), r = document.documentElement, k;
    for (k in a) if (/^[a-z-]+$/.test(k) && ['auto', 'off', '1'].indexOf(a[k]) < 0) r.setAttribute('data-' + k, a[k]);
  } catch (e) {}
</script>

With storage blocked, it fails silently and the site simply follows the system.

Text size is a token

Every font size in the design system multiplies by one of two custom properties:

:root {
  --text-scale: 1;
  --display-scale: calc(1 + (var(--text-scale) - 1) / 3);

  --text-body: calc(16px * var(--text-scale));
  --text-title: calc(48px * var(--display-scale));
}

:root[data-text-scale='1.3'] {
  --text-scale: 1.3;
}

At 130%, body text grows by 30% and display titles by 10%. That one decision is what lets the page reach 150% on a 320px screen without anything overflowing.

One rule, two triggers

The system preference and the panel’s choice have to behave as one. A mixin combines them, so each component writes its reduced-motion rule once:

@mixin reduced-motion {
  @media (prefers-reduced-motion: reduce) {
    :root:not([data-motion='full']) & {
      @content;
    }
  }

  :root[data-motion='reduce'] & {
    @content;
  }
}

The same pattern covers contrast. Replacing the scattered checks with these mixins was the bulk of the work — and it made the code smaller.

A MediaQueryList that knows about the panel

The JavaScript side had the same problem: fourteen components asked matchMedia('(prefers-reduced-motion: reduce)') directly. They now import one object that looks like a MediaQueryList — matches plus change events — but merges the system query with the stored choice:

export const reducedMotion = {
  get matches() {
    return state.motion === 'reduce' || (state.motion === 'auto' && system.matches);
  },
  addEventListener: (type, listener) => type === 'change' && motionListeners.add(listener),
  removeEventListener: (type, listener) => type === 'change' && motionListeners.delete(listener),
};

Because the components already listened for change, switching motion in the panel unpins the galleries and stops the light effect live, with no other changes.

Icons that survive forced colours

For mask icons, one mixin opts them out of forced-colour adjustment and paints them in the system text colour:

@mixin forced-icon {
  @media (forced-colors: active) {
    forced-color-adjust: none;
    background-color: CanvasText;
  }
}

Privacy: settings are not analytics

I use privacy-friendly analytics on this site, and it would be easy to count how many people turn on bigger text or reduced motion. I don’t, and I won’t.

A preference can imply a disability. Under GDPR in Europe and the LGPD in Brazil, health information is sensitive personal data. More importantly, nobody opening a settings panel expects to be counted for it. The settings live in the browser, and that’s where they stay.

How it was checked

So far:

  • text at 150% on desktop and phone widths, down to 320px, with no horizontal scrolling;
  • reduced motion and Windows forced colours emulated in Chromium, and each animation from the audit checked one by one;
  • settings persisting across pages and reloads, with no flash on load;
  • automated browser tests for the panel, the menus and the contact chat.

A full keyboard and screen reader review — NVDA, VoiceOver and TalkBack — is still under way. The accessibility statement says so, and it will be updated with the results. Honesty about what hasn’t been tested yet is part of the point.

A checklist for your own site

If you’re thinking about a panel, here’s the order I’d work in:

  1. Audit first. Keyboard, motion, contrast, forced colours, zoom, text spacing. Fix what you find.
  2. Respect system settings by default. prefers-reduced-motion, prefers-contrast and forced-colors before any toggle.
  3. Put type on tokens. A text-size setting only works if every size multiplies by the same variable.
  4. Write each preference once. A mixin in CSS, one shared object in JavaScript.
  5. Apply settings before first paint. A small inline script, wrapped in try/catch.
  6. Never rely on colour for state. Ticks, weight and words.
  7. Keep it on the device. No analytics on settings.
  8. Claim nothing you haven’t tested. Publish a statement with real limitations and a way to report problems.

An accessibility panel is a courtesy, not a cure. The real work is everything the visitor never has to switch on.

If you find something on this site that doesn’t work for you, I’d like to know — the accessibility statement says how to reach me, and accessibility issues go to the top of my list.

Published

Reactions0

Share

Comments 0

  1. Nothing here yet — start the conversation.

Who’s it from?

Pick a face

Comments are read before they appear; your email is only there if I need to reply privately.

Keep reading