AI

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Bell, BellRing, Clock3, Hourglass, LoaderCircle, Users } from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"

/* -------------------------------------------------------------------------- *
 * Queue Position
 *
 * The waiting room for a service that cannot start your job yet: one card that
 * answers "where am I, how long, can I walk away, can I get out" without the
 * user having to refresh.
 *

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/queue-position.json

Prompt

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.

On This Page