All patterns

Queue Position Advance

Your place in line steps down, the figure swapping upward as the bar of people ahead shortens.

feedbackcalmfriendlyautomatic · finite · starter · ~6.0s
Variant

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.

309 lines · react + motion only
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.

Launch waiting roomTicket sale queueSupport chat lineRate-limited access

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Ridgeline
    Inbox
    Starred
    Drafts
    Archive
    Sent
    InboxNew
    Contract renewalPriya Raman · 10:14
    Q3 hiring planMarcus Bell · 09:02
    Venue confirmed for ThursdayDana Whitfield · Tue
    Invoice 4821 clearedBilling · Tue
    Weekly summaryReports · Mon
    Inbox

    A support conversation shows where you sit in line and roughly how long it will be.

Related patterns