Queue Position Advance
Your place in line steps down, the figure swapping upward as the bar of people ahead shortens.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Queue Position Advance
*
* A waiting room that proves it is still moving. Each advance sends the
* old position out through the top and brings the new one up from below
* — upward, because moving up a queue is the thing that is happening —
* while the bar of people ahead shortens by exactly one place and the
* estimate softens behind it.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so the panel reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `startPosition`, `stepMs`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type QueuePositionAdvanceProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Where in line this session starts. */
startPosition?: number;
/** Where the sequence stops. */
endPosition?: number;
/** Gap between advances, in ms. */
stepMs?: number;
/** Heading above the figure. */
title?: string;
/** Line under the bar. Steadies the wait. */
hint?: string;
/** Roughly how long one place takes to clear, in seconds. */
secondsPerPlace?: number;
/** Accent for the bar and the figure. */
accent?: string;
/** Width — px number or any CSS length. */
width?: number | string;
/** Fires when the last advance lands. */
onArrived?: () => void;
};
type VariantConfig = {
/** px the figure travels as it changes. */
swapY: number;
/** Crossfade for one figure handing over to the next. */
fadeSeconds: number;
/** Seconds the bar takes to give up a place. */
barSeconds: number;
spring: { type: "spring"; stiffness: number; damping: number };
};
// The figure is the answer to "how much longer", so it lands rather than
// bounces: damping ratios (ζ = damping / 2√stiffness) stay at or above
// 0.89. Variants change travel and pace, never the settle count.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// ζ ≈ 1.00, a short hop. For a queue widget tucked in a corner.
subtle: {
swapY: 14,
fadeSeconds: 0.14,
barSeconds: 0.4,
spring: { type: "spring", stiffness: 500, damping: 45 },
},
// ζ ≈ 0.95. The all-purpose setting.
default: {
swapY: 22,
fadeSeconds: 0.18,
barSeconds: 0.55,
spring: { type: "spring", stiffness: 420, damping: 39 },
},
// ζ ≈ 0.89, a fuller travel — for a full-screen waiting room where the
// number is all there is to look at.
playful: {
swapY: 30,
fadeSeconds: 0.22,
barSeconds: 0.7,
spring: { type: "spring", stiffness: 340, damping: 33 },
},
};
/** Theme-adaptive neutral: mixing the inherited text color with
* `transparent` lands correctly on a light surface and on a dark one.
* The accent stays literal — it carries meaning. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const ACCENT = "#7C7CF0";
const FIGURE_HEIGHT = 40;
/** Coarse on purpose: a queue that quotes seconds invites a stopwatch. */
function estimate(places: number, secondsPerPlace: number) {
if (places <= 0) return "It's your turn";
const minutes = Math.round((places * secondsPerPlace) / 60);
if (minutes <= 0) return "Under a minute to go";
if (minutes === 1) return "About a minute to go";
return `About ${minutes} minutes to go`;
}
export default function QueuePositionAdvance({
variant = "default",
startPosition = 6,
endPosition = 2,
stepMs = 1500,
title = "You're in the queue",
hint = "Your place is held if you close this tab.",
secondsPerPlace = 42,
accent = ACCENT,
width = 288,
onArrived,
}: QueuePositionAdvanceProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [position, setPosition] = useState(startPosition);
const arrived = position <= endPosition;
const onArrivedRef = useRef(onArrived);
useEffect(() => {
onArrivedRef.current = onArrived;
}, [onArrived]);
useEffect(() => {
if (arrived) {
onArrivedRef.current?.();
return;
}
const timer = setTimeout(() => setPosition((place) => place - 1), stepMs);
return () => clearTimeout(timer);
}, [position, arrived, stepMs]);
const share = startPosition > 0 ? position / startPosition : 0;
const travel = reduceMotion ? 0 : cfg.swapY;
return (
<div
style={{
width,
padding: "15px 16px 16px",
borderRadius: 14,
background: tone(5),
border: `1px solid ${tone(10)}`,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span aria-hidden style={{ display: "inline-flex", opacity: 0.5 }}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
<circle cx="5.4" cy="5.2" r="2.3" stroke="currentColor" strokeWidth="1.3" />
<path
d="M1.9 13c0-2.2 1.6-3.6 3.5-3.6s3.5 1.4 3.5 3.6"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
/>
<path
d="M11 5.4h3.2M11 8.2h3.2M11 11h3.2"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
/>
</svg>
</span>
<span style={{ fontSize: 12.5, fontWeight: 600, opacity: 0.75 }}>{title}</span>
</div>
<div
style={{
display: "flex",
alignItems: "baseline",
gap: 8,
marginTop: 8,
}}
>
<span
style={{
fontSize: 15,
fontWeight: 600,
opacity: 0.45,
alignSelf: "center",
}}
>
#
</span>
{/* The figure swaps upward: out through the top, in from below.
Translation and a crossfade only — a place in a queue that
scales as it changes reads as a score, not a position. */}
<span
style={{
position: "relative",
display: "inline-block",
minWidth: "1.1ch",
height: FIGURE_HEIGHT,
overflow: "hidden",
fontSize: 32,
fontWeight: 650,
letterSpacing: -0.5,
lineHeight: `${FIGURE_HEIGHT}px`,
color: accent,
fontVariantNumeric: "tabular-nums",
}}
>
<AnimatePresence initial={false}>
<motion.span
key={position}
initial={{ y: travel, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -travel, opacity: 0 }}
transition={{
y: cfg.spring,
opacity: { duration: cfg.fadeSeconds, ease: "easeOut" },
}}
style={{ position: "absolute", inset: 0, display: "block" }}
>
{position}
</motion.span>
</AnimatePresence>
</span>
<span style={{ fontSize: 12.5, opacity: 0.55 }}>
{position === 1 ? "next in line" : "ahead of you"}
</span>
</div>
{/* The bar gives up exactly one place per advance, so the figure and
the bar can never disagree about how far along this is. */}
<div
role="progressbar"
aria-label="Queue position"
aria-valuemin={0}
aria-valuemax={startPosition}
aria-valuenow={position}
style={{
marginTop: 12,
height: 5,
borderRadius: 3,
background: tone(9),
overflow: "hidden",
}}
>
<motion.div
initial={false}
animate={{ scaleX: share }}
transition={{
duration: reduceMotion ? 0.2 : cfg.barSeconds,
ease: "easeOut",
}}
style={{
height: "100%",
borderRadius: 3,
background: accent,
transformOrigin: "left center",
}}
/>
</div>
<div
style={{
position: "relative",
height: 17,
marginTop: 10,
fontSize: 11.5,
}}
>
<AnimatePresence initial={false}>
<motion.span
key={estimate(position, secondsPerPlace)}
initial={{ opacity: 0, y: reduceMotion ? 0 : 5 }}
animate={{ opacity: 0.6, y: 0 }}
exit={{ opacity: 0, y: reduceMotion ? 0 : -5 }}
transition={{ duration: 0.24, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
display: "block",
lineHeight: "17px",
whiteSpace: "nowrap",
}}
>
{estimate(position, secondsPerPlace)}
</motion.span>
</AnimatePresence>
</div>
<div
style={{
marginTop: 8,
paddingTop: 9,
borderTop: `1px solid ${tone(9)}`,
fontSize: 11,
opacity: 0.45,
}}
>
{hint}
</div>
<span
aria-live="polite"
style={{
position: "absolute",
width: 1,
height: 1,
overflow: "hidden",
clipPath: "inset(50%)",
whiteSpace: "nowrap",
}}
>
{`Position ${position}. ${estimate(position, secondsPerPlace)}.`}
</span>
</div>
);
}About this pattern
A waiting room's only job is to prove it is still moving. Each advance sends the old place out through the top and brings the new one up from below — upward, because moving up a queue is literally what is happening — using translation and a crossfade at one constant size, since a figure that scales as it changes reads as a score rather than a position. The bar gives up exactly one place at the same moment, so the two readings can never disagree, and the estimate under them stays deliberately coarse: a queue that quotes seconds invites a stopwatch. The whole thing is calm by construction — nothing flashes, nothing counts down in red, and closing the tab is explicitly safe.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Inbox
A support conversation shows where you sit in line and roughly how long it will be.
Related patterns
- Connection RestoredThe offline bar turns green, confirms, and retracts in one continuous move.
- Undo SnackbarA removed row leaves a bar behind, and a hairline drains across it toward the point of no return.
- Field Valid CheckA small mark strokes itself in at the right edge of a field the moment its value becomes acceptable.