Progress

Linear progress bar — determinate (0–100) or indeterminate loop.

import { Progress }from "@/components/ui"

Specification

Shows a linear progress bar — determinate for a known 0–100 value, or an indeterminate loop when the value is null.

Props (5)
Every prop this component accepts, its type, default, and how to use it.
PropTypeDefaultDescription
valuenumber | null—Completion percentage (0–100); pass null or omit for an indeterminate loop.
tone"accent" | "warning" | "error""accent"Colors the progress fill.
showValueboolean—Displays the rounded percentage beside the label (determinate only).
labelstring—Text label shown above the bar and used as its aria-label.
classNamestring—Extra classes merged onto the wrapper.

Determinate & indeterminate

Upload68%
Processing…
Full source
The complete component, read from src/components/ui/progress.tsx.
progress.tsx
"use client";

import { cn } from "@/lib/utils";

type ProgressTone = "accent" | "warning" | "error";

const TONE: Record<ProgressTone, string> = {
  accent: "bg-accent",
  warning: "bg-warning",
  error: "bg-error",
};

interface ProgressProps {

  value?: number | null;
  tone?: ProgressTone;

  showValue?: boolean;
  label?: string;
  className?: string;
}

export function Progress({ value, tone = "accent", showValue, label, className }: ProgressProps) {
  const indeterminate = value === null || value === undefined;
  const pct = indeterminate ? 0 : Math.max(0, Math.min(100, value));

  return (
    <div className={cn("w-full", className)}>
      {(label || showValue) && (
        <div className="mb-1.5 flex items-center justify-between text-xs">
          {label && <span className="font-medium text-text">{label}</span>}
          {showValue && !indeterminate && <span className="tabular-nums text-text-secondary">{Math.round(pct)}%</span>}
        </div>
      )}
      <div
        role="progressbar"
        aria-valuenow={indeterminate ? undefined : Math.round(pct)}
        aria-valuemin={0}
        aria-valuemax={100}
        aria-label={label ?? "Progress"}
        className="h-2 w-full overflow-hidden rounded-full bg-bg-active"
      >
        {indeterminate ? (
          <div className={cn("h-full w-1/3 rounded-full", TONE[tone], "animate-[progress-indeterminate_1.2s_ease-in-out_infinite]")} />
        ) : (
          <div
            className={cn("h-full rounded-full transition-[width] duration-300", TONE[tone])}
            style={{ width: `${pct}%` }}
          />
        )}
      </div>
    </div>
  );
}

Dependencies

To use Progress you also need the following. Copy these into your project alongside it.

Recommended folder structure

project structure
src/components/ui/
  └─ progress.tsx
src/lib/
  └─ utils.ts

Dependency files

These supporting files aren’t components — copy each one into the matching path above.

src/lib/utils.ts
utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

/**
 * Merge class names with clsx semantics + Tailwind conflict resolution.
 * Prefer this in all components.
 */
export function cn(...inputs: ClassValue[]): string {
  return twMerge(clsx(inputs));
}

/**
 * Concatenate class names, filtering out falsy values.
 * @deprecated Prefer {@link cn} — it also resolves Tailwind class conflicts.
 */
export function cx(...args: (string | false | null | undefined)[]): string {
  return args.filter(Boolean).join(" ");
}

/**
 * Focus ring style helper.
 * @deprecated Focus rings are now handled globally via the
 * `:focus-visible` rule in `globals.css` and the `focus-visible:*`
 * utilities. This remains only for backwards compatibility.
 */
export function ring(color: string = "var(--color-accent)") {
  return { outlineColor: color };
}