Mobile

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ChevronDown, ChevronUp, KeyboardOff } from "lucide-react"
import { cn } from "@/lib/utils"

/** Marker attribute the consumer puts on every field the bar can travel to. */
export const KEYBOARD_ACCESSORY_FIELD_ATTR = "data-accessory-field"

const FIELD_SELECTOR = `[${KEYBOARD_ACCESSORY_FIELD_ATTR}]`
/** A marked wrapper is allowed: focus then goes to the first real control inside it. */
const FOCUSABLE_SELECTOR =
  'input:not([type="hidden"]), textarea, select, [contenteditable="true"], [tabindex]:not([tabindex="-1"])'
/**

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/keyboard-accessory.json

Prompt

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 both resize and scroll. offsetTop is 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 focusout disappears from under the finger and the button is never activated. The hide is deferred ~140ms and cancelled by the next focusin; every control except Done refocuses a field, so the cancel is the normal path. On mouse the press is cancelled at pointerdown (mouse pointers only) and focus never moves at all.
  • Travel order is declared, not discovered — the fields array decides what Next means, so a visually two-column form still travels the way it is meant to be filled, skip steps 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-disabled plus a polite live region, never the native disabled attribute, 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 becomes inert after 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+ArrowDown from inside a field, Done is Escape (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.

On This Page