All patterns

Route Progress Top

A top-edge bar advances during navigation and completes with a quick finish.

navigationminimalsubtleautomatic · finite · starter · ~1.6s
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.

315 lines · react + motion only
import { useEffect, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Route Progress Top
 *
 * The thin bar along the top edge that covers a navigation. It leaps to
 * a visible head start, trickles toward — never to — the end while the
 * next view is being fetched, then completes in a quick beat and gets out
 * of the way. The first navigation runs on mount; the links start more.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Surfaces are mixed from the inherited text color, so the frame reads
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type RouteProgressTopProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Route shown once the first navigation completes. */
  defaultIndex?: number;
  /** Notified when a navigation completes, with the new index. */
  onNavigate?: (index: number) => void;
};

type VariantConfig = {
  /** Seconds the trickle takes to crawl to its ceiling. */
  trickle: number;
  /** Fraction of the track the trickle is allowed to reach. */
  ceiling: number;
  /** Seconds of simulated fetching before the view is ready. */
  fetchSeconds: number;
  /** Seconds the completing run takes. */
  finish: number;
  /** px of accent glow around the bar. */
  glow: number;
};

// Quality rule: nothing here springs. A progress bar that overshoots has
// claimed to be more finished than it is, so the trickle is a long
// ease-out that decelerates toward its ceiling and the completion is a
// short ease-out that lands exactly on the end. Variants change the
// pacing, never the shape.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Barely there. For an app that navigates constantly.
  subtle: {
    trickle: 2,
    ceiling: 0.84,
    fetchSeconds: 0.9,
    finish: 0.16,
    glow: 0,
  },
  // The all-purpose setting: readable without asking for attention.
  default: {
    trickle: 2.4,
    ceiling: 0.9,
    fetchSeconds: 1.1,
    finish: 0.18,
    glow: 6,
  },
  // A brighter head with a longer crawl, for a marketing-side shell.
  playful: {
    trickle: 2.8,
    ceiling: 0.94,
    fetchSeconds: 1.3,
    finish: 0.22,
    glow: 10,
  },
};

const ACCENT = "#7C7CF0";

/** Theme-adaptive neutral: `currentColor` is the text color this component
 *  inherits — near-black on a light page, near-white on a dark one — so
 *  mixing it with `transparent` yields a surface, border or fill that is
 *  correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

/** Decelerating hard: fast off the mark, then a crawl. The whole lie of a
 *  trickle is in this curve. */
const TRICKLE_EASE: [number, number, number, number] = [0.06, 0.86, 0.28, 1];

const ROUTES = [
  {
    label: "Overview",
    heading: "Revenue is up 12.4%",
    body: "Three of five channels beat their weekly target.",
    rows: ["Direct · $18,240", "Marketplace · $9,120", "Partners · $4,860"],
  },
  {
    label: "Invoices",
    heading: "6 invoices open",
    body: "Two are past due by more than a week.",
    rows: ["INV-10482 · $248.00", "INV-10479 · $1,150.00", "INV-10476 · $64.00"],
  },
  {
    label: "Usage",
    heading: "82% of plan used",
    body: "At this rate the plan tops out on the 26th.",
    rows: ["API calls · 1.9M", "Storage · 412 GB", "Seats · 18 of 25"],
  },
] as const;

type Phase = "loading" | "completing" | "idle";

export default function RouteProgressTop({
  variant = "default",
  defaultIndex = 0,
  onNavigate,
}: RouteProgressTopProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const [route, setRoute] = useState(defaultIndex);
  const [pending, setPending] = useState<number | null>(defaultIndex);
  // The first navigation starts on mount: the pattern is what a page
  // looks like while it is arriving, so it should be arriving.
  const [phase, setPhase] = useState<Phase>("loading");

  // Fetch finishes → commit the view and let the bar complete.
  useEffect(() => {
    if (phase !== "loading") return;
    const timer = setTimeout(() => {
      setRoute((current) => pending ?? current);
      setPending(null);
      setPhase("completing");
      if (pending !== null) onNavigate?.(pending);
    }, cfg.fetchSeconds * 1000);
    return () => clearTimeout(timer);
  }, [phase, pending, cfg.fetchSeconds, onNavigate]);

  // Completed → after the bar has run out and faded, reset it to zero
  // off-screen so the next navigation starts from the left edge again.
  useEffect(() => {
    if (phase !== "completing") return;
    const timer = setTimeout(() => setPhase("idle"), 460);
    return () => clearTimeout(timer);
  }, [phase]);

  const navigate = (index: number) => {
    // Re-navigating to the route you are already on still runs the bar —
    // that is what a router does, and hiding it would make the app look
    // unresponsive to a deliberate click.
    setPending(index);
    setPhase("loading");
  };

  // Reduced motion: the bar still reports the three states — started,
  // finishing, gone — it just stops crawling to do it.
  const bar = {
    loading: {
      target: { scaleX: reduceMotion ? 0.35 : cfg.ceiling, opacity: 1 },
      transition: {
        scaleX: reduceMotion
          ? { duration: 0 }
          : { duration: cfg.trickle, ease: TRICKLE_EASE },
        opacity: { duration: 0.08 },
      },
    },
    completing: {
      target: { scaleX: 1, opacity: 0 },
      transition: {
        scaleX: { duration: reduceMotion ? 0 : cfg.finish, ease: "easeOut" as const },
        // The fade waits for the run to land: a bar that disappears
        // before it reaches the end reads as a failure.
        opacity: { duration: 0.24, delay: reduceMotion ? 0.12 : cfg.finish + 0.06 },
      },
    },
    idle: {
      target: { scaleX: 0, opacity: 0 },
      // Invisible already, so the rewind is instant rather than animated.
      transition: { duration: 0 },
    },
  }[phase];

  const current = ROUTES[route];
  const active = pending ?? route;

  return (
    <div
      style={{
        position: "relative",
        width: 336,
        height: 252,
        borderRadius: 18,
        background: tone(6),
        color: "inherit",
        border: `1px solid ${tone(12)}`,
        boxShadow: "0 14px 36px rgba(0,0,0,0.16)",
        overflow: "hidden",
      }}
    >
      {/* The bar is pinned to this frame's top edge so the pattern works
          inside a card. In an app shell, give it `position: fixed; top: 0;
          left: 0; right: 0` and a z-index above your header — everything
          else about it is unchanged. */}
      <motion.div
        role="progressbar"
        aria-label="Loading the next view"
        aria-hidden={phase === "idle"}
        initial={{ scaleX: 0, opacity: 0 }}
        animate={bar.target}
        transition={bar.transition}
        style={{
          position: "absolute",
          top: 0,
          left: 0,
          right: 0,
          height: 2.5,
          zIndex: 3,
          // Scale, not width: one compositor property, so the bar keeps
          // its frame rate however busy the page under it is.
          transformOrigin: "0% 50%",
          background: ACCENT,
          boxShadow: cfg.glow ? `0 0 ${cfg.glow}px ${ACCENT}` : "none",
        }}
      />

      <div
        style={{
          display: "flex",
          alignItems: "center",
          gap: 4,
          padding: "12px 12px 10px",
          borderBottom: `1px solid ${tone(12)}`,
        }}
      >
        <span style={{ fontSize: 12.5, fontWeight: 650, marginRight: 6 }}>
          Acme
        </span>
        {ROUTES.map((entry, index) => {
          const isActive = index === active;
          return (
            <button
              key={entry.label}
              type="button"
              onClick={() => navigate(index)}
              aria-current={index === route ? "page" : undefined}
              style={{
                padding: "5px 9px",
                borderRadius: 8,
                border: 0,
                background: isActive ? tone(10) : "transparent",
                color: "inherit",
                fontFamily: "inherit",
                fontSize: 12,
                fontWeight: isActive ? 600 : 500,
                opacity: isActive ? 1 : 0.55,
                cursor: "pointer",
              }}
            >
              {entry.label}
            </button>
          );
        })}
      </div>

      {/* The view underneath recedes slightly while the next one is on
          its way — the same signal the bar is giving, in the place the
          reader is actually looking. */}
      <motion.div
        animate={{ opacity: phase === "loading" ? 0.45 : 1 }}
        transition={{ duration: reduceMotion ? 0 : 0.2, ease: "easeOut" }}
        style={{ padding: "14px 16px" }}
      >
        <AnimatePresence mode="wait" initial={false}>
          <motion.div
            key={route}
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: reduceMotion ? 0.1 : 0.18, ease: "easeOut" }}
          >
            <div style={{ fontSize: 15, fontWeight: 650 }}>{current.heading}</div>
            <div style={{ fontSize: 12.5, opacity: 0.55, marginTop: 4 }}>
              {current.body}
            </div>
            <div style={{ marginTop: 12 }}>
              {current.rows.map((row) => (
                <div
                  key={row}
                  style={{
                    display: "flex",
                    alignItems: "center",
                    gap: 9,
                    padding: "8px 10px",
                    marginTop: 6,
                    borderRadius: 10,
                    background: tone(7),
                    border: `1px solid ${tone(10)}`,
                    fontSize: 12,
                  }}
                >
                  <span
                    aria-hidden
                    style={{
                      width: 6,
                      height: 6,
                      borderRadius: 999,
                      background: ACCENT,
                      opacity: 0.8,
                    }}
                  />
                  {row}
                </div>
              ))}
            </div>
          </motion.div>
        </AnimatePresence>
      </motion.div>
    </div>
  );
}

About this pattern

The bar that covers the gap between a click and the next view. It works because it is honest about what it does not know: a leap to a visible head start, then a long decelerating crawl toward a ceiling it never reaches, because arriving at the end before the data does is a lie the reader will catch. When the view commits, the bar completes in a short beat, waits for that run to land, and only then fades — a bar that disappears before it reaches the end reads as a failure. It scales rather than resizing, so it costs one compositor property no matter how busy the page beneath it is, and the view underneath dims while the next one is on its way.

Router navigation indicatorApp shell page changeForm submission in progressSlow fetch on a link click

Where it shows up

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

  • Ridgeline
    Docs
    Recent
    Shared
    Templates
    Trash
    DocsNew
    Q3 planning notesEdited 14 minutes agoScope
    Document page

    A slim accent bar runs along the top edge while the next view is fetched.

Related patterns