Mobile

Prompt Rail

A one-line rail of conversation starters that scrolls under the thumb and folds itself away — on a flick, on a key, or the moment the thread starts.

Preview in your theme

Loading preview…

"use client"

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

export interface PromptRailItem {
  /** Stable identity — also the React key. */
  id: string
  /** The chip face. Truncates in `chips`, wraps to two lines in `cards`. */
  label: string
  /** Second line. `cards` renders it; `chips` ignores it. */
  hint?: string
  /** Decorative leading glyph. It is aria-hidden and never part of the accessible name. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/prompt-rail.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "PromptRail" component (Pointer Events and
one animated grid row — no animation or gesture library).

Contract
- Export a forwardRef <div> extending React.HTMLAttributes<HTMLDivElement>.
- items?: { id, label, hint?, icon?: ReactNode, prompt?, disabled?,
  disabledReason? }[] (default []). `hint` is rendered by the "cards" variant
  only; `prompt` is what onSend receives and falls back to `label`.
- Fold state is controlled or uncontrolled: folded?: boolean, defaultFolded?:
  boolean (default false), onFoldedChange?: (folded: boolean) => void. Every
  path — flick, cuff, Escape, auto-fold after a send — goes through the same
  request, so a controlled owner can refuse it.
- onSend?: (item, text) => void. The component never sends anything itself;
  the consumer owns the composer, the request and the transcript.
- variant?: "chips" | "cards" (default "chips").
- foldTo?: "cuff" | "peek" | "gone" (default "cuff") — what is left behind.
- foldOnSend?: boolean (default true), swipeToFold?: boolean (default true),
  allowMouseDrag?: boolean (default false), docked?: boolean (default false).
- edgeInset?: number | string (default "0.75rem"), label?: string,
  labels?: Partial<{ show, hide, shown, hidden, unavailable }>,
  formatCount?/formatMore?/formatSent? for the cuff face, the peek opener and
  the spoken confirmation.

Behavior
- Empty items renders nothing at all (return null): an opener that opens onto
  nothing is a lie.
- Fold geometry is one grid row: the fold wrapper is `display: grid` with
  gridTemplateRows going 1fr (natural height) -> 0fr (nothing), and the single
  child is `min-h-0 overflow-hidden`. A drag paints the fraction in between
  (`0.62fr`), so nothing ever has to measure the content or keep a pixel height
  in sync with it. Opacity fades to 0.1 across the same travel.
- Flick to fold (Pointer Events only, one pointerId owns the gesture):
  pointerdown records the start point plus the rail's current offsetHeight;
  after 10px of movement the gesture is claimed once — downwards AND more
  vertical than horizontal — and anything else is dropped for the rest of the
  gesture so it can never steal the rail's own horizontal scroll. On claiming,
  re-baseline the start Y (the fold starts at 0, not at the claim distance),
  setPointerCapture on the element the press started on, and stop transitioning.
  Progress = clamp(dy / height, 0, 1), written through one rAF per frame.
  Velocity is smoothed (v = 0.3*v + 0.7*sample). On release, fold when the fling
  is faster than 0.45px/ms downwards, or when travel passed 35% and it was not
  flicked back up; otherwise it springs back. pointercancel always springs back.
- touch-action does the negotiation, not preventDefault: the scroller is
  `touch-action: pan-x` while swipeToFold is on, so the browser keeps horizontal
  panning (native momentum) and leaves the vertical axis free for the fold.
  Because of that, a horizontal pan arrives as a pointercancel rather than as a
  fight. Nothing calls preventDefault on a passive listener. With swipeToFold
  off the scroller is `touch-action: auto` and only the cuff and Escape fold it.
- A drag that started on a chip must not end as a tap on it: set a suppress flag
  when the drag is claimed and swallow the next click in a capture-phase
  handler; clear the flag on every pointerdown.
- Sending: pressing a chip announces and calls onSend(item, item.prompt ?? label),
  then folds when foldOnSend is on — the thread has started, so the row it was
  eating goes back to the transcript. A ref read AND written inside the handler
  stops one press being delivered twice in the same frame; it is released on the
  next commit, because retiring the rail is the fold's job, not the latch's.
- Disabled items use aria-disabled plus a handler guard, never the native
  attribute (which would blur a focused control), and pressing one announces
  `disabledReason` instead of silently doing nothing.
- Keyboard map: Tab reaches exactly one chip (roving tabindex following the last
  touched chip, re-derived every render so a vanished chip cannot lock the rail
  out); ArrowLeft/ArrowRight walk the row and scroll the focused chip into view
  by writing scrollLeft directly (scrollIntoView would also scroll the page);
  Home/End jump to the ends; Enter/Space send; Escape folds. The cuff is a
  disclosure button (aria-expanded + aria-controls on the fold region) and takes
  ArrowUp to open / ArrowDown to fold.
- Focus is handed over on purpose in both directions: when the rail folds with
  focus inside it, focus moves to the cuff — or, when foldTo="gone" leaves no
  cuff, to the root (tabIndex={-1}) — never to <body>. When the fold is the thing
  that unmounts the cuff, move focus in the handler, before the render that
  removes it, not in an effect afterwards. Opening from the cuff hands focus back
  to the chip the roving index points at. Use aria-hidden + tabIndex={-1} +
  pointer-events-none for the folded region, not `inert`: inert blurs its contents
  the instant it lands, which is the very thing being avoided.
- One polite live region (role="status", aria-live="polite", aria-atomic) says
  the outcomes only — sent, folded, shown, refused — and is cleared after 2.4s so
  the same message can be announced again. It stays silent during the drag.
- Cleanup: the rAF and the live-region timeout are cancelled on unmount, pointer
  capture is released in the same finish path that clears the drag record, and
  the reduced-motion media query is subscribed through useSyncExternalStore
  (unsubscribed automatically, SSR-safe).

Rendering & styling
- Semantic tokens only: bg-card + border for chips, cards and the cuff,
  text-muted-foreground for hints, hover:bg-accent/hover:text-accent-foreground,
  focus-visible:ring-2 ring-ring. The surviving "peek" prompt is the highest
  priority thing on the row, so it INVERTS (bg-foreground text-background)
  instead of taking a colour.
- Sizes follow the thumb: chips are h-11 (44px) with a truncating label capped
  at max-w-72; cards are w-56, two-line (line-clamp-2) label plus hint; the cuff
  is h-11. Type is 13px for faces, 11px for hints.
- Safe area: the row and the cuff pad with
  `max(var(--safe-area-inset-left|right, env(safe-area-inset-left|right, 0px)), edgeInset)`,
  and scroll-padding-inline gets the same value so a snapped card is not parked
  under a rounded corner. docked adds `env(safe-area-inset-bottom)` for a rail
  that is the bottom-most element on the screen.
- The scroller hides its scrollbar ([scrollbar-width:none] + the webkit
  pseudo-element), sets overscroll-x-contain so a flick does not chain into a
  pager or a back gesture, and snaps only in "cards" (fixed-width cards make
  mandatory snapping safe; variable-width chips scroll freely).
- Reduced motion: `motion-reduce:transition-none` on the fold and the opacity,
  the arrow-key reveal switches from smooth to instant scrolling, and the press
  scale is dropped. The fold itself keeps working — it just stops animating.
- Expose data-folded / data-dragging / data-variant on the root and merge the
  consumer className with cn().

Customization levers
- Fold physics: FOLD_RATIO (0.35) is how far is far enough, FLING_VELOCITY
  (0.45px/ms) is how fast counts as a flick regardless of distance, CLAIM_PX
  (10) is how much movement is allowed before a tap becomes a gesture, and the
  300ms ease-out is the settle. Raise the ratio for a rail you do not want
  dismissed by accident.
- Residue: foldTo picks what survives — a counting cuff, the first prompt as an
  inverted chip beside "+N more", or nothing at all when the consumer owns the
  way back. Adding a fourth residue is one branch in the cuff row.
- Presentation: "chips" for a dense row of one-liners, "cards" when each prompt
  needs a hint; card width (w-56), chip cap (max-w-72) and the row gap are the
  density knobs.
- Ownership: leave folded off and the rail owns its own state; pass folded (plus
  onFoldedChange) and the thread owns it — `folded={messages.length > 0}` is the
  whole integration.
- Tokens: swap the inverted peek chip for bg-primary/text-primary-foreground on
  a themed app, or drop the chip border for a flatter row.
- Copy: labels covers the five strings, formatCount / formatMore / formatSent
  the three generated ones — everything spoken or printed is replaceable for i18n.

Concepts

  • Fold-away disclosure — the rail is not a permanent row, it is a row with a lifecycle: worth a line on an empty thread, worth nothing on a busy one. Sending, flicking, Escape and the owner's own state all make the same request, and the component gives the vertical space back to the transcript.
  • Fold by grid fraction — the animation runs on grid-template-rows: 1fr → 0fr with an overflow-hidden child, so a half-finished drag is just 0.62fr. Nothing measures the content, and adding a chip never desynchronises a cached pixel height.
  • Axis negotiation by touch-actiontouch-action: pan-x hands horizontal panning (with its native momentum) to the browser and keeps the vertical axis for the fold. The two never fight, so nothing has to preventDefault a scroll the browser has already committed to.
  • Claim once, then commit — the gesture is judged on its first decisive 10px: downwards and vertical, or dropped for the rest of the press. A gesture that could change its mind halfway would steal the rail's own scrolling right out from under the thumb.
  • Peek residue — folding does not have to mean disappearing: peek keeps the single best prompt alive as an inverted chip that still sends, which is the difference between hiding a feature and retiring it.
  • Deliberate focus successor — the row that is folding may be holding focus, so focus is moved to the cuff (or the root when nothing is left) before it goes. Anything that unmounts or hides itself owes the keyboard a next place to stand, and <body> is not one.

On This Page