Making a note editor accessible is harder than it looks. A lot harder.
Most accessibility advice for web apps assumes a form, a list, and a few buttons. Give everything a name, keep contrast honest, trap focus in the dialog, and you can check that box and move on. A note editor is not that kind of app.
A note editor is a seemingly simple app on the surface: a few filters, search, a list of notes, and an editor to edit them in. In reality though, it's a very complex system of things that both need to work together, but also work in the way users expect them to work.
We recently put Snownotes through a proper accessibility audit and then spent a stretch of days closing what it found.
This is not a victory post (accessibility in our view is something you strive for rather than achieve, you can always do better) and it is not a WCAG scorecard. It is a write-up of the places a rich editor fights the browser's defaults, and of the decisions we made when the "correct" fix would have made the product worse for someone else.
What we were actually checking for
The audit was organised by severity, and severity meant consequence for a person, not how many criteria a finding could be tagged with.
A blocker was a task that simply could not be done by that group of users. No workaround inside the app. A serious finding meant the task was possible but grindingly hard, or a major feature was out of reach. Moderate meant real friction. Rule violations that only mattered on paper were dropped.
That framing matters because it changes which work you do first, and which "fixes" you refuse. We ended up with eighty-five findings across the editor, the app shell, dialogs and menus, the landing and auth flows, mobile, and global CSS. The interesting part was rarely the missing aria-label. It was the places where two good accessibility goals collided, or where a well-meant role made the document less navigable than silence.
The surface is not a text box
The first instinct with a contenteditable editor is to tell assistive technology what it is. Pin role="textbox" on the surface and screen readers treat it like a form field. That sounds helpful until you notice what you lost.
A rich note has headings, lists, tables, and nested structure. Screen reader users navigate those the way sighted users scan the page: jump by heading, move through list items, land on a table cell. Flatten the surface into a single text box and that structure disappears. The document becomes a long string with a caret.
So we do not put that role on the editor, and we have a test that fails if someone adds one later. The editable region still needs a name and a description of how to leave it. What it must not do is pretend to be a single-line field.
This pattern shows up again in smaller places. Slash menus and suggestion UIs often want aria-activedescendant, which expects a combobox or textbox role on the focused element. We already decided the editor cannot carry that role. The workable answer was a live announcement for the active item, rather than a role that would quietly undo the document navigation we had just protected.
Tab wants two jobs
In a notes app, Tab indents. Sighted keyboard users rely on it. The toolbar's Indent button, ], and Tab in a plain paragraph are supposed to agree. Taking Tab away from indent so it can move focus would fix one accessibility problem by creating another.
Leaving Tab as indent forever creates a keyboard trap. Once you are in the editor, you cannot Tab out. Inside a table it was worse: Tab always added a row, so someone trying to leave kept growing the document in silence.
We took the same way out CodeMirror 6 uses for this dilemma. Escape arms the next Tab to move focus. Any other keystroke cancels the arming, so an ordinary indent is never swallowed by accident. We also tell people about it: a visually hidden hint is associated with the editor when focus enters, and the shortcuts sheet says what Escape actually does there, instead of claiming it only closes menus. That advice is not optional decoration. WCAG 2.1.2 allows a non-standard exit only when the user is told how to use it.
That is the recurring shape of the hard work. You do not get to pick the clean WCAG answer when the product has already assigned the key a job. You invent an exit that preserves both jobs, and then you make sure people can discover it.
Announcements without talking over the user
Screen reader users need to know when a note opened, when a save failed, when search returned twelve results, when the assistant finished a reply. Toasts are not enough; many of them are visual-only by default, and status changes that matter need a path that does not depend on sight.
The naive fix is a live region for every status. Autosave then announces "Saved" every few keystrokes. An AI reply streamed as role="log" reads every token aloud as it arrives. Search result counts update while notes sync in the background, so the count chatters even when nobody asked for a filter.
We replaced two half-broken regions with one always-mounted pair and a small announce() helper, the spoken cousin of our existing toast helper. A few rules fell out of using it:
- Repeating the same string does nothing unless you clear the region first. Assistive tech ignores a re-set to text it already holds, so a second "Saved" would be silent without a brief blank in between.
- Routine saves are throttled. A save after a failure always speaks, throttle or not, because that is when someone is waiting to hear it.
- Opening a note announces the name. It does not steal focus into the editor on every activation, because the open path is shared by clicks, drag-and-drop, and the command palette, and stealing focus on a pointer click is its own kind of rude.
- The AI transcript is not a live region. It announces once when generation starts and once when it finishes. Streaming word-by-word is worse than waiting.
- Result counts key off the filter, not every time the list length moves for other reasons.
Status for sighted users still needs a persistent place to live when something is wrong. Infinite toasts with a close button and no focus move are a different failure mode. The spoken channel and the visible channel have to agree without either one becoming noise.
Focus that falls on the floor
Some of the worst bugs were not in the editor at all. They were in the invite and register flows every beta tester walks through once.
A step transition that unmounts, or hides, the element currently holding focus drops that focus onto <body>. Nothing is announced. From the keyboard, it feels like the app died. The form is still on screen, so a sighted user might keep typing into a field that is no longer the active step, or press Submit again because "sent" and "nothing happened" look the same when confirmation is only a banner stacked on top of a still-enabled form. Focus order and where focus goes after a change are easy to get wrong the moment the DOM is swapped rather than updated in place.
Fixes here are unglamorous and specific. Replace the form when the confirmation arrives, instead of stacking a message above it. Move focus into the new step's labelled group. Send focus back to the button that opened a confirm step when someone goes Back. Pair each focus move with an announcement so the transition is both spoken and landed.
If you only test accessibility inside the editor, you miss the door people have to open to get there.
The cost of every note being a tab stop
At a small note count, putting every row in the tab order feels fine. At forty notes it is already dozens of stops before you reach the document. At a few hundred it is absurd. Reaching the note body meant tabbing past the chrome, the categories, the tags, and then every single note.
Two changes carried most of the weight. The note list now uses a roving tabindex: exactly one row is a stop, and the others are reached with arrow keys, Home, and End. A skip link is the first stop on the page and jumps to the editor pane.
Small details mattered. If a filter shrinks the list under the focused index, the index has to clamp, or the list can end up with no tab stop and become unreachable. Arrowing down has to raise the progressive render limit so a row that is not mounted yet still exists when you ask for it. Card view is a grid visually, but arrow movement stays previous/next in DOM order, because "the item below" needs a live column count to mean anything and next/previous is predictable in both layouts.
This was the widest-reach keyboard item in the audit. It is also the kind of thing automated scanners barely see, because every row is still "focusable" in the abstract.
Pointer-only is fine until it is the only path
The block handle menu (turn into heading, move, duplicate, delete, and the rest) lived behind hover and a mouse gesture. Keyboard users could not open it, and once open it was not a real menu. That fails the basic keyboard requirement: if you can do it with a pointer, you need a keyboard path too.
Opening is now ShiftF10 (and the Menu key where a keyboard has one), aimed at the block under the caret. The open menu is a proper role="menu" with arrow navigation, submenus, and focus returned to the editor on close. The hover grip itself stays pointer-only on purpose: making that floating handle a tab stop would reintroduce a different bug we had already fixed. What has to be reachable is the actions, not the grab target.
On mobile the story rhymed. Archive and delete lived on a swipe. Switch Control and similar tools build their scan set from real actionable elements, so a row that was only a div with a click handler meant some people could not open a note at all. Rows are links now. Archive and delete live in the note's existing overflow menu, which was already the home for pin, tags, export, and the rest. The swipe stayed. It is a fast path for people who can use it. The fix was never to remove it, only to stop it being the only path.
Colour pickers and icon pickers had the same shape with an extra twist. Swatches became real menu items. The category icon grid could not, because the menu also contains a search field, and the menu library's typeahead steals printable keys the moment it has items to jump to. The grid owns its own roving tabindex instead, with arrow movement that returns to the search field from the top row. Converting it "the standard way" would have broken typing into search.
Contrast is not always a colour tweak
Some findings looked like CSS one-liners and were not.
Our destructive colour was doing two jobs: a dark fill behind a white label, and light text on a dark page. Lightening it for the text use would have wrecked the fill contrast. Text contrast and non-text contrast both care about the result on screen, not whether you reused one token for two jobs. We split the token the same way we already split input borders from page borders: one value for fills, a stronger one for text.
Selected menu rows were another trap. A muted background alone sat at roughly 1.1:1 against the page in places. No background swap that subtle can carry selection on its own. Selected rows now also get a primary left bar, reusing geometry we already had in another menu. That change is visible in every dropdown. It is also the difference between "which row am I on?" being guesswork and being obvious.
We left a few contrast and sizing questions deferred on purpose. An opt-in high-contrast mode and a re-spaced mobile toolbar (target size is its own constraint on phones) are product decisions about what the app looks like, not defects with a single right patch. Calling them deferred is more honest than pretending a token tweak closed them.
What we are aiming for
As of writing, every blocker and every serious finding from that pass is closed. A handful of moderate items remain, plus those deferred design calls. The backlog will grow again the moment we ship something new with a hover menu or a clever focus move.
That score is not the point.
We are not trying to hang a conformance badge on the marketing site. We are trying to make Snownotes genuinely usable for people who cannot rely on a mouse, or on vision, or on a large touch target, or on guessing what just happened after a silent UI transition. Passing a checklist while the editor still traps Tab, while saves never speak, while a switch user cannot open a note, would be the wrong kind of green.
The work is judgment under constraints. Keep Tab as indent and give people a way out. Announce saves without narrating every keystroke. Keep the swipe and put archive on a real menu. Refuse the textbox role even when a library asks for it. When the audit was wrong about a component, fix the real defect next door instead of the one on the page.
If you write or maintain a rich editor, budget for this kind of work as product work, not as a late compliance pass. The browser will not do it for you, and the interesting bugs only show up when you ask whether someone can finish the task at all.
Snownotes is in closed beta. If you want in, join the waitlist. If you use assistive technology and something still gets in your way, tell us. That report is worth more than another checkbox.