stream/ui
Browse the library

Progress

Shows how far along a task or quota is.

stableProgress.tsx

Examples

Tones

Storage72%
Bandwidth38%
Build minutes91%
progress/tones.tsx
import { Progress } from "@streamui/react";

const bars = [
  { label: "Storage", value: 72, tone: "pink" },
  { label: "Bandwidth", value: 38, tone: "lime" },
  { label: "Build minutes", value: 91, tone: "orange" },
] as const;

export default function ProgressTones() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-5">
      {bars.map((b) => (
        <div key={b.label}>
          <div className="mb-2 flex justify-between text-sm">
            <span className="font-bold">{b.label}</span>
            <span className="font-mono tabular-nums">{b.value}%</span>
          </div>
          <Progress value={b.value} tone={b.tone} aria-label={b.label} />
        </div>
      ))}
    </div>
  );
}

Steps

Use max for anything that isn't a percentage.

Onboarding: step 2 of 5

progress/interactive.tsx
"use client";

import { useState } from "react";
import { Minus, Plus } from "lucide-react";
import { Button, Progress } from "@streamui/react";

export default function ProgressInteractive() {
  const [step, setStep] = useState(2);
  const total = 5;

  return (
    <div className="w-full max-w-xs">
      <p id="onboarding-label" className="mb-2 font-bold">
        Onboarding: step {step} of {total}
      </p>
      <Progress value={step} max={total} tone="blue" aria-labelledby="onboarding-label" />
      <div className="mt-5 flex gap-3">
        <Button
          size="icon"
          variant="secondary"
          className="size-10"
          aria-label="Previous step"
          disabled={step <= 0}
          onClick={() => setStep((s) => s - 1)}
        >
          <Minus aria-hidden />
        </Button>
        <Button
          size="icon"
          variant="secondary"
          className="size-10"
          aria-label="Next step"
          disabled={step >= total}
          onClick={() => setStep((s) => s + 1)}
        >
          <Plus aria-hidden />
        </Button>
      </div>
    </div>
  );
}

Installation

bun add @streamui/react
usage.tsx
import { Progress } from "@streamui/react";

<Progress value={60} aria-label="Upload" />

API

PropTypeDefaultDescription
valuenumber—Current value. Clamped between 0 and max.
maxnumber100Value that means complete.
tone"lime" | "pink" | "blue" | "violet" | "orange""pink"Fill color.
...propsComponentProps<"div">—Native attributes; pass aria-label or aria-labelledby.

Accessibility

  • Uses role="progressbar" with aria-valuenow, aria-valuemin, and aria-valuemax.
  • Give it a name with aria-label or aria-labelledby.
  • The fill animates width only when motion is allowed.

Source

packages/react/src/Progress.tsx
import type { ComponentProps } from "react";
import { cn } from "./cn";
import { toneBg, type Tone } from "./tones";

export type ProgressProps = Omit<ComponentProps<"div">, "children"> & {
  value: number;
  max?: number;
  tone?: Tone;
};

/** Determinate progress bar. Give it a name with `aria-label` or `aria-labelledby`. */
export function Progress({ value, max = 100, tone = "pink", className, ...props }: ProgressProps) {
  const clamped = Math.min(max, Math.max(0, value));
  const percent = max > 0 ? (clamped / max) * 100 : 0;

  return (
    <div
      role="progressbar"
      aria-valuenow={clamped}
      aria-valuemin={0}
      aria-valuemax={max}
      className={cn("h-6 border-2 border-ink bg-paper-sunken shadow-brutal-sm", className)}
      {...props}
    >
      <div
        className={cn(
          "h-full transition-[width] duration-200 ease-out motion-reduce:transition-none",
          percent > 0 && percent < 100 && "border-r-2 border-ink",
          toneBg[tone],
        )}
        style={{ width: `${percent}%` }}
      />
    </div>
  );
}