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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/stream-stop-bar.jsonPrompt
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
streamingis 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.
Source Sheet
A citation opened as a bottom sheet instead of a popover — the passage in the thumb arc, the cited line lifted clear of the sheet, and a way back to where you were reading.
Snackbar
A bottom-edge message bar with one action, swipe-to-dismiss and a single-slot queue that never lets two share the thumb arc.