Queue Position
A generation-queue waiting room — a position dial that rolls down through every place it passes, a drain bar measured from where you joined, a self-correcting ETA, an optimistic notify-me switch and an arm-before-you-lose-it leave action.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/queue-position.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "QueuePosition" component: the waiting
room a generation service shows while a job sits in line. lucide-react for
icons, one shadcn Button. No other runtime dependency.
Contract
- forwardRef<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>
minus children; spread the rest onto the root.
- position: number — place in line, 1 = at the front. Fully controlled.
- total?: number — queue length, used only for the "…behind you" context.
- etaSeconds?: number — server estimate; the card ticks it down locally.
- startPosition?: number — where this user entered; the zero point of the
drain bar. Defaults to the first position the card was handed.
- status?: "queued" | "starting" (default "queued"). "starting" means the job
left the queue and a slot is warming up.
- service?: string (default "your request") — woven into the sentences.
- countdown?: boolean (default true), announceEverySeconds?: number
(default 45), showProgress?: boolean (default true).
- notify?: boolean / defaultNotify?: boolean / onNotifyChange?: (next:
boolean) => void | Promise<void> / notifyNote?: string.
- onLeave?: () => void.
- copy?: Partial<Record<"waiting" | "front" | "starting", Partial<{title,
body}>>>, labels?: Partial<{notify, leave, leavePrompt, leaveConfirm,
leaveStay}>.
- Export the derived state as a function too — queuePositionState(position,
status) => "waiting" | "front" | "starting" — so the host can gate its own
cancel affordance with the same predicate the card uses. "front" is derived
from position <= 1; it is never passed in.
Behavior — the queue is server truth, the clock is not
- The card NEVER moves the position by itself. It renders what it is given and
waits for the next push. Inventing a place is a lie the user can catch by
refreshing.
- The ETA is the one thing it owns: a 1s interval decrements it between pushes
so a four-minute wait does not look frozen. Any new etaSeconds wins
immediately, applied as a render-phase adjustment (an effect would paint the
stale estimate for a frame first).
- If the incoming estimate differs from the ticking one by >= 8s, flag it:
show "Recalculating the estimate…" for ~900ms and only then the new wording.
Silently rewriting "4 minutes" to "9 minutes" reads as a glitch.
- If the local clock reaches 0 while the position is still > 1, do NOT claim
"any second": switch to an overdue line ("longer than the estimate — you keep
your place"). A clock stuck at 0:00 destroys trust in every future estimate.
- Round the prose to quarter-minutes ("in about 45 seconds", "in about 4
minutes") so the sentence does not rewrite itself every tick, and put the
per-second mm:ss readout in a corner, aria-hidden.
Behavior — the dial (the signature)
- Keep one frame of state: {value, from, direction, seq}. A change renders the
outgoing number absolutely positioned (so the dial is sized by the incoming
one and a 100 -> 99 swap cannot jog the layout) and the incoming one keyed by
seq, each with its own slide keyframes.
- A DROP counts down through every place it passes: one timer per step, the
step length is PACE(620ms) / places-remaining clamped to 34–190ms, so a long
drop starts fast and decelerates into its final number. Clear the pending
timeout on unmount.
- A RISE (the queue was re-estimated the wrong way) SNAPS. Rolling upwards is
bad news dressed up as a slot-machine reward.
- Snap too when the jump is bigger than ~40 places, and always under
prefers-reduced-motion: the roll is decoration, the value is the function.
- Do the snapping as a render-phase adjustment, not in an effect — an effect
paints the old number for one frame first.
- The digits are aria-hidden. Assistive tech reads the real place from the
headline sentence, so a roll is never spelled out frame by frame.
Behavior — the drain bar
- Measure how far THIS user has come, not how full the queue is: baseline =
max(startPosition ?? first position seen, every position seen since), and
cleared = baseline - position. The ruler only ever grows.
- Consequence, on purpose: an upward re-estimate moves the bar BACKWARDS. That
is the truth; a monotonic bar would hide the fact that the wait grew.
- role="progressbar" with aria-valuemin/max 0–100, aria-valuenow=percent and an
aria-valuetext that speaks the caption ("12 of 20 places cleared since you
joined" / "You're at the front of the queue.").
- At the front and while starting the bar is full and gets a sweeping highlight
(motion-reduce:hidden).
Behavior — notify me
- Render the row only when onNotifyChange is wired: a switch that persists
nothing is a fake affordance, and a controlled notify without a writer is
exactly that — it would announce "notifications on" for a press that changed
nothing and then snap back to the prop.
- Build it as ONE button with role="switch" and aria-checked spanning icon +
label + track, so the whole row is the hit target and the accessible name is
just the label; the reassurance line lives outside the button and is attached
with aria-describedby.
- Flip optimistically. A returned promise puts the row in aria-busy with a
spinner and a one-shot lock (a double click must not send two contradictory
writes); a rejection rolls the switch back, prints one destructive line and
fires an assertive announcement.
- Guard the settle with an alive flag and a run id, so a resolve landing after
unmount — or after a newer toggle — writes nothing.
- Controlled + async: keep the optimistic value after a successful write until
the host's prop agrees with it (clear it in a render-phase check). Dropping it
on resolve flips the switch back under a user whose change never failed.
- Use aria-disabled, never disabled, while pending: disabling the focused
element throws focus to <body>.
Behavior — leaving, and the front of the queue
- Leaving is the one irreversible action here, so it ARMS: the button is
replaced by "Leave the queue? You'd hand back 12th place and rejoin at the
back" + a destructive confirm + a stay. Move focus onto the confirm, let
Escape back out, and auto-disarm after 6s (returning focus only if it was
lost with the confirm button).
- Derive "armed" as armed && leavable instead of resetting it in an effect, so
a half-armed confirmation cannot survive into status="starting", where the
leave affordance no longer exists.
- position <= 1 is its own state: the dial gets a pulse, the headline becomes
"You're next in line", the ETA line becomes "starts any second", and the
card announces it exactly once.
- status="starting" replaces the dial with a spinner, drops the crowd line and
the leave action, and announces once.
Behavior — announcements
- Two sr-only regions: role="status" aria-live="polite" for progress,
role="alert" for write failures. Key each message by an incrementing id so a
repeated sentence is spoken again.
- Announce PLACES, not seconds. A live region tied to the ETA restarts a screen
reader sixty times a minute. Only a CHANGED position may speak, at most once
per announceEverySeconds; remember the new place without resetting the clock
so the next window opens on schedule.
- The first reading is silent — whatever surface mounted the card already
introduced it. "You're next", "starting now" and "overdue" are one-shots that
re-arm when the state leaves and comes back.
Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground / border, bg-muted and
bg-muted/40 for the resting dial, bg-primary + primary/10 + border-primary/40
for the front, at-the-front and on states, bg-primary-foreground/40 for the
sweep, text-muted-foreground for every secondary line, text-destructive for
the failed write. No hex, no rgb(), no oklch().
- cn() merges the consumer's className into the root; data-state is
"waiting" | "front" | "starting" so hosts can restyle per state.
- Keyframes (two slide-ins, two slide-outs, one sweep) ship with the component
through a React 19 hoisted <style href precedence>, so duplicates dedupe and
no Tailwind config edit is needed.
- Every decorative animation is off under prefers-reduced-motion
(motion-reduce:hidden / motion-reduce:animate-none / a plain swap in the
dial); nothing about the readout depends on motion.
- Focus-visible ring on both the switch and the buttons; the numbers use
tabular-nums so a roll does not shift the layout.
Customization levers
- Roll feel: PACE (620ms) plus the 34–190ms clamp is the whole motion budget —
raise PACE for a slower, more ceremonial roll, drop the clamp range for a
snappier one, or set MAX_ROLL_STEPS to 0 to make every change a plain swap.
- Estimate policy: the 8s recalculation threshold and the 900ms flash; set
countdown={false} if your backend pushes a fresh ETA every second, and swap
etaPhrase() for your own rounding (or a locale-aware RelativeTimeFormat).
- Which blocks exist: showProgress={false} strips the bar to a bare readout for
a chat message; omit onNotifyChange to drop the switch; omit onLeave to drop
the leave action. Each block is one conditional at the top level.
- Wording: `service` reskins every sentence ("your video render", "this
export"); `copy` overrides title/body per state; `labels` covers the switch
and the three leave buttons. Localise by passing both.
- Density: root gap-3.5 / p-4 and the h-14 min-w-16 dial are the only size knobs
— a 6xl dial for a full-page waiting room, or text-xs everywhere for a
sidebar. The dial steps its own type down at 3 and 4 digits so a popular queue
cannot clip; move those breakpoints if your positions run longer.
- Announcement floor: announceEverySeconds (45) up for calm, down to ~10 for a
fast-draining queue; the one-shots stay one-shots either way.
- Semantics of the bar: pass startPosition to anchor the drain to a server-side
entry point (survives a reload) instead of the first position this mount saw.Concepts
- Count down, never up — a drop is animated through every place it passes because that motion is the good news, while an upward re-estimate snaps: rolling the wrong way turns a longer wait into a slot-machine reward, and under reduced motion both are a plain swap.
- Server truth vs the local clock — the position only ever comes from a push, but the estimate ticks locally between pushes so the card looks alive; a fresh estimate always wins, and one that disagrees materially says "recalculating" before it rewrites the sentence.
- Drain from the entry point — the bar measures how far this user has come (entry position → now), not how full the queue is, which means it is allowed to give ground back when the queue is re-estimated upward instead of hiding that the wait grew.
- An expired estimate is not a promotion — when the local clock reaches zero and the place is still > 1 the card says the wait is running long and that the place is kept, rather than parking on "any second now" and burning the credibility of every later estimate.
- Optimistic switch with a rollback — "notify me" flips the instant it is pressed, locks itself while the write is in flight, and rolls back with one line of explanation if the host rejects it; a resolve that lands after unmount or after a newer toggle writes nothing.
- Arm before you lose it — the only irreversible action on the card confirms in place, takes focus, expires after six seconds, and is derived rather than reset, so a half-armed confirmation can never survive into the state where there is no queue left to leave.
Thinking Indicator
A transcript-line thinking status — a highlight sweeping the label, a phase walk that never lies about progress, a clock that only appears once the wait is real, and a fade-out that hands the slot to the answer.
Conversation Title
An auto-generated chat title — a sparkle provenance hint, click-to-edit rename with a length counter, and a regenerate button that shimmer-cycles candidate names before settling.