Nakodo runs outreach campaigns for brands, and most of the work in it is reading a list and deciding yes or no about the thing in front of you. That shape invites keyboard shortcuts, and I have watched myself not add them for a month, so this is the audit: every key this app listens for, every time it moves focus, and the places it should and does not.

One part you can try without an account. The card on the home page runs a short tour of itself, and Escape stops it.

Two shortcuts, in one file, on a screen with a list behind it

Open one creator out of a filtered list and the header gives you back the list, the position in it, and a step either way. That component is the only place in the app that binds a key to an action:

// Back to the list as it was filtered, and previous/next through that list. // J and K step through it, as in most mail apps. useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.metaKey || e.ctrlKey || e.altKey || e.defaultPrevented) return; const t = e.target as HTMLElement | null; if (t && (t.isContentEditable || t.closest("input, textarea, select, [role='dialog'], [role='listbox']"))) return; if (e.key === "j" && next) router.push(next.href); else if (e.key === "k" && prev) router.push(prev.href); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [router, prev, next]); 

Four guards before the two lines that do anything, and each guard is a bug I would otherwise have shipped:

  • Modifiers. Cmd+J on a Mac is a browser command. A naked e.key === "j" test steals it.
  • defaultPrevented. If something closer to the event already handled the key, this handler is not entitled to a second opinion.
  • isContentEditable. Any rich text field anywhere, now or later.
  • closest("input, textarea, select, [role='dialog'], [role='listbox']"). The actual reason this guard exists in this form: typing the letter j into a search box must type the letter j. Checking only tagName misses the combobox listbox and misses the help panel, both of which are focusable containers rather than form controls.

prev and next arrive as props from the server component that already worked out the neighbours in the filtered order, so the handler has nothing to compute and the dependency array is honest.

The other half is that the shortcuts are also buttons:

<Link href={to.href} aria-label={`${label}: ${to.title}`} aria-keyshortcuts={shortcut} title={`${label}: ${to.title} (${shortcut})`} > 

aria-keyshortcuts appears exactly once in the source and twice in the rendered page, which is the right ratio for a shared component rendered for both directions. A shortcut nobody can discover is a shortcut for the author only; the title attribute tells a mouse user and the ARIA attribute tells a screen reader, and the key still works if you never see either.

Five key handlers in the entire codebase

$ grep -rn "onKeyDown\|addEventListener(\"keydown" src | wc -l 5 

Two are window listeners and three are React props:

campaigns/[id]/creators/[channelId]/nav.tsx window keydown j / k step through the list (marketing)/showcase.tsx window keydown Escape ends the homepage tour campaigns/targeting-fields.tsx onKeyDown Enter adds a place instead of submitting components/support/panel.tsx onKeyDown Escape closes the help panel components/support/panel.tsx onKeyDown Enter sends, Shift+Enter makes a newline 

The Enter one in the targeting field is the least glamorous and the most load-bearing:

onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); search(); } }} 

That input sits inside a form that has its own submit, and Enter in a text field submits the nearest form. Without the preventDefault the keyboard path through that screen is "type a town, press Enter, lose the step". With it, Enter does the thing a person typing a town means by Enter.

The support panel's pair is the one convention that had to be learned rather than reasoned about:

if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); onSend(); } 

Enter sends, Shift+Enter is a newline. In a textarea that is the opposite of the platform default, and it is what every chat window on earth does, so doing the platform-correct thing here would be the surprise.

No hotkey library, no command palette, no accessKey attribute anywhere:

$ grep -rn "accessKey\|useHotkeys\|mousetrap" src | wc -l 0 

Two programmatic focus moves

$ grep -rn "\.focus(" src | wc -l 2 

The first is the setup wizard, and it is the one I would copy into any multi-step form:

// Each new step starts at the top, with its heading read out. useEffect(() => { if (shown.current === step) return; shown.current = step; window.scrollTo({ top: 0 }); heading.current?.focus({ preventScroll: true }); }, [step]); 
<h2 ref={heading} tabIndex={-1} className="text-[22px] leading-7 outline-none"> 

A wizard step is a navigation that the browser does not know about: the URL may not change, so nothing resets focus, and a screen reader is left pointing at a button that no longer exists while the sighted user is looking at a new question. Moving focus to the step's heading answers both. tabIndex={-1} makes a heading focusable without putting it in the tab order, outline-none stops a focus ring appearing on text that nobody clicked, and preventScroll matters because the line above it already scrolled to the top and focus() would otherwise scroll again, to a slightly different place.

The second is the help panel:

// The panel takes focus when it opens, so Escape closes it and a screen // reader announces it. useEffect(() => { if (!open) return; const frame = requestAnimationFrame(() => panel.current?.focus()); return () => cancelAnimationFrame(frame); }, [open]); 

The requestAnimationFrame is not superstition. The panel mounts with an enter animation, and focusing an element in the same frame it is inserted competes with the browser's own layout and, in Safari, with the scroll position of the element behind it. One frame later the node is laid out and the focus lands where it looks like it lands. The cleanup cancels the frame, because an open-then-immediately-closed panel should not grab focus after it has gone.

Eighteen autoFocus occurrences, and four of them fire on first paint

$ grep -rn "autoFocus" src --include="*.tsx" | wc -l 18 $ grep -rl "autoFocus" src --include="*.tsx" | wc -l 8 

Five of the 18 are plumbing: two helper components declare an autoFocus?: boolean prop, destructure it, default it to false and pass it on. The other 13 are a component actually asking for focus, and they split cleanly into two groups.

Four fire when the page paints. The three auth forms (the email field on sign-in, the email field on the forgotten-password form, the new password field on the reset form) and the first field of the setup wizard. Each of those pages is a page whose entire purpose is one form, with nothing above it to read, and in that case moving the caret into the field is what a person would have done next anyway. Each of those fields also carries the right autoComplete token, so autofill is offered rather than fought.

Nine fire when something appears in response to a click. Every editor in the app shows a value with a Change button beside it rather than an empty box, and pressing Change swaps the text for a field; four more are number fields that only exist once a negotiation setting is switched on. Those are the use nobody argues about: focus follows an action the person just took, in the same frame, inside the thing they clicked.

The rule that falls out, and that I am writing down because I keep rediscovering it: autoFocus is correct when it is the continuation of a click, and suspect when it is the consequence of a navigation. Four of ours are the second kind, all four are single-purpose forms, and I would delete them all if any of those pages ever grew a paragraph worth reading first.

The finding: one dialog takes focus and does not trap it

Every panel in the app is a Sheet and every confirmation is a Dialog, both on top of the same primitives, and both bring a focus trap with them: seven Sheet files and five Dialog files inherit it for free. Exactly one thing in the app is a hand-rolled dialog:

<div ref={panel} tabIndex={-1} role="dialog" aria-label="Help" onKeyDown={(e) => { if (e.key === "Escape") { e.stopPropagation(); onClose(); } }} 

It announces itself as a dialog, it takes focus when it opens, Escape closes it, and the mobile backdrop behind it is aria-hidden. What it does not do is keep Tab inside itself. Press Tab enough times in the help panel and focus walks out into the page behind, which is still fully interactive, and the thing claiming to be a dialog is then a dialog by announcement only.

That is a real bug and it is mine. The fix is not a focus trap of my own; it is to put this panel on the same primitive as the other seven, which already solves it, and to keep the Escape handler only if the primitive's own is not enough.

Two more honest notes while the audit is open. The homepage tour opens its notes on hover and on click but not on focus, so a keyboard user tabbing the card gets no notes, which I wrote about when I built it and still have not fixed. And there is no skip link on any page, which I found in a separate sweep of the public pages and which is the cheapest unfixed thing on this list.

So: two shortcuts, five handlers, two focus calls, 13 autoFocus attributes, one missing focus trap, no skip link. For an app that is mostly a list and a decision, two shortcuts is fewer than it should be, and the reason to count first is that the next one can copy the four guards that the first one already got right.