← All components

Progress

How far a task has got, as an ink fill that slides — or a sweep, while the end is unknown.

Uploading plates18%
Importing statements3 of 8 files
Preparing the export

The idea

Not a Meter, and the difference is the point. A meter reports a measurement that already exists, on the value scale, where the colour says whether that much is good. Progress reports something still happening: it only ever means done-so-far, so it is plain ink, and it has a state a measurement never has — running with no known end. The fill slides by a transform on expo-out and never springs, because a bar that overshoots claims, for a moment, more done than is.

Install

terminal1 line
npm i mihirmodi-ui motion

The tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.

Usage

Progress — usage14 lines
import { Progress } from "mihirmodi-ui";

<Progress label="Uploading plates" value={percent} showLabel />

<Progress
  label="Importing statements"
  value={done}
  max={8}
  format={(v, max) => `${v} of ${max} files`}
  showLabel
/>

// No value: the end is not known yet, so it sweeps.
<Progress label="Preparing the export" />

Props

PropNotes
valuenumber | nullHow far along. Leave it out while the end is not known, for the sweep.
maxnumberWhat done is.
labelstringRequired: what is in progress. The accessible name.
showLabelbooleanShow the label above the bar, with the amount at the right.
format(value, max) => stringHow the amount is written — “3 of 8 files”. Also read out as the value text.

Source

Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.

packages/ui/src/components/data/Progress.tsx95 lines
"use client";

import { LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import { EASE_OUT_EXPO } from "../../lib/motion.js";

export type ProgressProps = {
  /** How far along. Leave it out (or pass `null`) while the end is not known yet. */
  value?: number | null;
  max?: number;
  /** The accessible name — what is in progress. Required: a bar alone does not say. */
  label: string;
  /** Show the label above the bar, with the amount at the right. */
  showLabel?: boolean;
  /** How the amount is written, beside the bar and to a screen reader. Defaults to a percentage. */
  format?: (value: number, max: number) => string;
  size?: "sm" | "md";
  className?: string;
};

/**
 * How far a task has got — an upload, an import, a build.
 *
 * It is not a `Meter`, and the difference is the point. A meter reports a
 * measurement that already exists — storage used, a score, a share — so it is
 * `role="meter"` and drawn on the value scale, where the colour at its tip says
 * whether that much is good. Progress reports something still happening: it is
 * `role="progressbar"`, it only ever means "done so far", and it is a plain ink
 * fill, because a task half-finished is neither good nor bad, only unfinished.
 * It also has a state a meter cannot: running with no known end, which a
 * measurement never is.
 *
 * The fill is the whole track's width, slid in from the left by a transform
 * rather than grown by its width, so moving it costs no layout and its rounded
 * end keeps its shape at every value. It tweens on expo-out and never springs:
 * a bar that overshoots claims, for a moment, more done than is. While the end
 * is unknown a short segment sweeps across instead; under reduced motion it
 * holds still and breathes in opacity, which says "working" without travel.
 */
export function Progress({ value = null, max = 100, label, showLabel = false, format, size = "sm", className = "" }: ProgressProps) {
  const reduce = useReducedMotion();
  const known = value !== null && Number.isFinite(value);
  const v = known ? Math.max(0, Math.min(max, value as number)) : 0;
  const pct = max > 0 ? (v / max) * 100 : 0;
  const text = known ? (format ? format(v, max) : `${Math.round(pct)}%`) : undefined;
  const h = size === "md" ? "h-2" : "h-1.5";

  return (
    <LazyMotion features={domAnimation} strict>
      <div className={className}>
        {showLabel && (
          <div className="mb-2 flex items-baseline justify-between gap-3 text-[12px] leading-none">
            <span className="truncate text-muted">{label}</span>
            {text && <span className="shrink-0 tabular-nums text-gray-1000">{text}</span>}
          </div>
        )}
        <div
          role="progressbar"
          aria-label={label}
          aria-valuemin={known ? 0 : undefined}
          aria-valuemax={known ? max : undefined}
          aria-valuenow={known ? v : undefined}
          aria-valuetext={text}
          aria-busy={!known || undefined}
          className={`relative w-full overflow-hidden rounded-full bg-gray-alpha-200 ${h}`}
        >
          {known ? (
            <m.span
              aria-hidden
              className="absolute inset-0 rounded-full bg-ink"
              initial={false}
              animate={{ x: `${pct - 100}%` }}
              transition={reduce ? { duration: 0 } : { duration: 0.6, ease: EASE_OUT_EXPO }}
            />
          ) : reduce ? (
            <m.span
              aria-hidden
              className="absolute inset-0 rounded-full bg-ink"
              initial={{ opacity: 0.2 }}
              animate={{ opacity: [0.2, 0.45, 0.2] }}
              transition={{ duration: 2, ease: "easeInOut", repeat: Infinity }}
            />
          ) : (
            <m.span
              aria-hidden
              className="absolute inset-y-0 left-0 w-2/5 rounded-full bg-ink"
              initial={{ x: "-100%" }}
              animate={{ x: ["-100%", "250%"] }}
              transition={{ duration: 1.3, ease: [0.65, 0, 0.35, 1], repeat: Infinity }}
            />
          )}
        </div>
      </div>
    </LazyMotion>
  );
}
packages/ui/src/lib/motion.ts36 lines
/**
 * The motion presets the newer components share. The older ones (Dock,
 * DropMenu, HighlightList) carry their own copies of the same numbers, which is
 * deliberate: each of them documents why its curve is what it is, in place.
 */

/** One slide for anything that re-targets its position. */
export const SLIDE = { type: "spring", bounce: 0.2, duration: 0.4 } as const;

/** Something arriving that should land with a small settle. */
export const LIFT = { type: "spring", bounce: 0.28, duration: 0.5 } as const;

/** The disagreeing pair behind squash-and-stretch: see DropMenu. */
export const GROW = { type: "spring", stiffness: 620, damping: 19, mass: 0.85 } as const;
export const CROSS = { type: "spring", stiffness: 380, damping: 30 } as const;

/** Closing is quick and flat. A bounce on the way out reads as indecision. */
export const CLOSE = { duration: 0.13, ease: "easeIn" } as const;

/** Under reduced motion: a short plain fade, nothing that travels. */
export const FLAT = { duration: 0.15 } as const;

/** Expo-out, for anything that also drives a blur — it must not overshoot. */
export const EASE_OUT_EXPO = [0.16, 1, 0.3, 1] as const;

/** The house curve: overshoots a touch, for transforms only. */
export const EASE_GEIST = [0.175, 0.885, 0.32, 1.1] as const;

/** A block entering a list: fade and 4px. Opacity never springs. */
export function enter(reduce: boolean | null) {
  return {
    initial: reduce ? { opacity: 0 } : { opacity: 0, y: 4 },
    animate: { opacity: 1, y: 0 },
    transition: reduce ? FLAT : { duration: 0.2, ease: EASE_GEIST },
  };
}