Mobile

Stream Stop Bar

A bottom-edge run bar for a streaming answer — it mints the AbortSignal, stops the run from the thumb arc, climbs above the soft keyboard, and flicks away to uncover the text it covers.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check, ChevronDown, RotateCcw, Square, TriangleAlert, X } from "lucide-react"
import { cn } from "@/lib/utils"

/**
 * The indeterminate sliver that rides the top edge while tokens arrive. React 19
 * hoists <style href> into the head and de-dupes by href, so several bars on one
 * page still share a single keyframes block.
 */
const KEYFRAMES = `@keyframes zy-stream-stop-bar-slide{0%{transform:translateX(-110%)}100%{transform:translateX(320%)}}`

/** Clock resolution. Every tick re-aims at the next whole second, so the reading never drifts. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/stream-stop-bar.json

Prompt

Build a React + TypeScript + Tailwind "StreamStopBar" component (lucide-react for icons; no other
runtime dependency). It is the bottom-edge run control for a streaming answer on a phone: it owns the
run's AbortController, it stops the run from the thumb arc, it stays above the soft keyboard, and it
can be flicked down out of the way of the text it covers.

Contract
- export const StreamStopBar = forwardRef<HTMLDivElement, StreamStopBarProps>; the rest of the native
  div props spread onto the root, className merged with cn().
- type StreamRunStatus = "idle" | "streaming" | "stopped" | "done" | "error".
- type StreamStopBarVariant = "bar" | "pill" | "sheet".
- Two controllable axes, each supporting controlled and uncontrolled use:
    status / defaultStatus = "idle" / onStatusChange
    tucked / defaultTucked = false / onTuckedChange
- Run callbacks: onRun(signal: AbortSignal), onStop(), onRegenerate(), onDismiss().
- Data props: tokens?: number (omitted = no counter, 0 = a real "nothing came back"),
  startedAt?: number (epoch ms — the injected instant the clock is derived from),
  errorMessage?: string, preview?: ReactNode (sheet variant only, the tail of the answer).
- Chrome props: variant = "bar", trackKeyboard = true,
  returnFocusRef?: RefObject<HTMLElement | null>, label = "Response controls",
  labels?: Partial<StreamStopBarLabels> with streaming / stopped / done / error / stop / regenerate /
  retry / dismiss / collapse / expand.
- status "idle" renders null; every other status renders the bar.

Behavior
- Run ownership. A run is exactly one visit to "streaming". Entering it mints a fresh AbortController
  in an effect and passes controller.signal to onRun. The effect cleanup aborts, which covers all
  three exits: the reader stopping, the consumer moving the status on, and the component unmounting
  with a request still open. Document that React StrictMode runs effects twice in development, so
  onRun must be idempotent.
- Stopping is one-shot. A ref is read AND written inside the click / key handler before the abort, so
  a double tap, or a tap racing Escape, can never abort twice or fire onStop twice. Stop then sets
  status "stopped"; a controlled consumer that refuses the change keeps a bar that is already aborted,
  and the guard keeps it that way.
- Clock. Elapsed time comes from startedAt (or an instant stamped when the run starts), never from a
  wall-clock read during render. A self-rescheduling timeout re-aims at the next whole second after
  every tick, so it does not drift; it runs only while streaming, so the last value stays on screen as
  the run's duration. The clock is hidden entirely when the bar was mounted straight into a settled
  status and never timed anything — "0s" would be a lie. Format: "4s" under a minute, "1:07" over it,
  tabular-nums. Token counts compact at 1000 ("1.2k").
- Soft keyboard. When trackKeyboard is on, subscribe to window.visualViewport (resize + scroll) plus
  window orientationchange and compute the occlusion as
  layoutHeight - (offsetTop + height) * scale, clamped at 0 and rounded; treat anything under ~120px
  as no keyboard. Apply it as the wrapper's bottom padding so the bar climbs above the keys — on iOS
  100vh, innerHeight and bottom: 0 all keep pointing underneath them. Commits are coalesced to one per
  animation frame, and every listener plus the pending frame is removed on unmount and when the option
  flips.
- Tuck gesture (bar and sheet; the pill has no second row, so it offers no gesture). Pointer Events
  only, on the status strip, with touch-action: none so the browser never scrolls behind the drag —
  which is also why nothing here calls preventDefault. A press below 4px of movement is still a tap;
  past that the strip takes setPointerCapture on itself, the offset follows the finger through a
  rAF-coalesced state write, and dragging past either end is damped (0.3x, max 24px). On release: a
  flick faster than 0.4px/ms commits in its own direction, otherwise crossing 40% of the travel
  commits. pointercancel returns to the current stop. The travel is measured as
  panel.offsetHeight - actions.offsetTop + wrapper padding-bottom - panel padding-bottom, using
  offset* rather than rects because those ignore the panel's own transform — measured with rects
  while tucked, the travel would shrink a little every time. Leaving the panel's own bottom padding
  out of the travel is what keeps a tucked sheet's strip above the home indicator instead of on it. A
  ResizeObserver on wrapper, panel and action row re-measures when the status changes the row's
  height or the keyboard changes the padding.
- Keyboard map. Escape anywhere inside the bar stops the run (and only while streaming; it is handled
  on the bar with stopPropagation so a dialog above it is not closed by the same press). The chevron
  is a disclosure button: Enter / Space toggles, ArrowDown tucks, ArrowUp restores. Tab reaching any
  control parked below the fold un-tucks the bar instead of leaving a focus ring where nobody can see
  it. Every gesture therefore has a visible control and a key behind it.
- Dismiss. Focus moves to returnFocusRef (when it is still connected) BEFORE the status becomes
  "idle" and the bar unmounts; the other order drops focus on <body>.
- Safe area. Floating variants pad the wrapper with calc(0.75rem + env(safe-area-inset-bottom)). The
  sheet variant reaches the physical edge instead, so the inset lives on the panel itself
  (pb-[env(safe-area-inset-bottom)]): its surface fills the indicator band while its content stays
  clear of it, tucked or not. The keyboard inset replaces the wrapper's padding while the keyboard is
  up — the keys already cover the indicator.
- Edge cases: no tokens prop (no counter), 0 tokens, a long error reason (clamped to two lines rather
  than pushing the buttons off screen), a long custom status label (truncated, never wrapped), a
  consumer refusing either controlled change (the bar springs back), and no visualViewport API at all
  (the climb is simply skipped).

Rendering & styling
- Semantic tokens only, monochrome first: bg-card / text-card-foreground for the panel, border,
  bg-muted and text-muted-foreground for the meta line, bg-background under the secondary button. The
  one high-priority control inverts — bg-foreground text-background — instead of taking a colour.
  Colour is reserved for real semantics: text-destructive for the failure reason and its icon.
- Root: absolute inset-x-0 bottom-0, pointer-events-none, with pointer-events-auto on the panel so the
  transcript beside a narrow pill stays tappable. Pass `fixed` through className when the bar belongs
  to the app shell rather than to a card.
- Radii ladder: rounded-2xl panel (rounded-full for the pill, flat bottom for the sheet), rounded-lg
  buttons. Type ladder: 13px semibold labels, 11–12px meta and reasons.
- Touch: the status strip is at least 44px tall, icon buttons are 44px squares, the primary action is
  48px. Nothing depends on hover.
- Motion: the panel transitions transform over 260ms with a cubic-bezier(0.32, 0.72, 0, 1) ease, and
  the transition is switched off while a finger is on it. A pulsing dot and a sliding indeterminate
  sliver mark the streaming status; both are decorative, so prefers-reduced-motion drops the animation
  and the tuck settles instantly — the feature keeps working.
- ARIA: the root is a labelled region carrying data-status and data-tucked; the chevron is a real
  button with aria-expanded and aria-controls pointing at the action row; icons are aria-hidden and
  icon-only buttons carry aria-label; a polite, atomic role="status" region announces the start and
  the outcome of a run (never the token count, which would be a screen-reader storm). No control is
  ever natively disabled.

Customization levers
- Variant: keep one, two or all three presentations (bar / pill / sheet). Dropping the pill removes
  the icon-only branch; dropping the sheet removes the grab handle and the preview slot.
- Gesture physics: DRAG_START_PX (tap vs drag), FLING_VELOCITY, TUCK_RATIO, RUBBER_BAND and its cap,
  SETTLE_MS and the easing. Raising TUCK_RATIO makes the tuck harder to trigger by accident.
- Keyboard: KEYBOARD_THRESHOLD, or drop useKeyboardInset entirely if your shell already resizes the
  layout viewport (Android's interactive-widget=resizes-content does).
- Blocks: the token counter, the clock, the indeterminate sliver, the preview line and the Dismiss
  button are each independent — remove any of them without touching the state machine.
- Copy: every string goes through `labels`, so i18n never needs a fork.
- Colour: the inverted primary can become bg-primary/text-primary-foreground if the product wants the
  brand on its most important control; keep the failure reason on text-destructive either way.
- Density: the 44/48px targets and the 0.75rem/1rem safe-area gaps are the only spacing constants
  worth moving, and they should move together with the type ladder.

Concepts

  • Run ownership — the bar, not the composer, holds the AbortController. One visit to streaming is one run, and its signal dies with the run whichever way it ends: the reader stopping it, the consumer settling it, or the component unmounting.
  • One-shot stop — the guard is a ref read and written inside the same handler, so a double tap and a tap racing the Escape key still produce exactly one abort and one onStop.
  • Thumb-arc parking — the controls live in the bottom band a thumb reaches without regripping, which is also the band the answer is written into; that conflict is what the tuck gesture exists to resolve.
  • Keyboard climb — the soft keyboard shrinks only the visual viewport, so a bottom-pinned bar has to read the occlusion itself and pad by it. Everything simpler (100vh, innerHeight, bottom: 0) keeps pointing under the keys.
  • Tuck travel — the distance the panel sinks is measured from the top of the action row to the wrapper's bottom edge with offsetTop / offsetHeight, which are immune to the transform already applied; measuring rects instead would shorten the travel a little on every gesture.
  • Focus succession — nothing disappears while holding focus: Dismiss moves focus to a deliberate successor first, and a Tab that lands on a tucked control brings the bar back rather than hiding the focus ring.

On This Page