Keyboard Accessory
The bar that rides on top of the software keyboard: previous / next field travel, quick-insert chips, and a Done key that hands focus back on purpose.
Loading preview…
- Dependencies
- lucide-react
- Registry dependencies
- utils
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/keyboard-accessory.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "KeyboardAccessory" component — the bar iOS
calls an input accessory view: it rides on top of the software keyboard with
previous / next field travel and a Done key. React + lucide-react only, no
gesture or animation library.
Contract
- "use client". forwardRef<HTMLDivElement, KeyboardAccessoryProps> extending
React.HTMLAttributes<HTMLDivElement>; the rest props spread onto the root.
- Export const KEYBOARD_ACCESSORY_FIELD_ATTR = "data-accessory-field". The
consumer marks each field element with it; the bar resolves ids to elements by
querying inside `scope` at the moment it needs them, never by holding refs
(fields mount, unmount and re-order).
- Props:
- fields: { id; label?; skip? }[] — required. THIS list is the travel order,
not DOM order. `skip` keeps a field addressable but jumps over it (a
read-only row). Each id must match a marked element inside `scope`; ids with
nothing in the DOM are silently skipped when travelling.
- scope: RefObject<HTMLElement | null> — required. The element holding the
fields, and the element that receives focus when Done is pressed. While
mounted, give it tabindex="-1" if it has none, and remove that attribute on
unmount; never overwrite a tabindex it already carries.
- variant: "travel" | "assist" | "done" = "travel". travel = chevrons + field
label + counter + Done. assist = a horizontally scrollable chip row above the
same controls. done = one full-width Done key, for a numeric keypad that has
no return key at all.
- activeField / defaultActiveField / onActiveFieldChange — controlled and
uncontrolled. Writing activeField also MOVES focus to that field, so a failed
validation can send the user straight to the offending input; guard it with
the last value the component acted on so it never re-steals focus the user
has moved on from.
- onDone?: () => void — fires after focus has been handed over.
- chips?: { id; label; value? }[] + onChipSelect?: (chip) => void — assist
variant only. Call onChipSelect with the field ALREADY refocused so the
consumer's handler can use setRangeText against a live caret.
- loop = false, visibility: "auto" | "always" = "auto",
placement: "fixed" | "contained" = "fixed", keyboardInset?: number,
shortcuts = true, doneLabel = "Done", label = "Input accessory".
- labels?: Partial<KeyboardAccessoryLabels> — every other string, merged over
English defaults: previousField, nextField, quickInsert, noFieldSelected,
noFields, the counter as functions fieldsReady(count) and
fieldPosition(position, total) (1-based), and the refusals noFieldToMoveTo,
alreadyLast, alreadyFirst and cannotFocus(fieldLabel).
- Mirror the variant onto the root as data-variant.
Behavior — the keyboard is measured, never guessed
- Keyboard height = window.innerHeight - visualViewport.height -
visualViewport.offsetTop, clamped at 0 and rounded, subscribed through
useSyncExternalStore over visualViewport's resize AND scroll events (getSnapshot
returns a number, so equal frames are equal snapshots; the server snapshot is
0). offsetTop is what keeps the bar glued when iOS scrolls the page under a
raised keyboard. On an Android window that resizes instead of overlapping, the
formula lands on 0 — also correct, because the bottom edge is already above the
keyboard. Never ship a device-height table. Do NOT multiply by
visualViewport.scale (the way a "keyboard height" hook does to ignore zoom):
the bar is fixed to the layout viewport and must land on the VISIBLE bottom,
and iOS zooms into any field under 16px — scaled, the bar docks off screen.
- The bar is bottom: 0 plus transform: translate3d(0, -inset px, 0). Hidden it is
translate3d(0, 100%, 0) — one property, so docking and hiding can never fight.
- Safe area: with the keyboard DOWN pad the bottom with
max(0.375rem, env(safe-area-inset-bottom)) so the home indicator cannot swallow
the Done key; with the keyboard UP drop back to the small padding, because that
strip is under the keyboard already.
- Visibility ("auto"): visible while a marked field — or the bar itself — holds
focus. Track it with focusin / focusout on the DOCUMENT, not on scope: focusin
from the bar (a sibling of scope) has to be heard too, and scope may mount in a
later commit than the effect. Adopt an already-autofocused field with one
requestAnimationFrame after mount.
- The blur grace period is the crux: on touch, pressing a bar button blurs the
field BEFORE the click lands, so hiding on focusout would pull the bar out from
under the finger. Defer the re-check ~140ms and cancel it on the next focusin;
every button except Done puts focus straight back on a field, which cancels it.
On mouse, additionally preventDefault the pointerdown (pointerType === "mouse"
only — cancelling a touch pointerdown risks eating the click) so the press never
steals focus in the first place.
- Travel: from the declared order minus skipped fields minus unresolved ids, move
one step; with nothing focused, Next goes to the first field and Previous to the
last. At an end, `loop` wraps, otherwise the press is REFUSED — announced
politely, never a silent dead button. If the target refuses focus, say so and
leave the reported field alone rather than lying about where the user is.
- Done: move focus to the scope container (that is also what retracts the
keyboard — it is not a text entry), fall back to blur() if the container
refused focus, clear the active field, hide, then call onDone. Focus must never
be dropped on <body>, and never left on a button that is sliding away.
- Chips insert nothing themselves: refocus the field, then hand the chip to
onChipSelect. Rendering a chip that only looks like it inserts text would be a
fake interaction.
- Keyboard parity for a hardware keyboard: while a field is focused, Alt+ArrowUp /
Alt+ArrowDown travel and Escape acts as Done. preventDefault that Escape on
purpose — inside a sheet the first Escape should put the keyboard away and only
a second one close the sheet. That needs a keydown listener on WINDOW in the
CAPTURE phase: Radix / shadcn overlays listen on document in the capture
phase, which runs before any bubbling listener and before every React handler,
and they do not dismiss an Escape whose default is already prevented — so a
document bubble listener or a React handler is too late. Do NOT
stopPropagation it: the field's own onKeyDown (clear a search, revert an inline
edit) must still receive the key. A field reporting aria-expanded="true" (an
open combobox) keeps its Escape, so the first press closes its own popup.
- Cleanup: the document focus listeners, the window capture key listener, the grace
timer, the announcement timer, the rAF and the visualViewport subscription all
come off on unmount or when their dependency changes; the tabindex the
component added to scope is removed too.
Rendering & styling
- Semantic tokens only: bg-card/95 + backdrop-blur + border-t for the bar,
text-foreground / text-muted-foreground for the label and counter, bg-muted +
border for chips, and bg-foreground + text-background for Done — the highest
priority control INVERTS rather than taking a colour. Merge the consumer's
className last with cn().
- Type scale stays small and tight: 13px field label, 11px tabular-nums counter,
12px chips. The label truncates: a 390px screen has no room for a wrapped
40-character field name, and the bar must never change height.
- Hit areas: every control is h-11 (44px) with min-w-11 on the icon buttons;
nothing depends on hover, nothing depends on a gesture.
- Accessibility:
- role="toolbar" + aria-orientation="horizontal" + aria-label on the root, with
a roving tabindex over every control (chips, then chevrons, then Done): one
Tab stop for the whole bar, ArrowLeft / ArrowRight to walk it, Home / End for
the ends. Arrow keys are handled only when focus is already on a control.
- The chevrons take aria-disabled at the ends, NEVER the native disabled
attribute — the browser blurs a node the moment it becomes disabled, and the
user may be standing on it. The refusal is announced in a polite sr-only
role="status" that is cleared afterwards so an identical second refusal is
read again.
- aria-keyshortcuts advertises Alt+ArrowUp / Alt+ArrowDown / Escape.
- The visible field label + counter is aria-hidden: moving focus already
announces the field, and repeating it here would say every jump twice.
- Hidden state uses `inert` plus pointer-events-none, and focus is handed over
BEFORE the state flips, so the bar is never inert with focus inside it.
- prefers-reduced-motion: the docking and hiding transforms are applied either
way — that is position, not decoration — and only the transition is dropped
(motion-reduce:transition-none). Nothing about the feature depends on the
animation.
Customization levers
- variant is the layout axis: "travel" for a form, "assist" when the field wants
symbols or snippets, "done" for a keypad. Only "assist" reads `chips`.
- placement: "fixed" in a real app; "contained" inside a phone frame or preview,
where it docks to the nearest positioned ancestor and reads keyboardInset
instead of the viewport. keyboardInset also lets tests drive the docking.
- visibility="always" turns the bar into a permanent toolbar for a single-field
screen; "auto" is the input accessory behaviour.
- loop suits a short repeating form (a two-field search); leave it off for a long
one, where wrapping from the last field to the first feels like a mis-tap.
- i18n: doneLabel and label name the Done key and the toolbar; labels overrides
every other string, including the counter and the refusals, whose variable
parts arrive as function arguments so word order is the translation's call.
- Density: the bar is px-2 pt-1.5 with h-11 controls. Keep 44px hit areas if you
compress it — shrink the gaps and the chip padding, not the height.
- To add a control (a formatter, a unit toggle), render it as another
data-accessory-control button in the same row: the roving tabindex picks it up
by query, so nothing else has to change.
- To let chips insert by themselves, keep onChipSelect for uncontrolled inputs
(setRangeText at selectionStart) and swap in a controlled recipe for React
state — the component deliberately does not write into your value.Concepts
- Ride the keyboard, do not guess it — the dock offset is
innerHeight - visualViewport.height - offsetTop, subscribed to bothresizeandscroll.offsetTopis the part everyone forgets: iOS lets the page scroll underneath a raised keyboard, and without it the bar drifts off the keys. On an Android window that resizes instead of overlapping, the same formula lands on 0, which is also right — the bottom edge is already above the keys. A device-height table is wrong on the first device you did not test. - Blur grace period — a touch press on the bar blurs the field before its click ever lands, so a bar that hides on
focusoutdisappears from under the finger and the button is never activated. The hide is deferred ~140ms and cancelled by the nextfocusin; every control except Done refocuses a field, so the cancel is the normal path. On mouse the press is cancelled atpointerdown(mouse pointers only) and focus never moves at all. - Travel order is declared, not discovered — the
fieldsarray decides what Next means, so a visually two-column form still travels the way it is meant to be filled,skipsteps over a read-only row, and an id whose element is not in the DOM is passed over instead of dead-ending. At the ends the press is refused out loud:aria-disabledplus a polite live region, never the nativedisabledattribute, which would blur a control the user may be standing on. - Done is a focus handoff, not a blur — dismissing the keyboard means moving focus somewhere deliberate: the scope container, which is not a text entry, so the platform puts the keyboard away and focus never lands on
<body>or on a button that is sliding off screen. The bar only becomesinertafter the handover, so it is never inert with focus trapped inside it. - Safe area is a state, not a constant — with the keyboard down the bar pads itself by
env(safe-area-inset-bottom)so the home indicator cannot swallow the Done key; with the keyboard up that strip is already covered and the padding drops back. This is the whole reason the component is mobile-only: on desktop Tab already travels the form, and there is no keyboard to put away. - Every touch affordance has a key — the chevrons are
Alt+ArrowUp/Alt+ArrowDownfrom inside a field, Done isEscape(default-prevented in window's capture phase, ahead of the document-level listener Radix / shadcn overlays use, which leaves a prevented Escape alone — so the first press dismisses the keyboard and only a second closes the surrounding sheet, while the field's own key handler still runs), and the bar itself is one Tab stop with a roving tabindex walked by the arrow keys.
Amount Keypad
An in-app currency keypad that replaces the software keyboard — oversized live-grouped digits, decimal-point or cash-register entry, and a delete key that repeats on hold.
Keyboard Avoider
A screen container that keeps its content and pinned footer clear of the software keyboard by the height the visual viewport actually reports — resize the frame, lift the frame, or ride only the bar.