WUWensity UI

Progress

Linear, circular, segmented and stacked progress in one primitive.

Upload64%
x

Installation

npx shadcn@latest add https://layro.dev/r/wensity/progress.json

Or, with @layro in your components.json:

npx shadcn@latest add @layro/wensity/progress

Code

"use client";

import * as React from "react";
import { Progress as BaseProgress } from "@base-ui/react/progress";
import {
  motion,
  useMotionValue,
  useMotionValueEvent,
  useReducedMotion,
  useSpring,
  useTransform,
  type MotionValue,
} from "framer-motion";
import { cn } from "@/lib/utils";

/** Motion tiers, the same speeds as the --primitive-motion-* tokens. */
const SPRING = {
  fast: { type: "spring", duration: 0.08, bounce: 0 },
  moderate: { type: "spring", duration: 0.16, bounce: 0 },
  slow: { type: "spring", duration: 0.24, bounce: 0 },
} as const;

export type ProgressSize = "sm" | "md" | "lg";
export type ProgressTone = "neutral" | "brand" | "success" | "warning" | "danger";

export interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
  value?: number;
  max?: number;
  size?: ProgressSize;
  tone?: ProgressTone;
  /** Accessible label for the progress bar. */
  label?: string;
  showValue?: boolean;
  indeterminate?: boolean;
}

export interface CircularProgressProps extends React.HTMLAttributes<HTMLDivElement> {
  value?: number;
  max?: number;
  size?: number;
  strokeWidth?: number;
  tone?: ProgressTone;
  label?: string;
  showValue?: boolean;
  indeterminate?: boolean;
}

export interface SegmentedProgressProps extends React.HTMLAttributes<HTMLDivElement> {
  steps: number;
  current: number;
  size?: ProgressSize;
  tone?: ProgressTone;
  labels?: string[];
}

export interface StackedProgressSegment {
  value: number;
  tone?: ProgressTone;
  label?: string;
}

export interface StackedProgressProps extends React.HTMLAttributes<HTMLDivElement> {
  segments: StackedProgressSegment[];
  max?: number;
  size?: ProgressSize;
  showLegend?: boolean;
}


const PROGRESS_SPRING = SPRING.slow;
const PROGRESS_SPRING_INSTANT = { stiffness: 10000, damping: 100, mass: 1 };

const trackSizes: Record<ProgressSize, string> = {
  sm: "h-1.5 rounded-full",
  md: "h-2 rounded-full",
  lg: "h-2.5 rounded-full",
};

const fillTones: Record<ProgressTone, string> = {
  neutral:
    "bg-[linear-gradient(90deg,color-mix(in_srgb,var(--foreground)_72%,var(--background)),color-mix(in_srgb,var(--foreground)_88%,var(--background)))]",
  brand: "bg-[var(--primitive-control-solid)]",
  success: "bg-[var(--primitive-success)]",
  warning: "bg-[var(--primitive-warning)]",
  danger: "bg-[var(--primitive-destructive)]",
};

const fillGlow: Record<ProgressTone, string> = {
  neutral: "shadow-[0_0_14px_color-mix(in_srgb,var(--foreground)_22%,transparent)]",
  brand: "shadow-[0_0_16px_color-mix(in_srgb,var(--primitive-control-solid)_38%,transparent)]",
  success: "shadow-[0_0_16px_color-mix(in_srgb,var(--primitive-success)_32%,transparent)]",
  warning: "shadow-[0_0_16px_color-mix(in_srgb,var(--primitive-warning)_28%,transparent)]",
  danger: "shadow-[0_0_16px_color-mix(in_srgb,var(--primitive-destructive)_32%,transparent)]",
};

const completeDropShadow: Record<ProgressTone, string> = {
  neutral: "drop-shadow-[0_0_6px_color-mix(in_srgb,var(--foreground)_22%,transparent)]",
  brand: "drop-shadow-[0_0_6px_color-mix(in_srgb,var(--primitive-control-solid)_35%,transparent)]",
  success: "drop-shadow-[0_0_6px_color-mix(in_srgb,var(--primitive-success)_35%,transparent)]",
  warning: "drop-shadow-[0_0_6px_color-mix(in_srgb,var(--primitive-warning)_35%,transparent)]",
  danger: "drop-shadow-[0_0_6px_color-mix(in_srgb,var(--primitive-destructive)_35%,transparent)]",
};

const strokeTones: Record<ProgressTone, string> = {
  neutral: "stroke-[color-mix(in_srgb,var(--foreground)_75%,var(--background))]",
  brand: "stroke-[var(--primitive-control-solid)]",
  success: "stroke-[var(--primitive-success)]",
  warning: "stroke-[var(--primitive-warning)]",
  danger: "stroke-[var(--primitive-destructive)]",
};

const indeterminateGradients: Record<ProgressTone, string> = {
  neutral:
    "bg-[linear-gradient(90deg,transparent,color-mix(in_srgb,var(--foreground)_70%,var(--background)),transparent)]",
  brand:
    "bg-[linear-gradient(90deg,transparent,var(--primitive-control-solid),transparent)]",
  success:
    "bg-[linear-gradient(90deg,transparent,var(--primitive-success),transparent)]",
  warning:
    "bg-[linear-gradient(90deg,transparent,var(--primitive-warning),transparent)]",
  danger:
    "bg-[linear-gradient(90deg,transparent,var(--primitive-destructive),transparent)]",
};

const legendDots: Record<ProgressTone, string> = {
  neutral: "bg-[color-mix(in_srgb,var(--foreground)_70%,var(--background))]",
  brand: "bg-[var(--primitive-control-solid)]",
  success: "bg-[var(--primitive-success)]",
  warning: "bg-[var(--primitive-warning)]",
  danger: "bg-[var(--primitive-destructive)]",
};

function clamp(value: number, min: number, max: number) {
  return Math.min(max, Math.max(min, value));
}

function percent(value: number, max: number) {
  if (max <= 0) return 0;
  return clamp((value / max) * 100, 0, 100);
}

function useSmoothPercent(target: number, reduceMotion: boolean) {
  const motionValue = useMotionValue(target);
  const smooth = useSpring(
    motionValue,
    reduceMotion ? PROGRESS_SPRING_INSTANT : PROGRESS_SPRING,
  );

  React.useEffect(() => {
    motionValue.set(target);
  }, [target, motionValue]);

  return smooth;
}

function ProgressPercentLabel({
  value,
  className,
  initial = 0,
}: {
  value: MotionValue<number>;
  className?: string;
  initial?: number;
}) {
  const [display, setDisplay] = React.useState(initial);

  useMotionValueEvent(value, "change", (next) => {
    setDisplay(Math.round(next));
  });

  return <span className={className}>{display}%</span>;
}

export const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
  (
    {
      className,
      value = 0,
      max = 100,
      size = "md",
      tone = "brand",
      label = "Progress",
      showValue = false,
      indeterminate = false,
      ...props
    },
    ref,
  ) => {
    const reduceMotion = useReducedMotion() ?? false;

    React.useEffect(() => {
      ensureProgressStyles();
    }, []);

    const actual = percent(value, max);
    const smoothPercent = useSmoothPercent(actual, reduceMotion);
    const isComplete = actual >= 100;

    return (
      <BaseProgress.Root data-wensity-primitive=""
        ref={ref}
        value={indeterminate ? null : value}
        max={max}
        aria-label={label}
        aria-busy={indeterminate || undefined}
        data-slot="progress"
        className={cn("w-full", className)}
        {...props}
      >
        {showValue ? (
          <div className="mb-2 flex items-center justify-between text-xs">
            <BaseProgress.Label className="font-medium text-[var(--foreground)]">
              {label}
            </BaseProgress.Label>
            {!indeterminate ? (
              <ProgressPercentLabel
                value={smoothPercent}
                initial={Math.round(actual)}
                className="tabular-nums text-[var(--muted-foreground)]"
              />
            ) : null}
          </div>
        ) : null}
        <BaseProgress.Track
          // A recessed track (well); thin, so no rim eats into the bar.
          data-surface="well"
          className={cn(
            "relative w-full overflow-hidden border-0",
            trackSizes[size],
            isComplete &&
              "ring-1 ring-[color-mix(in_srgb,var(--foreground)_12%,transparent)] transition-shadow duration-[var(--primitive-motion-slow)] ease-out",
          )}
        >
          {indeterminate ? (
            <span
              aria-hidden="true"
              className={cn(
                "absolute inset-y-0 left-0 w-1/2 rounded-full",
                "motion-safe:animate-[wensity-progress-indeterminate-sweep_1.65s_cubic-bezier(0.45,0.05,0.2,1)_infinite]",
                "motion-reduce:left-[25%] motion-reduce:opacity-70",
                indeterminateGradients[tone],
              )}
            />
          ) : (
            <BaseProgress.Indicator
              className={cn(
                // `block` + inherit height from Base UI so the themed fill paints.
                "block rounded-full transition-[width] duration-[var(--primitive-motion-slow)] ease-[var(--primitive-ease,cubic-bezier(0.16,1,0.3,1))]",
                "motion-reduce:transition-none",
                fillTones[tone],
                isComplete && fillGlow[tone],
              )}
            />
          )}
        </BaseProgress.Track>
      </BaseProgress.Root>
    );
  },
);
Progress.displayName = "Progress";

export const CircularProgress = React.forwardRef<HTMLDivElement, CircularProgressProps>(
  (
    {
      className,
      value = 0,
      max = 100,
      size = 56,
      strokeWidth = 7,
      tone = "brand",
      label = "Progress",
      showValue = true,
      indeterminate = false,
      ...props
    },
    ref,
  ) => {
    const reduceMotion = useReducedMotion() ?? false;

    React.useEffect(() => {
      ensureProgressStyles();
    }, []);

    const radius = (size - strokeWidth) / 2;
    const circumference = 2 * Math.PI * radius;
    const actual = percent(value, max);
    const smoothPercent = useSmoothPercent(actual, reduceMotion);
    const strokeOffset = useTransform(
      smoothPercent,
      (v) => circumference - (v / 100) * circumference,
    );
    const isComplete = actual >= 100;

    return (
      <div data-wensity-primitive=""
        ref={ref}
        data-slot="circular-progress"
        className={cn("relative inline-flex items-center justify-center", className)}
        style={{ width: size, height: size }}
        {...props}
      >
        <svg
          width={size}
          height={size}
          viewBox={`0 0 ${size} ${size}`}
          className={cn("-rotate-90", indeterminate && "motion-safe:animate-[wensity-progress-circular-spin_2s_linear_infinite]")}
          role="progressbar"
          aria-label={label}
          aria-valuemin={0}
          aria-valuemax={max}
          aria-valuenow={indeterminate ? undefined : value}
          aria-busy={indeterminate || undefined}
        >
          <circle
            cx={size / 2}
            cy={size / 2}
            r={radius}
            fill="none"
            strokeWidth={strokeWidth}
            className="stroke-[color-mix(in_srgb,var(--foreground)_10%,var(--background))]"
          />
          {indeterminate ? (
            <circle
              cx={size / 2}
              cy={size / 2}
              r={radius}
              fill="none"
              strokeWidth={strokeWidth}
              strokeLinecap="round"
              className={cn(strokeTones[tone], "opacity-95")}
              style={{
                strokeDasharray: `${circumference * 0.68} ${circumference}`,
                strokeDashoffset: circumference * 0.08,
              }}
            />
          ) : (
            <motion.circle
              cx={size / 2}
              cy={size / 2}
              r={radius}
              fill="none"
              strokeWidth={strokeWidth}
              strokeLinecap="round"
              className={cn(strokeTones[tone], isComplete && completeDropShadow[tone])}
              style={{
                strokeDasharray: circumference,
                strokeDashoffset: strokeOffset,
              }}
            />
          )}
        </svg>
        {showValue && !indeterminate ? (
          <ProgressPercentLabel
            value={smoothPercent}
            initial={Math.round(actual)}
            className="absolute text-[11px] font-semibold tabular-nums text-[var(--foreground)]"
          />
        ) : null}
      </div>
    );
  },
);
CircularProgress.displayName = "CircularProgress";

export const SegmentedProgress = React.forwardRef<HTMLDivElement, SegmentedProgressProps>(
  ({ className, steps, current, size = "md", tone = "brand", labels, ...props }, ref) => {
    const reduceMotion = useReducedMotion() ?? false;

    return (
      <div data-wensity-primitive="" ref={ref} data-slot="segmented-progress" className={cn("w-full", className)} {...props}>
        <div
          role="progressbar"
          aria-label="Step progress"
          aria-valuemin={0}
          aria-valuemax={steps}
          aria-valuenow={current}
          className="flex w-full gap-1.5"
        >
          {Array.from({ length: Math.max(1, steps) }, (_, index) => {
            const stepNumber = index + 1;
            const safeCurrent = clamp(current, 0, Math.max(1, steps));
            const isComplete = stepNumber < safeCurrent;
            const isActive = stepNumber === safeCurrent;

            return (
              <div key={index} className="flex min-w-0 flex-1 flex-col gap-1.5">
                <div
                  className={cn(
                    "relative overflow-hidden bg-[color-mix(in_srgb,var(--foreground)_8%,var(--background))]",
                    trackSizes[size],
                  )}
                >
                  <motion.span
                    aria-hidden="true"
                    className={cn(
                      "block h-full rounded-full",
                      isComplete || isActive ? fillTones[tone] : "opacity-0",
                    )}
                    initial={false}
                    animate={{
                      width: isComplete || isActive ? "100%" : "0%",
                      opacity: isComplete || isActive ? 1 : 0,
                    }}
                    transition={
                      reduceMotion
                        ? { duration: 0 }
                        : SPRING.slow
                    }
                  />
                </div>
                {labels?.[index] ? (
                  <span
                    className={cn(
                      "truncate text-[10px] font-medium tracking-[-0.01em] transition-colors duration-[var(--primitive-motion-fast)] ease-out motion-reduce:transition-none",
                      isActive || isComplete
                        ? "text-[var(--foreground)]"
                        : "text-[var(--muted-foreground)]",
                    )}
                  >
                    {labels[index]}
                  </span>
                ) : null}
              </div>
            );
          })}
        </div>
      </div>
    );
  },
);
SegmentedProgress.displayName = "SegmentedProgress";

export const StackedProgress = React.forwardRef<HTMLDivElement, StackedProgressProps>(
  (
    {
      className,
      segments,
      max = 100,
      size = "md",
      showLegend = true,
      ...props
    },
    ref,
  ) => {
    const reduceMotion = useReducedMotion() ?? false;
    const total = segments.reduce((sum, segment) => sum + segment.value, 0);
    const scaleMax = Math.max(max, total);

    return (
      <div data-wensity-primitive="" ref={ref} data-slot="stacked-progress" className={cn("w-full", className)} {...props}>
        <div
          role="progressbar"
          aria-label="Stacked progress"
          className={cn(
            "flex w-full overflow-hidden bg-[color-mix(in_srgb,var(--foreground)_8%,var(--background))]",
            trackSizes[size],
          )}
        >
          {segments.map((segment, index) => {
            const width = percent(segment.value, scaleMax);
            const tone = segment.tone ?? "neutral";
            return (
              <motion.span
                key={`${segment.label ?? "segment"}-${index}`}
                aria-hidden="true"
                title={segment.label}
                className={cn(
                  "h-full",
                  fillTones[tone],
                  index > 0 && "border-l border-[var(--background)]/80",
                )}
                initial={false}
                animate={{ width: `${width}%` }}
                transition={
                  reduceMotion
                    ? { duration: 0 }
                    : SPRING.slow
                }
              />
            );
          })}
        </div>
        {showLegend ? (
          <div className="mt-2.5 flex flex-wrap gap-x-4 gap-y-1.5">
            {segments.map((segment, index) => {
              const tone = segment.tone ?? "neutral";
              return (
                <div
                  key={`${segment.label ?? "legend"}-${index}`}
                  className="inline-flex items-center gap-1.5 text-[11px] text-[var(--muted-foreground)]"
                >
                  <span
                    aria-hidden="true"
                    className={cn("size-2 rounded-full", legendDots[tone])}
                  />
                  <span className="font-medium text-[var(--foreground)]">{segment.label}</span>
                  <span className="tabular-nums">{segment.value}</span>
                </div>
              );
            })}
          </div>
        ) : null}
      </div>
    );
  },
);
StackedProgress.displayName = "StackedProgress";

const PROGRESS_STYLE_ID = "wensity-progress-styles-v3";

function ensureProgressStyles() {
  if (typeof document === "undefined") return;
  if (document.getElementById(PROGRESS_STYLE_ID)) return;

  const style = document.createElement("style");
  style.id = PROGRESS_STYLE_ID;
  style.textContent = `
    @keyframes wensity-progress-indeterminate-sweep {
      0% { transform: translateX(-100%); }
      100% { transform: translateX(200%); }
    }
    @keyframes wensity-progress-circular-spin {
      100% { transform: rotate(270deg); }
    }
    @media (prefers-reduced-motion: reduce) {
      [data-slot="progress"] [class*="wensity-progress-indeterminate-sweep"],
      [data-slot="circular-progress"] [class*="wensity-progress-circular-spin"] {
        animation: none !important;
      }
    }
  `;
  document.head.appendChild(style);
}

export function ProgressStyles() {
  React.useEffect(() => {
    ensureProgressStyles();
  }, []);
  return null;
}

Versions

  1. 1.0.0 · current

    Oct 27, 2026

    First release.

Similar components