Mobile

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.

Preview in your theme

Loading preview…

"use client"

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

/**
 * Grow / shrink duration (ms). The reveal check waits this long so it measures
 * the settled panel instead of a panel that is still one frame tall.
 */
const EXPAND_MS = 220
/** Breathing room kept under the panel when it is scrolled back into the visible viewport. */
const REVEAL_GUTTER = 16
/**

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/inline-picker-row.json

Prompt

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. elementFromPoint does 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-checked and onValueChange keep 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 viewportinnerHeight does not shrink when the software keyboard opens, so measuring against it would cheerfully expand a panel underneath the keyboard. The check uses visualViewport, 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; inert closes it, and collapsing hands focus to the row first so it never lands on <body>.
  • Refusal that stays on screenlocked keeps the row focusable and announced with aria-disabled and prints the reason next to it, instead of the phone-hostile pattern of hiding the explanation in a hover tooltip.

On This Page