Thumb Toolbar
A bottom action bar sized to the thumb-reach arc: a hand-side overflow key that opens its menu upward, press-and-slide selection, safe-area padding and a full keyboard path.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/thumb-toolbar.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ThumbToolbar" component — a bottom action
bar laid out around the thumb, whose overflow menu opens UPWARD because a bar on
the bottom edge has nowhere below it to go. React + lucide-react only: no popover
library, no gesture library, no animation library.
Contract
- "use client". forwardRef<HTMLDivElement, ThumbToolbarProps> extending
Omit<React.HTMLAttributes<HTMLDivElement>, "children">; the rest props spread
onto the root. Children are omitted on purpose — the row is built from data.
- ThumbToolbarAction: { key; label; icon: ReactNode; primary?; destructive?;
disabled?; hint?; onSelect?: () => void }. `hint` is a second line rendered in
the overflow menu only (the row has no space for it).
- Props:
- actions: ThumbToolbarAction[] — ordered left to right. The array order IS the
visual order in every variant; nothing is silently reordered.
- variant: "bar" | "arc" | "dock" = "bar" — three reach strategies, not three
skins. bar: an even row. arc: the same row lifted onto the thumb sweep. dock:
a compact pill in the hand-side corner holding one action plus the overflow.
- hand: "right" | "left" = "right" — which bottom corner the thumb pivots from.
Mirrors the arc, the overflow slot and the menu's alignment.
- maxVisible = 4 — slots in the row before the rest folds away, counting the
overflow key itself. Clamped 1..6 through one helper that rejects NaN (a NaN
cap would hide every action). Ignored by dock, which always shows one action.
- open / defaultOpen = false / onOpenChange — the overflow menu, controlled or
uncontrolled.
- onAction?: (key: string) => void — fires for every activation, alongside the
action's own onSelect.
- label = "Quick actions" (the toolbar's accessible name), overflowLabel =
"More" (visible text + accessible name of the overflow key), emptyLabel =
"No actions available", showLabels = true, safeArea = true.
- Mirror `variant` and `hand` onto the root as data-variant / data-hand, and mark
the menu with data-slot="thumb-toolbar-menu", so consumers can skin by state.
- Splitting is pure and lives outside the component: actions.length <= cap means
everything is visible; otherwise the visible slice is actions.slice(0, cap - 1)
— one slot has to pay for the overflow key — and the rest folds into the menu.
dock takes the first action flagged primary (or actions[0]) and folds the rest.
Behavior
- Layout by reach:
- The overflow key takes the HAND-SIDE END of the row, because it is the only
control whose absence makes actions unreachable. hand="left" moves it to the
left end; the actions themselves never move.
- arc lifts each slot by ARC_LIFT * sqrt(1 - u^2), where u is the slot's
distance from the thumb pivot as a 0..1 ratio (peak 22px, at the pivot). Apply
it as margin-bottom, not a transform, so the row grows to hold the lift
instead of letting a raised key escape the card. It is layout, not motion:
reduced motion leaves it alone. A single slot gets no lift.
- The menu is absolutely positioned at bottom-full (above the row), aligned to the
hand side, with a capped max-height and its own scroller — a long list must not
grow past the top of the screen.
- The gesture is press-and-slide, from the overflow key into the menu:
- pointerdown on the overflow key records the start point and takes
setPointerCapture immediately, on that same key. Without capture a mouse
leaving this small target stops delivering moves and the drag dies halfway up.
touch-action: none on the key so the browser does not scroll the page instead.
Nothing calls preventDefault — the CSS declares the intent, so no listener has
to fight a passive registration.
- The gesture is claimed only after 10px of UPWARD travel, and dropped for good
if the movement is mostly sideways (that belongs to whatever the bar sits on).
Claiming opens the menu.
- While dragging, the row under the finger is armed by hit-testing:
document.elementFromPoint -> closest("[data-thumb-item]") -> its key. Per-row
pointer handlers cannot work here, because the capture routes every move to
the key that started the gesture. Disabled rows never arm.
- Release over an armed row runs it, closes the menu and returns focus to the
overflow key. Release over nothing — or a pointercancel — leaves the menu
open as an ordinary menu, so the press is never wasted.
- A press that never travelled is an ordinary tap: the click handler owns it.
After a real drag, set a ref flag that swallows the click the browser
synthesizes next, or the release would immediately toggle the menu shut. Reset
the flag on the following pointerdown, because a moved touch may never produce
a click at all.
- Every activation of a row action also dismisses an open overflow.
- Edge cases:
- actions=[] renders the bar shell with emptyLabel, keeping the height stable
instead of collapsing the layout out from under the thumb.
- One action renders no overflow key at all.
- Labels truncate in the row (never wrap, never change its height) and are shown
whole, with their hint, in the menu.
- disabled is aria-disabled plus a guard in the handler, never the native
attribute: the browser blurs a node the instant it becomes disabled, so a key
the user is standing on would drop focus onto <body>.
- A stored roving index is clamped at render rather than written back, so a
shrinking action list cannot point at a slot that no longer exists.
- Cleanup: the outside-press listener is attached only while the menu is open and
removed on close and on unmount; pointer capture is released on the element that
took it, on both pointerup and pointercancel; no timers or rAF are used at all.
Rendering & styling
- Semantic tokens only, monochrome first: bg-card + border + shadow-sm for the
row, bg-popover / text-popover-foreground for the menu, text-foreground and
text-muted-foreground for content, hover:bg-muted for the pressed-in state. The
highest-priority action INVERTS (bg-foreground text-background) instead of
taking a colour; colour is spent only on genuine semantics
(text-destructive / bg-destructive). Radius ladder 16/12/8: rounded-2xl row and
menu, rounded-lg keys and rows, full round for the dock pill.
- Type scale is small and tight: 11px key labels, 13px menu rows, 11px hints.
- Touch: every key is min-h-14 (56px) and every menu row min-h-11 (44px); padding
does the work, nothing depends on hover, and the gesture is a shortcut for a
path that a tap and a key can both walk.
- Safe area: the root pads with env(safe-area-inset-bottom / left / right) — it
sits on the screen edge, so it owes the home indicator and the landscape notch
their inset. The menu is offset by the same inset so it stays aligned with the
row it belongs to.
- Motion: the menu has an entrance only (motion-safe:animate-in + fade + a 2-unit
slide from the bottom), never an exit — a dismissed menu that stays mounted for
an exit animation stays under the finger. Under prefers-reduced-motion it simply
appears. The press feedback is a 150ms group-active scale on the glyph, dropped
under reduced motion.
- Accessibility:
- The row is role="toolbar" + aria-label with a roving tabindex: ONE tab stop,
then ArrowLeft / ArrowRight move between keys and Home / End jump to the ends.
onFocus on each key keeps the roving index honest when focus arrives by click.
- The overflow key is aria-haspopup="menu", aria-expanded, and aria-controls the
menu while it is open; ArrowUp opens it (the menu rises, so the key points up)
and lands on the first row. Enter / Space do the same through click.
- The menu is role="menu" labelled by the overflow key (aria-labelledby), its
scroller carries role="none" so it does not sit between the menu and the rows
it owns, and every row is role="menuitem" with tabIndex -1.
- Inside the menu: ArrowDown / ArrowUp / Home / End move focus, Enter / Space
activate, Escape closes. Tab closes it too and hands focus back to the
overflow key rather than to whatever the DOM order offers next.
- Escape is handled on the component's own subtree with stopPropagation, never
on window: an Escape pressed inside a dialog that holds this bar must close
one layer, not both.
- Anything that unmounts hands focus to a deliberate successor: whenever the
menu closes while focus is inside it, focus goes to the overflow key first.
Customization levers
- Reach lives in three numbers: maxVisible (how much stays in the row), ARC_LIFT
(how pronounced the sweep is — 22px reads as a curve, 0 makes arc identical to
bar) and DRAG_OPEN_PX (10px is a deliberate slide; raise it if the bar sits on a
scroller that people flick).
- variant is the density lever: bar for a stable five-key row, arc for a big
phone held one-handed, dock for a screen whose content must stay visible.
- hand is a settings-screen toggle, not a build-time choice; pass a stored user
preference straight through.
- showLabels={false} halves the row height and keeps the names in aria-label.
- Wire it up as it should be: this component does not position itself. Give it
className="fixed inset-x-0 bottom-0 z-40" for a real screen, or drop it into a
card in a settings sheet. Height and width are the consumer's, merged last
through cn().
- Skin by state with data-variant / data-hand on the root and
data-slot="thumb-toolbar-menu"; add a badge to a key by wrapping its icon —
`icon` is any ReactNode.
- Actions are data, so a permission-filtered array is the whole story: filter the
array and the split, the arc and the menu all follow. Keep the primary next to
the thumb by putting it last (right hand) or first (left hand).Concepts
- Thumb-reach arc — the thumb sweeps a circle around a pivot at the bottom corner of the hand it is on, so the comfortable line is not straight: slots near the pivot can ride higher, slots far from it belong on the bottom edge. The
arcvariant lifts each slot byARC_LIFT * sqrt(1 - u²)whereuis its distance from the pivot. It is applied as margin, so it is layout rather than motion — the curve survivesprefers-reduced-motionuntouched. - Overflow that opens upward — a menu hanging off a bar that already sits on the bottom edge would render under the home indicator, off-screen. This one is anchored at
bottom-full, aligned to the hand side, capped in height and scrollable, so a fourteen-action list still resolves inside the screen instead of running off the top of it. - Press-and-slide, with a tap underneath — pressing the overflow key and sliding up opens the menu and arms whatever the finger is over; releasing runs it in one continuous motion. Pointer capture makes it reliable (a small key stops receiving moves the moment the pointer leaves it), which in turn makes per-row handlers useless — the armed row is found by hit-testing
elementFromPointinstead. The gesture is a shortcut layered over a plain tap, never a replacement for it. - Handedness is data, not a rebuild —
handmirrors three things at once: which end the overflow key occupies, which way the arc rises, and which edge the menu aligns to. The action array itself never reorders, so a user flipping the setting sees the same bar reflected, not a reshuffled one. - The reachable slot is the one you must not lose — the overflow key takes the hand-side end, because every other action can be reached through it, and it is the one control whose absence makes the folded actions unreachable. Everything about it is oversized: a 56px key, a 44px minimum on every menu row, and
env(safe-area-inset-*)between the bar and the physical edge. - Focus never lands on
<body>— closing the menu while focus is inside it returns focus to the overflow key first,Tabout of the menu closes it deliberately rather than dropping the user behind it, and an unavailable key isaria-disabledwith a guard in the handler, never nativelydisabled— the browser blurs a disabled node instantly, and the user may be standing on it.
Screen Transition
A push/pop screen stack with an interactive left-edge back swipe, a parallaxing under-layer and a drag that stays cancellable until you let go.
Reachability Pull
One-handed mode as a wrapper: a pull on the home-indicator strip brings the whole screen down into the thumb arc — sliding, shrinking or leaning into a corner — with a button, arrow keys, Escape, a tap and a timeout that all send it back.