Inline Picker Row
A settings row that expands in place to reveal its picker — slide a thumb across the options and lift to choose, instead of pushing a whole screen.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/inline-picker-row.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "InlinePickerRow" component (lucide-react for
Check / ChevronDown / Lock, no animation library, no popover primitive). It is a
settings row that expands in place to reveal its picker, instead of pushing a new
screen the user then has to navigate back from.
Contract
- forwardRef<HTMLDivElement>, extends
Omit<React.HTMLAttributes<HTMLDivElement>, "children" | "defaultValue">, spreads the
rest onto the root and merges className with cn().
- options: { value, label, hint?, icon?: ReactNode, disabled? }[].
- Selection is controlled or uncontrolled: value? / defaultValue? / onValueChange?.
Expansion likewise: open? / defaultOpen? / onOpenChange?.
- label (required, names the row and the radiogroup), description?, placeholder?
(shown when value matches no option, default "Choose"), emptyLabel?
(panel copy when options is empty).
- variant?: "list" | "grid" | "chips", default "list". Same radiogroup, three
presentations: full-width rows with a trailing check (long labels + hints), a
two-column tile grid (4-8 short labels that need a hint), a wrapping chip cluster
(many one-word values).
- collapseOnSelect? (default true), safeAreaBottom? (default false),
revealOnExpand? (default true), locked? + lockedReason? for a refusal.
- Root carries data-state="open" | "closed".
Behavior
- Expansion: the panel is a CSS grid that transitions grid-template-rows between
0fr and 1fr, with overflow-hidden on the grid *child* (a 0fr track only collapses
to zero when the child's overflow is not visible). No measured pixel height, so a
changing option list can never leave a stale max-height behind, and the rows below
simply move down — the row keeps its position in the list.
- Collapsed panel is inert (inert={!open || undefined}): zero height but tabbable is
an invisible keyboard trap, and inert also drops it out of the accessibility tree
while it shrinks.
- Slide to pick (the mobile gesture): pointerdown anywhere on the option group
captures the pointer on the group itself (setPointerCapture) and hit-tests
document.elementFromPoint(x, y).closest("[data-option-value]") on every move, so
the finger can leave a button, cross into the next one, or leave the panel
entirely and still be tracked. The option under the finger gets a preview ring;
aria-checked keeps reporting the committed value, not the preview. Lifting over an
option commits it; pointercancel commits nothing. The group is touch-action:none
(a drag inside the panel picks, it never scrolls) while the row header is
touch-action:manipulation, because a flick that starts on the row must still
scroll the settings list it lives in. A tap is the degenerate case of the same
gesture: press and lift on one option. The synthesised click that may follow a
lift is swallowed by an onClickCapture guard keyed on a ref written in the
pointerup handler, so a commit can never fire twice.
- Keyboard, equal in power to the gesture: on the row, Enter / Space toggles,
ArrowDown / ArrowUp opens and moves focus into the panel (via a ref flag read in
an effect, because options can only take focus once the panel stops being inert),
Esc collapses. Inside the panel, Arrow keys move and select the way a radiogroup
does but deliberately do NOT collapse (browsing stays cheap), Home / End jump,
Enter / Space is the explicit commit that collapses, Esc collapses. Roving
tabindex: exactly one option is tabbable — the selected one, else the first
enabled one. Disabled options are skipped by traversal, never merely dimmed.
- Focus: collapsing hands focus to the row button before the panel goes inert —
both when focus was inside the panel and when it is nowhere at all, because
capturing the pointer retargets the compatibility mouse events and a tap can end
with focus on <body>. The row's accessible name already carries the new value.
- Reveal (why this is a phone component): on expand — and again whenever the visual
viewport SHRINKS by a keyboard-sized jump (120px) while open — compare the panel's
rect against visualViewport.offsetTop / .height (fall back to innerHeight) and, if
it sticks out, call root.scrollIntoView({ block: "nearest" }). The software keyboard
shrinks the visual viewport while leaving innerHeight alone, so innerHeight would
happily open the panel underneath the keyboard. The size test is what makes the
listener usable: a bare resize also fires when the phone's URL bar collapses and
re-expands mid-scroll, and following that would drag an open row back under the
thumb every time the user scrolled away from it. Only an open transition scrolls: a
row rendered already-open must not yank the page on load. The timer and the
visualViewport listener are cleared on close and on unmount.
- locked is a refusal, not a disappearance: open is forced false whoever owns the
state, the row reports aria-disabled (never the native disabled attribute — the
user may be standing on it), stays focusable and announced, and lockedReason is
rendered on the screen instead of hidden in a tooltip no phone can hover.
- Empty options renders emptyLabel instead of an empty radiogroup; a single option
still opens, still commits.
Rendering & styling
- Semantic tokens only, monochrome-first: root rounded-2xl border bg-card
text-card-foreground; row label text-sm font-semibold, description and the
collapsed value text-muted-foreground, the value text-foreground while open;
panel separated by border-t. Selected list rows take bg-muted plus a Check;
selected tiles and chips INVERT (bg-foreground text-background,
border-foreground) rather than taking a colour. Preview ring is ring-2 ring-ring.
- Touch sizing: the row is min-h-14, list options min-h-11, tiles min-h-16, chips
min-h-11 — nothing under 44px, nothing hover-only (hover is a bonus layer).
- Motion: only two transitions (grid-template-rows, the chevron's rotate-180), both
with motion-reduce:transition-none, and the reveal scroll switches to
behavior:"auto" under reduced motion. Reduced motion loses the animation, never
the feature — the preview ring is a ring, not an animation. prefers-reduced-motion
is read through useSyncExternalStore on matchMedia (subscribed, so a mid-session
change is honoured; SSR snapshot is "motion allowed").
- Safe area: safeAreaBottom swaps the panel's bottom padding for
pb-[max(0.75rem,env(safe-area-inset-bottom))] so the last option clears the home
indicator when the row is the final thing above the screen edge.
- ARIA: the row is a button with aria-expanded + aria-controls; the group is
role="radiogroup" aria-labelledby={the row label's id}; options are
role="radio" + aria-checked; icons are aria-hidden.
Customization levers
- Variant axis: "list" / "grid" / "chips" are three entries in one class map plus
three JSX branches — add a fourth (a swatch row, a two-line "card") by adding one
entry and one branch; the gesture, keyboard and ARIA layers are variant-agnostic.
- Density: row min-h-14 / px-4, panel px-3 py-3, option gap-1 / gap-2. Drop the row
to min-h-12 for a compact list, but keep every option at min-h-11 or the slide
starts skipping targets.
- Grid columns: grid-cols-2 is the phone default; grid-cols-3 works for one-word
labels with no hint.
- Emphasis: swap the inverted selection (bg-foreground text-background) for
bg-primary text-primary-foreground if the product wants selection to carry the
brand colour; keep the Check either way, so colour is never the only signal.
- Behaviour knobs: collapseOnSelect={false} for a panel that stays up while the user
compares; revealOnExpand={false} inside your own scroll container that already
manages position; controlled open across several rows turns a group of them into
an accordion.
- The panel is deliberately not scrollable. Past roughly eight options, stop
expanding in place and hand the choice to a bottom sheet.Concepts
- Expand in place, not push — the alternative on a phone is a whole pushed screen: a transition out, a screen with one job, a back gesture, a transition in. This row trades all of that for the rows around it staying exactly where they are, which is what makes a settings list feel like one surface instead of a stack.
- Slide to pick — the commit is a lift, not a tap: press the panel, drag through the options with the pointer captured on the group, and let go on the one you want.
elementFromPointdoes the hit-testing, so the finger may wander off the button, past the edge, and back again without losing the gesture. - Preview versus commit — the ring that follows the thumb is a preview only:
aria-checkedandonValueChangekeep speaking about the committed value, so sliding across five options is one change event, not five. The arrow keys are the opposite case — they really do move the selection, the way a radiogroup does, they just refuse to collapse the row while you browse. - Reveal against the visual viewport —
innerHeightdoes not shrink when the software keyboard opens, so measuring against it would cheerfully expand a panel underneath the keyboard. The check usesvisualViewport, re-runs when a keyboard-sized shrink covers the row — not when the URL bar collapses mid-scroll — and scrolls the least it can. - Inert while collapsed — a zero-height panel whose buttons are still tabbable is an invisible keyboard trap;
inertcloses it, and collapsing hands focus to the row first so it never lands on<body>. - Refusal that stays on screen —
lockedkeeps the row focusable and announced witharia-disabledand prints the reason next to it, instead of the phone-hostile pattern of hiding the explanation in a hover tooltip.
Chip Scroller
A one-line filter chip rail that snaps under the thumb, fades at whichever edge still has chips behind it, and pins the off-screen count where it stays reachable.
Phone Number Input
A mobile phone field that regroups digits as you type, picks the country from a bottom sheet, and normalises pasted international numbers into E.164.