Mobile

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"

/** How the row is laid out. Not a colour swap: three different reach strategies. */
export type ThumbToolbarVariant = "bar" | "arc" | "dock"
/** Which bottom corner the thumb pivots from. Mirrors the arc and the overflow slot. */
export type ThumbToolbarHand = "right" | "left"

export interface ThumbToolbarAction {
  key: string
  label: string

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/thumb-toolbar.json

Prompt

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 arc variant lifts each slot by ARC_LIFT * sqrt(1 - u²) where u is its distance from the pivot. It is applied as margin, so it is layout rather than motion — the curve survives prefers-reduced-motion untouched.
  • 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 elementFromPoint instead. The gesture is a shortcut layered over a plain tap, never a replacement for it.
  • Handedness is data, not a rebuildhand mirrors 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, Tab out of the menu closes it deliberately rather than dropping the user behind it, and an unavailable key is aria-disabled with a guard in the handler, never natively disabled — the browser blurs a disabled node instantly, and the user may be standing on it.

On This Page