This site has a full WebGL cosmic background. Flying word animations. SPA navigation with view transitions. A chat widget with an AI that knows things about me. It’s aggressively visual. The kind of site that makes accessibility auditors reach for their red pens before they’ve even scrolled.

And it scores fine. Screen readers navigate it. Keyboard-only users can reach everything. People with vestibular disorders don’t get sick. Phones from 2019 don’t catch fire.

This isn’t because I bolted accessibility on at the end. It’s because I made one architectural decision at the very beginning that made everything else possible: the visual effects are decoration. The HTML is the site.


The Tension

Here’s the problem. You want a site that makes people go “what the hell, how did you build this.” You want the cosmic dust, the particle effects, the smooth transitions that make static sites feel alive. You want to show off, because your portfolio site exists to show what you can build.

But some of your visitors can’t see. Some get migraines from motion. Some are browsing on a phone that cost less than your lunch. Some are on a train going through a tunnel with 200ms latency and half a bar of signal. And a site that works for you on your M4 Max with a 120Hz display and a fat ethernet pipe is not the same site those people experience.

The usual advice is “just don’t build anything fancy.” Which is like telling a chef to stop using heat because some people have sensitive teeth. The answer isn’t to make everything bland. It’s to make the fancy parts optional.


Progressive Enhancement, Not Graceful Degradation

There’s a difference, and it matters.

Graceful degradation says: build the full experience, then figure out what to do when things break. You start with the WebGL canvas, the animations, the whole show, and then you write fallbacks for when the GPU can’t handle it or JavaScript fails to load. It’s damage control.

Progressive enhancement says: build the base experience first, then layer enhancements on top for browsers that support them. The HTML is semantic and complete. The CSS makes it look good. The JavaScript makes it feel alive. Each layer is optional. Remove any one of them and you still have a functional site.

My site is built this way. If you disable JavaScript entirely, you get a static site with a CSS gradient background, fully readable content, working navigation, and no WebGL anything. It’s not exciting, but it works. Every blog post is readable. Every link goes somewhere. The semantic HTML does its job without help.

The WebGL canvas sits on top of this as a <canvas> element with aria-hidden="true". Screen readers don’t see it. They don’t need to. It’s a visual decoration, like a background image that happens to move. The content layer underneath is where the actual information lives, in <article> tags and <h1>s and <nav> elements that any assistive technology knows how to parse.

This isn’t a fallback. This is the foundation. The 3D effects are the fallback’s opposite: they’re the bonus content for browsers that can handle them.


Respecting Motion Preferences

The prefers-reduced-motion media query exists because motion on screens can cause real physical symptoms. Vestibular disorders, seizure conditions, migraines; these aren’t preferences, they’re medical realities. When someone sets “Reduce motion” in their OS settings, they’re not saying “I think animations are ugly.” They’re saying “animations make me feel sick.”

When my site detects prefers-reduced-motion: reduce, here’s what happens:

  • The WebGL canvas is hidden entirely. Not paused, not slowed down; hidden. A static CSS gradient takes its place.
  • All CSS transitions and animations are disabled or reduced to simple opacity fades.
  • The flying word animations stop. The words are still there in the DOM if they carry semantic meaning, but they don’t move.
  • Page transitions become instant cuts instead of animated swoops.

The implementation is straightforward. CSS handles most of it:

@media (prefers-reduced-motion: reduce) {
  #webgl-canvas {
    display: none;
  }

  .cosmic-bg-fallback {
    display: block;
  }

  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
}

The JavaScript side checks window.matchMedia('(prefers-reduced-motion: reduce)') before initializing any animations and listens for changes, because people can toggle the setting while your page is open. If someone turns on reduced motion mid-session, the effects stop. Immediately. Not “after the current animation finishes.” Now.

I set the duration to 0.01ms instead of 0s because some browsers treat zero-duration animations differently with respect to animationend events. A near-zero duration fires the events correctly without any visible motion. Fun edge case, right?


Potato Mode 😅

Not everyone who needs reduced effects has a system setting for it. Some people are just on bad hardware. And a site that tanks their browser to 8 frames per second isn’t accessible, even if it technically works.

My site runs an FPS monitor in the background. It samples requestAnimationFrame timing over a rolling window, and if the frame rate drops below a threshold consistently (not a single stutter, but sustained poor performance) it triggers what I affectionately call potato mode.

Potato mode pops a small, non-intrusive prompt: “Your device seems to be struggling. Want to disable visual effects?” If you say yes, it does the same thing as prefers-reduced-motion: kills the WebGL canvas, stops animations, switches to the static fallback. If you say no, it backs off and doesn’t ask again for that session. Consent! Even for performance bailouts!

This matters because performance IS accessibility. A site that makes a $100 Android phone’s browser unresponsive is just as inaccessible as a site without alt text. The user can’t get to the content. That’s the whole definition of inaccessible.

The detection has to be careful, though. You don’t want to trigger on a momentary dip because the user switched tabs and the browser deprioritized your page. You want sustained poor performance under normal conditions. The rolling window approach handles this: it needs several seconds of consistently bad frames before it concludes the device genuinely can’t keep up.


Keyboard Navigation

Every interactive element on this site is reachable with a keyboard. Tab moves focus forward. Shift+Tab moves it back. Enter activates things. Escape closes things. This sounds obvious, and it should be, but it’s surprisingly easy to break when you’re building custom components.

The SPA navigation is the tricky part. When you click a link and the content swaps without a page reload, the browser doesn’t do its normal focus management. It doesn’t move focus to the top of the new page. It doesn’t announce the new page title. If you’re a sighted user, you see the content change and your eyes move to it. If you’re a keyboard user or screen reader user, your focus is still sitting on the link you just clicked, which might not even exist in the DOM anymore. Spooky.

After every navigation, my router explicitly moves focus to the main content area. There’s a skip link at the top of every page (visually hidden but keyboard-accessible) that jumps past the navigation to the content. Focus goes to the <main> element, which has tabindex="-1" so it can receive programmatic focus without being part of the normal tab order.

Browser back and forward buttons work correctly because the SPA uses the History API. Pressing back doesn’t just change the URL and leave you staring at the wrong content. It loads the previous page’s content and manages focus the same way a forward navigation does.


Screen Readers

The canvas is aria-hidden="true". The decorative elements that exist only for visual effect are hidden from the accessibility tree. What remains is clean semantic HTML that reads exactly how you’d expect.

ARIA live regions handle the dynamic parts. When navigation occurs, a visually hidden <div role="status" aria-live="polite"> gets updated with the new page title. Screen readers announce it: “Navigated to: Accessibility Isn’t a Checkbox.” The user knows where they are without seeing anything change on screen.

The content structure matters here. Every page has a single <h1>. Sections use <h2> through <h4> in proper hierarchy. Navigation is inside <nav>. The main content is inside <main>. The footer is inside <footer>. This isn’t clever. It’s just correct HTML. But “just correct HTML” is apparently a high bar, because most of the web doesn’t clear it. 😅


The Chat Widget

The chat widget was the hardest accessibility challenge because it’s a modal with dynamic content that updates asynchronously. That’s a lot of things that can go wrong for assistive technology. Ask me how I know.

When the chat opens, focus is trapped inside it. Tab cycles through the input field, the send button, the close button, and back to the input. You can’t accidentally tab out into the page behind the modal, because the page behind the modal is inert (marked with aria-hidden="true" on the main content wrapper while the chat is open).

When the AI sends a response, an ARIA live region announces it. The screen reader says the response text. You don’t have to hunt for it in the DOM or wonder if something changed. The response comes to you.

The close button is labeled. The input has a label. The model selector has a label. Everything has a label. This is not exciting engineering work. It is filling out the form fields correctly, and it takes five minutes, and most developers skip it because they can see the UI and it “looks obvious.” It’s not obvious if you can’t see it!!

Escape closes the chat. Focus returns to the element that opened it. This is standard modal behavior that WAI-ARIA authoring practices have documented for years, and it’s still rare in the wild. 🙃


How I Test This

I don’t trust automated tools to tell me a site is accessible. They catch maybe 30% of issues. The rest requires a human.

Keyboard-only navigation: I unplug my mouse and use the site. Can I reach everything? Can I tell where focus is? Does focus order make sense? Do I get trapped anywhere? This catches more issues than any automated scanner. Seriously, try it on your own site. I dare you.

VoiceOver on macOS: I turn on VoiceOver and navigate the site with my eyes closed. Does the reading order make sense? Are the announcements helpful or noise? Do the live regions fire at the right time? VoiceOver is unforgiving: if your HTML is sloppy, it reads sloppy.

CPU throttling: Chrome DevTools lets you throttle CPU to 4x or 6x slowdown. This simulates a low-end phone. I watch the FPS counter, make sure potato mode triggers, and verify the fallback experience is actually usable. Not just “technically functional” but genuinely usable.

Real devices: I test on an old Android phone that I keep charged specifically for this purpose. It lives in a drawer. It has one job. Emulation is useful, but nothing replaces a real underpowered device with a real slow GPU rendering your real WebGL scene. If it works on that phone, it works everywhere.


The Architecture Decision

None of this is hard individually. Semantic HTML, ARIA attributes, focus management, media queries, performance monitoring; each piece is well-documented and straightforward to implement.

What’s hard is doing it retroactively. If you build a site where the WebGL canvas IS the site; where your content is rendered inside the 3D scene, where navigation is a camera movement, where the text is a texture on a plane; then making it accessible means rebuilding from scratch. You can’t add semantic HTML under a Three.js scene that IS the page. The architecture doesn’t support it. You’re cooked. 🍳

The decision that makes everything work is the one I made before I wrote any WebGL code: the HTML is the site. The 3D effects are progressive enhancement. Remove them and you still have everything that matters.

Accessibility isn’t a feature you add. It’s a constraint you design around. And like most good constraints, it makes the architecture better. The separation between content and decoration that makes my site accessible is the same separation that makes it fast, resilient, and maintainable. The accessible version isn’t a lesser version. It’s the foundation the fancy version is built on.