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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/prompt-rail.jsonPrompt
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 → 0frwith anoverflow-hiddenchild, so a half-finished drag is just0.62fr. Nothing measures the content, and adding a chip never desynchronises a cached pixel height. - Axis negotiation by touch-action —
touch-action: pan-xhands horizontal panning (with its native momentum) to the browser and keeps the vertical axis for the fold. The two never fight, so nothing has topreventDefaulta 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:
peekkeeps 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.
Hold To Talk
A press-and-hold voice-message button: the strip takes over the composer with a live waveform, sliding up locks the recording hands-free, sliding toward the edge discards it, releasing sends it — and every one of those has a key or a button.
Attachment Tray
The composer's + tray: a source panel that opens at the software keyboard's own height and pushes the composer up instead of covering it, driven by drag or button.