For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to documentation URLs. Homepage markdown is available at /llms.md/content.md. An agent skill is available at /.well-known/agent-skills/site-skill.md.

Progress

The Progress component from this design system, built on Base UI.

Installation

$ pnpm dlx shadcn@latest add @cobalt/progress

Source

"use client";

import { Progress as ProgressPrimitive } from "@base-ui/react/progress";
import { cn } from "cn";
import * as React from "react";

import { Spinner } from "@/components/ui/spinner";

// The fill changes value on the compositor: a 500ms translate with the soft
// landing curve, so the rail never repaints. Values only, no width animation.
const EASE = "ease-[cubic-bezier(0.22,1,0.36,1)]";

export type ProgressSize = "sm" | "default" | "lg";

// Track thickness in px: 4, 6 (default), 8.
const trackHeights = {
  default: "h-1.5",
  lg: "h-2",
  sm: "h-1",
} as const;

// Ring box and line width in px, plus the spinner step that draws the
// indeterminate state at the same box size.
const ringSizes = {
  default: { box: 24, line: 2.5, spinner: "lg" },
  lg: { box: 32, line: 3, spinner: "lg" },
  sm: { box: 16, line: 2, spinner: "default" },
} as const;

interface ProgressState {
  isIndeterminate: boolean;
  percentage: number;
}

const ProgressStateContext = React.createContext<ProgressState>({
  isIndeterminate: false,
  percentage: 0,
});

function clampPercentage(value: number | null, min: number, max: number) {
  if (value == null) {
    return 0;
  }
  const range = max - min;
  const percentage = range === 0 ? 0 : ((value - min) / range) * 100;
  if (!Number.isFinite(percentage)) {
    return 0;
  }
  return Math.min(100, Math.max(0, percentage));
}

function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
  return (
    <ProgressPrimitive.Label
      data-slot="progress-label"
      className={cn(
        "flex min-w-0 items-center gap-0.5 truncate text-sm font-medium text-foreground",
        className
      )}
      {...props}
    />
  );
}

function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {
  return (
    <ProgressPrimitive.Value
      data-slot="progress-value"
      className={cn(
        "shrink-0 text-sm font-medium tabular-nums text-[color:var(--foreground-tertiary,var(--muted-foreground))]",
        className
      )}
      {...props}
    />
  );
}

function ProgressTrack({
  children,
  className,
  size = "default",
  ...props
}: Omit<ProgressPrimitive.Track.Props, "className"> & {
  className?: string;
  /** Track thickness: 4, 6 (default), or 8px. */
  size?: ProgressSize;
}) {
  return (
    <ProgressPrimitive.Track
      data-slot="progress-track"
      className={cn(
        // The rail keeps an edge on every surface in both themes and the fill
        // draws from the text colour, so a text class recolours it.
        "relative w-full rounded-full bg-border text-ring",
        trackHeights[size],
        className
      )}
      {...props}
    >
      {children}
    </ProgressPrimitive.Track>
  );
}

/** The sweeping band of light for an indeterminate bar. */
function ProgressSweep({ className }: { className?: string }) {
  return (
    // The clip keeps the light inside the rail's length but lets the glow
    // and bloom bleed past its thickness.
    <span
      aria-hidden="true"
      className={cn(
        "absolute inset-0 [clip-path:inset(-8px_0_round_9999px)]",
        className
      )}
    >
      <span className="absolute inset-y-0 left-0 w-2/5 animate-[progress-sweep_1.6s_cubic-bezier(0.65,0,0.35,1)_infinite] motion-reduce:[animation-duration:4s] rtl:[animation-direction:reverse]">
        <span className="absolute inset-0 rtl:-scale-x-100">
          <span className="absolute -inset-y-[5px] right-[10px] left-0 rounded-full bg-linear-to-r from-transparent to-current opacity-30 blur-[5px]" />
          <span className="absolute -inset-y-[1.5px] right-[4px] left-[25%] rounded-full bg-linear-to-r from-transparent to-current opacity-60 blur-[2px]" />
          <span className="absolute inset-y-0 right-0 left-[35%] rounded-full bg-linear-to-r from-transparent to-current" />
        </span>
      </span>
    </span>
  );
}

function ProgressIndicator({
  className,
  ...props
}: Omit<ProgressPrimitive.Indicator.Props, "className"> & {
  className?: string;
}) {
  const { isIndeterminate, percentage } =
    React.useContext(ProgressStateContext);

  return (
    <ProgressPrimitive.Indicator
      data-slot="progress-indicator"
      // Base UI sizes the indicator by width; the full width here lets the
      // inner fill move by translate instead.
      style={{ width: "100%" }}
      className={cn(
        "absolute inset-0",
        !isIndeterminate && "overflow-hidden rounded-full rtl:-scale-x-100",
        className
      )}
      {...props}
    >
      {isIndeterminate ? (
        <ProgressSweep />
      ) : (
        <span
          className={cn(
            "absolute inset-0 rounded-full bg-current transition-[translate] duration-500 motion-reduce:transition-none",
            EASE
          )}
          style={{ translate: `${percentage - 100}% 0` }}
        />
      )}
    </ProgressPrimitive.Indicator>
  );
}

type ProgressProps = Omit<
  ProgressPrimitive.Root.Props,
  "className" | "value"
> & {
  className?: string;
  /** Helper text under the bar. */
  description?: React.ReactNode;
  /** Draw the indeterminate sweep instead of a fill. `value={null}` does the same. */
  isIndeterminate?: boolean;
  /** Label above the bar, also the accessible name. */
  label?: React.ReactNode;
  /** Show the formatted value at the end of the label row. Default: shown with a label. */
  showValueLabel?: boolean;
  /** Track thickness: 4, 6 (default), or 8px. */
  size?: ProgressSize;
  /** 0 to `max`. `null` (or `isIndeterminate`) draws the sweep. */
  value?: number | null;
  /** Custom text for the value, in place of the formatted percentage. */
  valueLabel?: React.ReactNode;
};

function Progress({
  children,
  className,
  description,
  isIndeterminate,
  label,
  max = 100,
  min = 0,
  showValueLabel,
  size = "default",
  value,
  valueLabel,
  ...props
}: ProgressProps) {
  const isIndeterminateResolved = isIndeterminate ?? value == null;
  const percentage = clampPercentage(value ?? null, min, max);
  const showValue =
    showValueLabel ?? (label != null && !isIndeterminateResolved);

  return (
    <ProgressPrimitive.Root
      data-slot="progress"
      min={min}
      max={max}
      value={isIndeterminateResolved ? null : (value ?? 0)}
      className={cn("flex w-full flex-col gap-1.5", className)}
      {...props}
    >
      <ProgressStateContext.Provider
        value={{ isIndeterminate: isIndeterminateResolved, percentage }}
      >
        {children}
        {label != null || showValue ? (
          <div className="flex items-baseline justify-between gap-3">
            {label != null ? (
              <ProgressLabel className="min-w-0 truncate">
                {label}
              </ProgressLabel>
            ) : (
              <span />
            )}
            {showValue ? (
              <ProgressValue>
                {valueLabel != null ? () => valueLabel : undefined}
              </ProgressValue>
            ) : null}
          </div>
        ) : null}
        <ProgressTrack size={size}>
          <ProgressIndicator />
        </ProgressTrack>
        {description ? (
          <span className="text-xs leading-4 font-medium tracking-[0.15px] text-[color:var(--foreground-tertiary,var(--muted-foreground))]">
            {description}
          </span>
        ) : null}
      </ProgressStateContext.Provider>
    </ProgressPrimitive.Root>
  );
}

type ProgressCircleProps = Omit<
  ProgressPrimitive.Root.Props,
  "children" | "className" | "value"
> & {
  /** Colour of the arc, from the text colour. Default accent. */
  className?: string;
  /** Draw the orbiting light instead of an arc. `value={null}` does the same. */
  isIndeterminate?: boolean;
  /** 16, 24 (default), or 32px. */
  size?: ProgressSize;
  /** 0 to `max`. `null` (or `isIndeterminate`) draws the orbit. */
  value?: number | null;
};

/**
 * A ring for tight spots: a file tile, a table cell, a checklist header. Name
 * it with `aria-label`. The arc draws by stroke-dashoffset, a paint-only
 * change on a few pixels of SVG, eased like the bar.
 */
function ProgressCircle({
  className,
  isIndeterminate,
  max = 100,
  min = 0,
  size = "default",
  value,
  ...props
}: ProgressCircleProps) {
  const { box, line, spinner } = ringSizes[size];
  const radius = (box - line) / 2;
  const circumference = 2 * Math.PI * radius;
  const isIndeterminateResolved = isIndeterminate ?? value == null;
  const percentage = clampPercentage(value ?? null, min, max);

  return (
    <ProgressPrimitive.Root
      data-slot="progress-circle"
      min={min}
      max={max}
      value={isIndeterminateResolved ? null : (value ?? 0)}
      className="inline-flex shrink-0"
      {...props}
    >
      {isIndeterminateResolved ? (
        <Spinner
          aria-hidden="true"
          size={spinner}
          className={cn(
            "text-ring",
            // Our spinner stops at 24px, so the large ring is scaled up to
            // the source's 32px box.
            size === "lg" && "size-8",
            className
          )}
        />
      ) : (
        <svg
          aria-hidden="true"
          width={box}
          height={box}
          viewBox={`0 0 ${box} ${box}`}
          className={cn("-rotate-90 text-ring", className)}
        >
          <circle
            cx={box / 2}
            cy={box / 2}
            r={radius}
            fill="none"
            strokeWidth={line}
            className="stroke-border"
          />
          <circle
            cx={box / 2}
            cy={box / 2}
            r={radius}
            fill="none"
            stroke="currentColor"
            strokeWidth={line}
            strokeLinecap="round"
            strokeDasharray={circumference}
            strokeDashoffset={circumference * (1 - percentage / 100)}
            className={cn(
              "transition-[stroke-dashoffset] duration-500 motion-reduce:transition-none",
              EASE
            )}
          />
        </svg>
      )}
    </ProgressPrimitive.Root>
  );
}

export {
  Progress,
  ProgressCircle,
  ProgressIndicator,
  ProgressLabel,
  ProgressTrack,
  ProgressValue,
};