New: Activity Terrain +12 more

Progress

A bar or ring that shows how far a task has come, eases between updates and slides while the total is unknown.

12 examplesDepends on@base-ui/reactclass-variance-authoritycnFrom HextaUI (MIT)

Installation

Run this in your project. It adds the files, installs the npm packages and anything this builds on, and sets up the theme:

npx shadcn@latest add https://kryptonite-ui.vercel.app/r/progress.json
Install manually
  1. Install the dependencies:

    npm install @base-ui/react class-variance-authority cn
  2. Copy this into components/ui/progress.tsx:

    progress.tsx
    "use client"
    
    import * as React from "react"
    import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
    import { cva, type VariantProps } from "class-variance-authority"
    import { cn } from "cn"
    
    type ClassName<State> =
      string | ((state: State) => string | undefined) | undefined
    
    function mergeClassName<State>(base: string, className: ClassName<State>) {
      return typeof className === "function"
        ? (state: State) => cn(base, className(state))
        : cn(base, className)
    }
    
    type ProgressSize = "xs" | "sm" | "default" | "lg"
    
    type ProgressVariant = "default" | "success" | "warning" | "destructive"
    
    type ProgressCircleSize = "sm" | "default" | "lg" | "xl"
    
    const ProgressContext = React.createContext<{
      kind: "bar" | "circle"
      size: ProgressSize | ProgressCircleSize
      variant: ProgressVariant
    } | null>(null)
    
    function useProgressContext(part: string) {
      const context = React.useContext(ProgressContext)
      if (!context) {
        throw new Error(`${part} must be used within <Progress>.`)
      }
      return context
    }
    
    function toPercent(value: number | null | undefined, min: number, max: number) {
      if (value == null || !Number.isFinite(value)) {
        return null
      }
      const percent = ((value - min) * 100) / (max - min)
      return Number.isNaN(percent) ? 0 : Math.min(100, Math.max(0, percent))
    }
    
    const progressTrackVariants = cva(
      "relative flex w-full items-center overflow-hidden rounded-full bg-foreground/10 [--progress-dir:1] rtl:[--progress-dir:-1] dark:bg-foreground/15 forced-colors:border",
      {
        variants: {
          size: {
            xs: "h-0.5",
            sm: "h-1",
            default: "h-1.5",
            lg: "h-2.5",
          },
        },
        defaultVariants: {
          size: "default",
        },
      }
    )
    
    const progressIndicatorVariants = cva(
      "h-full w-0 rounded-full transition-[width] duration-300 ease-spring data-indeterminate:transition-none data-indeterminate:before:absolute data-indeterminate:before:inset-y-0 data-indeterminate:before:start-0 data-indeterminate:before:w-2/5 data-indeterminate:before:rounded-full data-indeterminate:before:bg-inherit motion-safe:data-indeterminate:before:animate-progress-slide motion-reduce:transition-none motion-reduce:data-indeterminate:before:w-full motion-reduce:data-indeterminate:before:animate-skeleton-pulse forced-colors:bg-highlight",
      {
        variants: {
          variant: {
            default: "bg-primary",
            success: "bg-success",
            warning: "bg-warning",
            destructive: "bg-destructive",
          },
        },
        defaultVariants: {
          variant: "default",
        },
      }
    )
    
    const progressSegmentVariants = cva(
      "h-full w-(--progress-segment) shrink-0 transition-[width,margin-inline-start] duration-300 ease-spring data-gap:ms-0.5 motion-reduce:transition-none forced-colors:bg-highlight",
      {
        variants: {
          variant: {
            default: "",
            success: "bg-success",
            warning: "bg-warning",
            destructive: "bg-destructive",
          },
          tone: {
            none: "",
            "chart-1": "bg-chart-1",
            "chart-2": "bg-chart-2",
            "chart-3": "bg-chart-3",
            "chart-4": "bg-chart-4",
            "chart-5": "bg-chart-5",
          },
        },
        compoundVariants: [{ variant: "default", tone: "none", className: "bg-primary" }],
        defaultVariants: {
          variant: "default",
          tone: "none",
        },
      }
    )
    
    const progressCircleStroke = {
      default: "stroke-primary",
      success: "stroke-success",
      warning: "stroke-warning",
      destructive: "stroke-destructive",
    } satisfies Record<ProgressVariant, string>
    
    const progressCircleVariants = cva(
      "relative inline-grid size-(--progress-circle-size) shrink-0 place-items-center *:col-start-1 *:row-start-1",
      {
        variants: {
          size: {
            sm: "text-[0.5rem] [--progress-circle-size:1rem] [--progress-stroke:3px]",
            default:
              "text-[0.5rem] [--progress-circle-size:1.5rem] [--progress-stroke:2.5px]",
            lg: "text-[0.625rem] [--progress-circle-size:2.5rem] [--progress-stroke:2px]",
            xl: "text-sm [--progress-circle-size:4rem] [--progress-stroke:1.5px]",
          },
        },
        defaultVariants: {
          size: "default",
        },
      }
    )
    
    type ProgressTone = "chart-1" | "chart-2" | "chart-3" | "chart-4" | "chart-5"
    
    type ProgressSegment = {
      value: number
      variant?: ProgressVariant
      tone?: ProgressTone
      label?: string
    }
    
    type ProgressProps = Omit<ProgressPrimitive.Root.Props, "value"> & {
      value?: ProgressPrimitive.Root.Props["value"]
      size?: ProgressSize
      variant?: ProgressVariant
      segments?: ProgressSegment[]
    }
    
    function segmentsText(
      segments: ProgressSegment[],
      locale: Intl.LocalesArgument
    ) {
      const format = new Intl.NumberFormat(locale)
      return segments
        .filter((segment) => segment.label && segment.value > 0)
        .map((segment) => `${format.format(segment.value)} ${segment.label}`)
        .join(", ")
    }
    
    function ProgressSegments({
      segments,
      min,
      max,
    }: {
      segments: ProgressSegment[]
      min: number
      max: number
    }) {
      let filled = false
      return segments.map((segment, index) => {
        const percent = toPercent(segment.value + min, min, max) ?? 0
        const gap = filled && percent > 0
        filled ||= percent > 0
        return (
          <span
            key={index}
            data-slot="progress-segment"
            data-variant={segment.variant ?? "default"}
            data-tone={segment.tone}
            data-gap={gap ? "" : undefined}
            style={{ "--progress-segment": `${percent}%` } as React.CSSProperties}
            className={progressSegmentVariants({
              variant: segment.variant,
              tone: segment.tone ?? "none",
            })}
          />
        )
      })
    }
    
    function Progress({
      className,
      children,
      size = "default",
      variant = "default",
      locale = "en-US",
      segments,
      value,
      min = 0,
      max = 100,
      getAriaValueText,
      ...props
    }: ProgressProps) {
      const context = React.useMemo(
        () => ({ kind: "bar" as const, size, variant }),
        [size, variant]
      )
      const total = segments
        ? segments.reduce(
            (sum, segment) =>
              sum + (Number.isFinite(segment.value) ? Math.max(0, segment.value) : 0),
            min
          )
        : undefined
      const labelled = segments?.some((segment) => segment.label)
    
      return (
        <ProgressContext.Provider value={context}>
          <ProgressPrimitive.Root
            data-slot="progress"
            data-size={size}
            data-variant={variant}
            locale={locale}
            value={total ?? value ?? null}
            min={min}
            max={max}
            getAriaValueText={
              getAriaValueText ??
              (segments && labelled
                ? (formattedValue) => {
                    const parts = segmentsText(segments, locale)
                    return parts
                      ? `${formattedValue ?? ""} (${parts})`.trim()
                      : (formattedValue ?? "")
                  }
                : undefined)
            }
            className={mergeClassName(
              "flex w-full min-w-0 flex-wrap items-center gap-x-3 gap-y-2",
              className
            )}
            {...props}
          >
            {children}
            <ProgressTrack>
              {segments ? (
                <ProgressSegments segments={segments} min={min} max={max} />
              ) : (
                <ProgressIndicator />
              )}
            </ProgressTrack>
          </ProgressPrimitive.Root>
        </ProgressContext.Provider>
      )
    }
    
    function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
      const { size } = useProgressContext("ProgressTrack")
    
      return (
        <ProgressPrimitive.Track
          data-slot="progress-track"
          className={mergeClassName(
            progressTrackVariants({ size: size === "xl" ? "lg" : size }),
            className
          )}
          {...props}
        />
      )
    }
    
    function ProgressIndicator({
      className,
      ...props
    }: ProgressPrimitive.Indicator.Props) {
      const { variant } = useProgressContext("ProgressIndicator")
    
      return (
        <ProgressPrimitive.Indicator
          data-slot="progress-indicator"
          className={mergeClassName(
            progressIndicatorVariants({ variant }),
            className
          )}
          {...props}
        />
      )
    }
    
    function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
      return (
        <ProgressPrimitive.Label
          data-slot="progress-label"
          className={mergeClassName(
            "min-w-0 flex-1 text-sm font-medium wrap-anywhere",
            className
          )}
          {...props}
        />
      )
    }
    
    function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {
      const { kind } = useProgressContext("ProgressValue")
    
      return (
        <ProgressPrimitive.Value
          data-slot="progress-value"
          className={mergeClassName(
            kind === "bar"
              ? "ms-auto shrink-0 text-sm text-muted-foreground tabular-nums"
              : "font-medium tabular-nums",
            className
          )}
          {...props}
        />
      )
    }
    
    type ProgressCircleProps = ProgressPrimitive.Root.Props &
      VariantProps<typeof progressCircleVariants> & {
        variant?: ProgressVariant
      }
    
    function ProgressCircle({
      className,
      children,
      size = "default",
      variant = "default",
      value,
      min = 0,
      max = 100,
      locale = "en-US",
      ...props
    }: ProgressCircleProps) {
      const resolvedSize = size ?? "default"
      const context = React.useMemo(
        () => ({ kind: "circle" as const, size: resolvedSize, variant }),
        [resolvedSize, variant]
      )
      const percent = toPercent(value, min, max)
    
      return (
        <ProgressContext.Provider value={context}>
          <ProgressPrimitive.Root
            data-slot="progress-circle"
            data-size={resolvedSize}
            data-variant={variant}
            value={value}
            min={min}
            max={max}
            locale={locale}
            className={mergeClassName(
              progressCircleVariants({ size: resolvedSize }),
              className
            )}
            {...props}
          >
            <svg
              viewBox="0 0 24 24"
              fill="none"
              aria-hidden
              data-slot="progress-circle-svg"
              style={{ "--progress": percent ?? 0 } as React.CSSProperties}
              className="size-full -rotate-90"
            >
              <circle
                cx="12"
                cy="12"
                r="10"
                data-slot="progress-circle-track"
                className="stroke-foreground/10 [stroke-width:var(--progress-stroke)] dark:stroke-foreground/15"
              />
              <circle
                cx="12"
                cy="12"
                r="10"
                pathLength={100}
                data-slot="progress-circle-indicator"
                className={cn(
                  "origin-center [stroke-width:var(--progress-stroke)] [stroke-dasharray:100_100] [stroke-linecap:round] [transform-box:fill-box]",
                  progressCircleStroke[variant],
                  percent === null
                    ? "[stroke-dasharray:25_75] motion-safe:animate-spinner-rotate motion-reduce:animate-skeleton-pulse motion-reduce:[stroke-dasharray:100_100]"
                    : "transition-[stroke-dashoffset,opacity] duration-300 ease-spring [stroke-dashoffset:calc(100_-_var(--progress))] motion-reduce:transition-none",
                  percent === 0 && "opacity-0"
                )}
              />
            </svg>
            {children}
          </ProgressPrimitive.Root>
        </ProgressContext.Provider>
      )
    }
    
    export {
      Progress,
      ProgressCircle,
      progressCircleVariants,
      ProgressIndicator,
      progressIndicatorVariants,
      ProgressLabel,
      progressSegmentVariants,
      ProgressTrack,
      progressTrackVariants,
      ProgressValue,
    }
    export type {
      ProgressCircleProps,
      ProgressCircleSize,
      ProgressProps,
      ProgressSegment,
      ProgressSize,
      ProgressTone,
      ProgressVariant,
    }

Examples

Indeterminate circle

Circle

Long labels

Custom range and format

Indeterminate

Animated value

Right to left

Segments

Sizes

Without a visible label

Status

Feedback

Questions, bugs or ideas for Progress? Post them here, or reply to someone else. Sign in with GitHub to comment.