Vela UI Docsvenizia.ai

Progress

A linear or circular indicator showing task completion, with color that shifts by status.

npx shadcn@latest add @velaui/progress
npx shadcn@latest add @velaui/progress

Examples

  • Default
  • Bold neutral
  • Done
  • Circle sizes
  • Circle done

View code

Progress · Default

Installation

pnpm dlx velaui add @velaui/progress
pnpm dlx velaui add @velaui/progress

Code

progress-default.tsx
import { Progress } from '@/components/ui/progress';

export default function Example() {
  return <Progress value={50} />
}

View code

Progress · Bold neutral

Installation

pnpm dlx velaui add @velaui/progress
pnpm dlx velaui add @velaui/progress

Code

progress-bold-neutral.tsx
import { Progress } from '@/components/ui/progress';

export default function Example() {
  return <Progress variant="neutral" thickness="bold" value={65} />
}

View code

Progress · Done

Installation

pnpm dlx velaui add @velaui/progress
pnpm dlx velaui add @velaui/progress

Code

progress-done.tsx
import { Progress } from '@/components/ui/progress';

export default function Example() {
  return <Progress value={100} doneLabel="Deploy complete" />
}

View code

Progress · Circle sizes

Installation

pnpm dlx velaui add @velaui/progress
pnpm dlx velaui add @velaui/progress

Code

progress-circle-sizes.tsx
import { ProgressCircle } from '@/components/ui/progress';

export default function Example() {
  return (
    <div className="flex items-center gap-4">
      <ProgressCircle size="sm" variant="neutral" value={30} />
      <ProgressCircle size="md" variant="accent" value={60} />
      <ProgressCircle size="lg" variant="accent" value={80} />
    </div>
  )
}

View code

Progress · Circle done

Installation

pnpm dlx velaui add @velaui/progress
pnpm dlx velaui add @velaui/progress

Code

progress-circle-done.tsx
import { ProgressCircle } from '@/components/ui/progress';

export default function Example() {
  return <ProgressCircle variant="accent" value={100} />
}

Usage

import { Progress } from '@/components/ui/progress';

export default function Example() {
  return <Progress value={50} />
}

Props

Progress

PropTypeDefault
doneLabelstring"Done"
showValuebooleantrue
thickness"thin" | "bold""thin"
variantProgressVariant"accent"

Takes these and every prop of ProgressProps.

ProgressCircle

PropTypeDefault
showValuebooleantrue
size"sm" | "md" | "lg""lg"
variantProgressVariant"accent"

Takes these and every prop of ProgressCircleProps.

Dependencies

npm
class-variance-authority@0.7.1radix-ui@1.6.0
Installs with it
1 icon
npm
@base-ui/react@1.6.0class-variance-authority@0.7.1
Installs with it
1 icon

Source

components/ui/progress.tsx
/* eslint-disable react-refresh/only-export-components */
"use client";

import * as React from "react";
import { Progress as ProgressPrimitive } from "radix-ui";
import { cva, type VariantProps } from "class-variance-authority";
import { Check01 } from "@/components/icons/check-01";

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

/* -------------------------------------------------------------------------- */
/*                                   Variant                                   */
/* -------------------------------------------------------------------------- */

export type ProgressVariant = "accent" | "neutral" | "status";

function clampPercent(value: number | null | undefined) {
  return Math.min(Math.max(value ?? 0, 0), 100);
}

/* -------------------------------------------------------------------------- */
/*                                Progress bar                                 */
/* -------------------------------------------------------------------------- */

const progressVariants = cva("flex max-h-6 min-h-6 w-full", {
  variants: {
    layout: {
      track: "items-center gap-1",
      badge: "flex-col items-start justify-center",
    },
  },
  defaultVariants: {
    layout: "track",
  },
});

const progressTrackVariants = cva(
  "w-full overflow-hidden rounded-lg bg-bg-surface-1",
  {
    variants: {
      thickness: {
        thin: "h-1",
        bold: "h-2",
      },
    },
    defaultVariants: {
      thickness: "thin",
    },
  },
);

const progressFillVariants = cva("h-full rounded-lg transition-all", {
  variants: {
    variant: {
      accent: "bg-accent-default",
      neutral: "bg-text-strong",
      status: "bg-success-middle",
    },
  },
  defaultVariants: {
    variant: "accent",
  },
});

const progressBadgeVariants = cva(
  "inline-flex h-4 shrink-0 items-center justify-center gap-0.5 rounded-full px-1.5 py-0.5 text-[10px] leading-[1.3] font-medium text-text-inverse",
  {
    variants: {
      variant: {
        accent: "bg-accent-default",
        neutral: "bg-text-strong",
        status: "bg-success-middle",
      },
    },
    defaultVariants: {
      variant: "accent",
    },
  },
);

export type ProgressProps = Omit<
  React.ComponentProps<typeof ProgressPrimitive.Root>,
  "children"
> &
  VariantProps<typeof progressTrackVariants> & {
    variant?: ProgressVariant;
    /** Render the trailing percentage label. */
    showValue?: boolean;
    /** Text of the completion badge shown once the task reaches 100%. */
    doneLabel?: string;
  };

function Progress({
  className,
  value,
  variant = "accent",
  thickness = "thin",
  showValue = true,
  doneLabel = "Done",
  ...props
}: ProgressProps) {
  const percent = clampPercent(value);
  const done = percent >= 100;

  return (
    <ProgressPrimitive.Root
      data-slot="progress"
      data-variant={variant}
      value={value}
      className={cn(
        progressVariants({ layout: done ? "badge" : "track" }),
        className,
      )}
      {...props}
    >
      {done ? (
        <span
          data-slot="progress-badge"
          className={cn(progressBadgeVariants({ variant }))}
        >
          <span
            aria-hidden
            className="flex size-[9px] shrink-0 items-center justify-center"
          >
            <span className="size-[5.625px] rounded-full bg-current" />
          </span>
          {doneLabel}
        </span>
      ) : (
        <>
          <div className="flex min-w-px flex-1 flex-col items-start py-2">
            <div className={cn(progressTrackVariants({ thickness }))}>
              <ProgressPrimitive.Indicator
                data-slot="progress-indicator"
                className={cn(progressFillVariants({ variant }), "w-full")}
                style={{ transform: `translateX(-${100 - percent}%)` }}
              />
            </div>
          </div>
          {showValue && (
            <span
              data-slot="progress-value"
              className="w-8 shrink-0 text-right text-sm leading-[1.4] font-normal text-text-strong"
            >
              {Math.round(percent)}%
            </span>
          )}
        </>
      )}
    </ProgressPrimitive.Root>
  );
}

/* -------------------------------------------------------------------------- */
/*                               Progress circle                               */
/* -------------------------------------------------------------------------- */

/** Ring box, stroke width, arc radius, filled-disc radius and check size, in px. */
const PROGRESS_CIRCLE_GEOMETRY = {
  sm: { box: 20, stroke: 3.24, radius: 7.38, disc: 9, icon: 10 },
  md: { box: 32, stroke: 3.92, radius: 12.04, disc: 14, icon: 16 },
  lg: { box: 48, stroke: 4.4, radius: 19.8, disc: 22, icon: 24 },
} as const;

const progressCircleVariants = cva("relative shrink-0", {
  variants: {
    size: {
      sm: "size-5",
      md: "size-8",
      lg: "size-12",
    },
  },
  defaultVariants: {
    size: "lg",
  },
});

const progressCircleToneVariants = cva("size-full -rotate-90", {
  variants: {
    variant: {
      accent: "text-accent-default",
      neutral: "text-text-strong",
      status: "text-success-middle",
    },
  },
  defaultVariants: {
    variant: "accent",
  },
});

export type ProgressCircleProps = Omit<
  React.ComponentProps<typeof ProgressPrimitive.Root>,
  "children"
> &
  VariantProps<typeof progressCircleVariants> & {
    variant?: ProgressVariant;
    /** Render the percentage inside the ring — the design only labels `lg`. */
    showValue?: boolean;
  };

function ProgressCircle({
  className,
  value,
  variant = "accent",
  size = "lg",
  showValue = true,
  ...props
}: ProgressCircleProps) {
  const percent = clampPercent(value);
  const done = percent >= 100;
  const { box, stroke, radius, disc, icon } =
    PROGRESS_CIRCLE_GEOMETRY[size ?? "lg"];
  const center = box / 2;
  const circumference = 2 * Math.PI * radius;

  return (
    <ProgressPrimitive.Root
      data-slot="progress-circle"
      data-variant={variant}
      value={value}
      className={cn(progressCircleVariants({ size }), className)}
      {...props}
    >
      <svg
        aria-hidden
        viewBox={`0 0 ${box} ${box}`}
        className={cn(progressCircleToneVariants({ variant }))}
      >
        {done ? (
          <circle cx={center} cy={center} r={disc} fill="currentColor" />
        ) : (
          <>
            <circle
              cx={center}
              cy={center}
              r={radius}
              fill="none"
              strokeWidth={stroke}
              className="stroke-bg-surface-1"
            />
            <circle
              data-slot="progress-circle-indicator"
              cx={center}
              cy={center}
              r={radius}
              fill="none"
              stroke="currentColor"
              strokeWidth={stroke}
              strokeDasharray={circumference}
              strokeDashoffset={circumference * (1 - percent / 100)}
              className="transition-all"
            />
          </>
        )}
      </svg>
      {done ? (
        <Check01
          aria-hidden
          strokeWidth={2}
          style={{ width: icon, height: icon }}
          className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-icon-inverse"
        />
      ) : (
        showValue &&
        size === "lg" && (
          <span
            data-slot="progress-circle-value"
            className="absolute inset-0 flex items-center justify-center text-xs leading-[1.3] font-normal text-text-strong"
          >
            {Math.round(percent)}%
          </span>
        )
      )}
    </ProgressPrimitive.Root>
  );
}

export {
  Progress,
  ProgressCircle,
  progressVariants,
  progressTrackVariants,
  progressFillVariants,
  progressBadgeVariants,
  progressCircleVariants,
  progressCircleToneVariants,
};
components/ui/progress.tsx
/* eslint-disable react-refresh/only-export-components */
"use client";

import { Progress as ProgressPrimitive } from "@base-ui/react/progress";
import { cva, type VariantProps } from "class-variance-authority";
import { Check01 } from "@/components/icons/check-01";

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

/* -------------------------------------------------------------------------- */
/*                                   Variant                                   */
/* -------------------------------------------------------------------------- */

export type ProgressVariant = "accent" | "neutral" | "status";

function clampPercent(value: number | null | undefined) {
  return Math.min(Math.max(value ?? 0, 0), 100);
}

/* -------------------------------------------------------------------------- */
/*                                Progress bar                                 */
/* -------------------------------------------------------------------------- */

const progressVariants = cva("flex max-h-6 min-h-6 w-full", {
  variants: {
    layout: {
      track: "items-center gap-1",
      badge: "flex-col items-start justify-center",
    },
  },
  defaultVariants: {
    layout: "track",
  },
});

const progressTrackVariants = cva(
  "w-full overflow-hidden rounded-lg bg-bg-surface-1",
  {
    variants: {
      thickness: {
        thin: "h-1",
        bold: "h-2",
      },
    },
    defaultVariants: {
      thickness: "thin",
    },
  },
);

const progressFillVariants = cva("h-full rounded-lg transition-all", {
  variants: {
    variant: {
      accent: "bg-accent-default",
      neutral: "bg-text-strong",
      status: "bg-success-middle",
    },
  },
  defaultVariants: {
    variant: "accent",
  },
});

const progressBadgeVariants = cva(
  "inline-flex h-4 shrink-0 items-center justify-center gap-0.5 rounded-full px-1.5 py-0.5 text-[10px] leading-[1.3] font-medium text-text-inverse",
  {
    variants: {
      variant: {
        accent: "bg-accent-default",
        neutral: "bg-text-strong",
        status: "bg-success-middle",
      },
    },
    defaultVariants: {
      variant: "accent",
    },
  },
);

export type ProgressProps = Omit<
  ProgressPrimitive.Root.Props,
  "children"
> &
  VariantProps<typeof progressTrackVariants> & {
    variant?: ProgressVariant;
    /** Render the trailing percentage label. */
    showValue?: boolean;
    /** Text of the completion badge shown once the task reaches 100%. */
    doneLabel?: string;
  };

function Progress({
  className,
  value,
  variant = "accent",
  thickness = "thin",
  showValue = true,
  doneLabel = "Done",
  ...props
}: ProgressProps) {
  const percent = clampPercent(value);
  const done = percent >= 100;

  return (
    <ProgressPrimitive.Root
      data-slot="progress"
      data-variant={variant}
      value={value}
      className={cn(
        progressVariants({ layout: done ? "badge" : "track" }),
        className,
      )}
      {...props}
    >
      {done ? (
        <span
          data-slot="progress-badge"
          className={cn(progressBadgeVariants({ variant }))}
        >
          <span
            aria-hidden
            className="flex size-[9px] shrink-0 items-center justify-center"
          >
            <span className="size-[5.625px] rounded-full bg-current" />
          </span>
          {doneLabel}
        </span>
      ) : (
        <>
          <div className="flex min-w-px flex-1 flex-col items-start py-2">
            {/* The indicator reads the value itself and sizes its width to the
             * percentage inline — no translate trick needed. */}
            <ProgressPrimitive.Track
              className={cn(progressTrackVariants({ thickness }))}
            >
              <ProgressPrimitive.Indicator
                data-slot="progress-indicator"
                className={cn(progressFillVariants({ variant }))}
              />
            </ProgressPrimitive.Track>
          </div>
          {showValue && (
            <span
              data-slot="progress-value"
              className="w-8 shrink-0 text-right text-sm leading-[1.4] font-normal text-text-strong"
            >
              {Math.round(percent)}%
            </span>
          )}
        </>
      )}
    </ProgressPrimitive.Root>
  );
}

/* -------------------------------------------------------------------------- */
/*                               Progress circle                               */
/* -------------------------------------------------------------------------- */

/** Ring box, stroke width, arc radius, filled-disc radius and check size, in px. */
const PROGRESS_CIRCLE_GEOMETRY = {
  sm: { box: 20, stroke: 3.24, radius: 7.38, disc: 9, icon: 10 },
  md: { box: 32, stroke: 3.92, radius: 12.04, disc: 14, icon: 16 },
  lg: { box: 48, stroke: 4.4, radius: 19.8, disc: 22, icon: 24 },
} as const;

const progressCircleVariants = cva("relative shrink-0", {
  variants: {
    size: {
      sm: "size-5",
      md: "size-8",
      lg: "size-12",
    },
  },
  defaultVariants: {
    size: "lg",
  },
});

const progressCircleToneVariants = cva("size-full -rotate-90", {
  variants: {
    variant: {
      accent: "text-accent-default",
      neutral: "text-text-strong",
      status: "text-success-middle",
    },
  },
  defaultVariants: {
    variant: "accent",
  },
});

export type ProgressCircleProps = Omit<
  ProgressPrimitive.Root.Props,
  "children"
> &
  VariantProps<typeof progressCircleVariants> & {
    variant?: ProgressVariant;
    /** Render the percentage inside the ring — the design only labels `lg`. */
    showValue?: boolean;
  };

function ProgressCircle({
  className,
  value,
  variant = "accent",
  size = "lg",
  showValue = true,
  ...props
}: ProgressCircleProps) {
  const percent = clampPercent(value);
  const done = percent >= 100;
  const { box, stroke, radius, disc, icon } =
    PROGRESS_CIRCLE_GEOMETRY[size ?? "lg"];
  const center = box / 2;
  const circumference = 2 * Math.PI * radius;

  return (
    <ProgressPrimitive.Root
      data-slot="progress-circle"
      data-variant={variant}
      value={value}
      className={cn(progressCircleVariants({ size }), className)}
      {...props}
    >
      <svg
        aria-hidden
        viewBox={`0 0 ${box} ${box}`}
        className={cn(progressCircleToneVariants({ variant }))}
      >
        {done ? (
          <circle cx={center} cy={center} r={disc} fill="currentColor" />
        ) : (
          <>
            <circle
              cx={center}
              cy={center}
              r={radius}
              fill="none"
              strokeWidth={stroke}
              className="stroke-bg-surface-1"
            />
            <circle
              data-slot="progress-circle-indicator"
              cx={center}
              cy={center}
              r={radius}
              fill="none"
              stroke="currentColor"
              strokeWidth={stroke}
              strokeDasharray={circumference}
              strokeDashoffset={circumference * (1 - percent / 100)}
              className="transition-all"
            />
          </>
        )}
      </svg>
      {done ? (
        <Check01
          aria-hidden
          strokeWidth={2}
          style={{ width: icon, height: icon }}
          className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-icon-inverse"
        />
      ) : (
        showValue &&
        size === "lg" && (
          <span
            data-slot="progress-circle-value"
            className="absolute inset-0 flex items-center justify-center text-xs leading-[1.3] font-normal text-text-strong"
          >
            {Math.round(percent)}%
          </span>
        )
      )}
    </ProgressPrimitive.Root>
  );
}

export {
  Progress,
  ProgressCircle,
  progressVariants,
  progressTrackVariants,
  progressFillVariants,
  progressBadgeVariants,
  progressCircleVariants,
  progressCircleToneVariants,
};