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…
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".
- 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.
- 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. Stop Escape from propagating on
purpose — inside a sheet the first Escape should put the keyboard away and only
a second one close the sheet.
- Cleanup: the document focus listeners, the document 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.
- doneLabel and label are the i18n seam; the refusal strings are the only other
prose, and they live in one announce() call each.
- 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(stopped from propagating, so the first press dismisses the keyboard and only a second closes the surrounding sheet), 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.