# Progress

> Linear and circular progress with a moving highlight.

Category: Feedback. Docs: https://get-neonkit.vercel.app/components/progress

## Installation

```bash
npx shadcn@latest add https://get-neonkit.vercel.app/r/progress.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/progress`.

npm dependencies: none. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

## Usage

```tsx
import { Progress } from "@/components/ui/progress"

<Progress value={64} label="Uploading brand-assets.zip" showValue />
```

## Examples

### Upload list

The sheen stops once a bar is full, so finished work goes quiet.

```tsx
"use client";

import * as React from "react";
import { FileArchive, FileImage, FileText } from "lucide-react";
import { Progress } from "@/components/ui/progress";

const files = [
  { name: "q3-board-review.pdf", size: 8.4, icon: FileText, start: 100, rate: 0 },
  { name: "launch-hero@2x.png", size: 14.2, icon: FileImage, start: 38, rate: 6 },
  { name: "brand-assets.zip", size: 126, icon: FileArchive, start: 9, rate: 2.6 },
];

// Uneven steps so the bars move like a real network, not a metronome.
const steps = [1.4, 0.6, 1.1, 0.3, 1.6, 0.9, 0.5, 1.2];
const LOOP = 44;

function travelled(tick: number, offset: number) {
  let total = 0;
  for (let i = 0; i < tick; i++) total += steps[(i + offset) % steps.length];
  return total;
}

export default function ProgressDemo() {
  const [tick, setTick] = React.useState(0);

  React.useEffect(() => {
    const id = setInterval(() => setTick((t) => (t + 1) % LOOP), 500);
    return () => clearInterval(id);
  }, []);

  const uploads = files.map((file, i) => ({
    ...file,
    value: Math.min(100, file.start + file.rate * travelled(tick, i * 3)),
  }));
  const complete = uploads.filter((file) => file.value === 100).length;

  return (
    <div className="w-full max-w-md rounded-xl border border-white/10 bg-white/[0.03] p-5">
      <div className="mb-5 flex items-baseline justify-between gap-4">
        <h3 className="text-sm font-medium text-white/90">Uploads</h3>
        <span className="text-xs tabular-nums text-white/45">
          {complete} of {files.length} complete
        </span>
      </div>
      <ul className="grid gap-5">
        {uploads.map(({ name, size, icon: Icon, value }) => {
          const done = value === 100;
          return (
            <li key={name} className="flex items-start gap-3">
              <span className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-white/10 bg-white/[0.03] text-white/50">
                <Icon className="size-4" />
              </span>
              <div className="grid min-w-0 flex-1 gap-1.5">
                <Progress
                  value={value}
                  label={name}
                  showValue
                  color={done ? "green" : undefined}
                />
                <p className="text-xs tabular-nums text-white/45">
                  {done
                    ? `${size} MB · Uploaded`
                    : `${((size * value) / 100).toFixed(1)} of ${size} MB`}
                </p>
              </div>
            </li>
          );
        })}
      </ul>
    </div>
  );
}
```

### Indeterminate

Leave value undefined when you can't measure the work yet.

```tsx
"use client";

import * as React from "react";
import { CircularProgress, Progress } from "@/components/ui/progress";

const stages = [
  "Cloning repository",
  "Installing dependencies",
  "Building",
  "Uploading build output",
  "Assigning domains",
];

function formatElapsed(seconds: number) {
  const m = String(Math.floor(seconds / 60)).padStart(2, "0");
  const s = String(seconds % 60).padStart(2, "0");
  return `${m}:${s}`;
}

export default function ProgressIndeterminate() {
  const [elapsed, setElapsed] = React.useState(0);

  React.useEffect(() => {
    const id = setInterval(() => setElapsed((s) => s + 1), 1000);
    return () => clearInterval(id);
  }, []);

  const stage = stages[Math.floor(elapsed / 3) % stages.length];

  return (
    <div className="w-full max-w-md rounded-xl border border-white/10 bg-white/[0.03] p-5">
      <div className="mb-4 flex items-center gap-2.5">
        <CircularProgress size={14} thickness={2} aria-hidden />
        <span className="text-sm font-medium text-white/90">acme-web</span>
        <span className="ml-auto text-xs tabular-nums text-white/45">
          {formatElapsed(elapsed)}
        </span>
      </div>
      <Progress label="Deploying to production" />
      <div className="mt-3 flex items-center justify-between gap-4 text-xs text-white/45">
        <span className="truncate">{stage}…</span>
        <span className="shrink-0 font-mono">main · 7f3c2a1</span>
      </div>
    </div>
  );
}
```

### Circular

CircularProgress renders children in the center, or the percentage with showValue.

```tsx
import { CircularProgress } from "@/components/ui/progress";

const usage = [
  { label: "Storage", value: 72, detail: "36 of 50 GB" },
  { label: "Bandwidth", value: 41, detail: "410 GB of 1 TB" },
  { label: "Build minutes", value: 90, detail: "5,400 of 6,000", color: "amber" },
];

export default function ProgressCircular() {
  return (
    <div className="flex w-full flex-wrap justify-center gap-3">
      {usage.map(({ label, value, detail, color }) => (
        <div
          key={label}
          className="flex w-36 flex-col items-center gap-4 rounded-xl border border-white/10 bg-white/[0.03] px-3 pt-5 pb-4 text-center"
        >
          <CircularProgress value={value} size={76} color={color} aria-label={`${label} used`}>
            <span className="grid gap-1 leading-none">
              <span className="text-base">{value}%</span>
              <span className="text-[10px] font-normal text-white/45">used</span>
            </span>
          </CircularProgress>
          <div className="grid gap-0.5">
            <p className="text-sm text-white/90">{label}</p>
            <p className="text-xs tabular-nums text-white/45">{detail}</p>
          </div>
        </div>
      ))}
    </div>
  );
}
```

### Sizes

```tsx
import { CircularProgress, Progress } from "@/components/ui/progress";

export default function ProgressSizes() {
  return (
    <div className="grid w-full max-w-md gap-8">
      <div className="grid gap-5">
        <Progress size="sm" value={32} label="Small" showValue />
        <Progress size="md" value={58} label="Medium" showValue />
        <Progress size="lg" value={84} label="Large" showValue />
      </div>
      <div className="flex items-center justify-center gap-6">
        <CircularProgress size={40} thickness={4} value={32} />
        <CircularProgress size={56} thickness={5} value={58} showValue />
        <CircularProgress size={72} thickness={6} value={84} showValue />
      </div>
    </div>
  );
}
```

## API reference

### Progress

Also accepts every native div attribute.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `number` | — | Current value. Undefined renders an indeterminate bar. |
| `max` | `number` | `100` | Value that counts as complete. |
| `color` | `NeonColor` | `inherited` | Palette name or hex. Falls back to --neon-accent, then cyan. |
| `size` | `"sm" \| "md" \| "lg"` | `"md"` | Track height: 4, 8 or 12px. |
| `indeterminate` | `boolean` | `false` | Slides a segment of light instead of showing the value. |
| `label` | `ReactNode` | — | Text above the track, also used as the accessible name. |
| `showValue` | `boolean` | `false` | Shows the rounded percentage at the right of the label row. |

### CircularProgress

Also accepts every native div attribute.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `number` | — | Current value. Undefined renders a spinning arc. |
| `max` | `number` | `100` | Value that counts as complete. |
| `size` | `number` | `64` | Diameter in pixels. |
| `thickness` | `number` | `6` | Stroke width in pixels. |
| `color` | `NeonColor` | `inherited` | Palette name or hex. Falls back to --neon-accent, then cyan. |
| `showValue` | `boolean` | `false` | Renders the percentage in the center. |
| `indeterminate` | `boolean` | `false` | Spins a fixed arc instead of showing the value. |
| `children` | `ReactNode` | — | Custom center content. Replaces the percentage. |

## Accessibility

- Both components render role="progressbar" with aria-valuemin, aria-valuemax and aria-valuenow, and drop aria-valuenow while indeterminate.
- label becomes the accessible name through aria-labelledby; without one, pass aria-label or it falls back to "Progress".
- The sheen is hidden and the indeterminate animations slow down when the user prefers reduced motion.

## Source

`components/ui/progress.tsx`

```tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

const keyframes = `
@keyframes nk-progress-sheen { 0% { transform: translateX(-100%); } 60%, 100% { transform: translateX(300%); } }
@keyframes nk-progress-slide { 0% { transform: translateX(-100%); } 100% { transform: translateX(250%); } }
`;

const sizes = {
  sm: {
    track: "h-1",
    text: "text-xs",
    gap: "gap-1.5",
    glow: "shadow-[0_0_6px_rgb(var(--neon)/0.55)]",
  },
  md: {
    track: "h-2",
    text: "text-sm",
    gap: "gap-2",
    glow: "shadow-[0_0_10px_rgb(var(--neon)/0.5)]",
  },
  lg: {
    track: "h-3",
    text: "text-sm",
    gap: "gap-2.5",
    glow: "shadow-[0_0_12px_rgb(var(--neon)/0.45)]",
  },
} as const;

function toPercent(value: number, max: number) {
  if (!(max > 0)) return 0;
  return Math.min(100, Math.max(0, (value / max) * 100));
}

export interface ProgressProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "color"> {
  /** Current value. Leave it undefined for an indeterminate bar. */
  value?: number;
  max?: number;
  /** Palette name or any hex color. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
  size?: keyof typeof sizes;
  /** Slide a segment of light instead of showing a value. */
  indeterminate?: boolean;
  /** Shown above the track and used as the accessible name. */
  label?: React.ReactNode;
  /** Show the percentage at the right of the label row. */
  showValue?: boolean;
}

const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
  (
    {
      className,
      style,
      value,
      max = 100,
      color,
      size = "md",
      indeterminate = false,
      label,
      showValue = false,
      ...props
    },
    ref,
  ) => {
    const labelId = React.useId();
    const isIndeterminate = indeterminate || value == null;
    const percent = isIndeterminate ? 0 : toPercent(value, max);
    const hasName = props["aria-label"] != null || props["aria-labelledby"] != null;
    const hasLabel = label != null && label !== false;
    const s = sizes[size];

    return (
      <div
        ref={ref}
        role="progressbar"
        aria-valuemin={0}
        aria-valuemax={max}
        aria-valuenow={isIndeterminate ? undefined : Math.min(max, Math.max(0, value))}
        aria-labelledby={!hasName && hasLabel ? labelId : undefined}
        aria-label={!hasName && !hasLabel ? "Progress" : undefined}
        data-state={isIndeterminate ? "indeterminate" : percent >= 100 ? "complete" : "loading"}
        style={neonVars(color, style)}
        className={cn(
          "[--neon:var(--neon-accent,34_211_238)]",
          "grid w-full",
          s.gap,
          className,
        )}
        {...props}
      >
        <style>{keyframes}</style>
        {(hasLabel || showValue) && (
          <div className={cn("flex items-baseline justify-between gap-3", s.text)}>
            <span id={labelId} className="min-w-0 truncate text-white/90">
              {label}
            </span>
            {showValue && !isIndeterminate && (
              <span aria-hidden className="shrink-0 tabular-nums text-white/55">
                {Math.round(percent)}%
              </span>
            )}
          </div>
        )}
        <div
          className={cn(
            "relative w-full rounded-full bg-white/[0.06] shadow-[inset_0_0_0_1px_rgb(255_255_255/0.04)]",
            s.track,
          )}
        >
          {isIndeterminate ? (
            // The mask fades the segment in and out at the ends without
            // clipping its glow above and below the track.
            <div className="absolute inset-x-0 -inset-y-4 [-webkit-mask-image:linear-gradient(90deg,transparent,#000_15%,#000_85%,transparent)] [mask-image:linear-gradient(90deg,transparent,#000_15%,#000_85%,transparent)]">
              <div
                className={cn(
                  "absolute inset-y-4 left-0 w-2/5 rounded-full bg-[rgb(var(--neon))]",
                  "[animation:nk-progress-slide_1.6s_cubic-bezier(0.65,0,0.35,1)_infinite] motion-reduce:[animation-duration:4s]",
                  s.glow,
                )}
              />
            </div>
          ) : (
            <div
              style={{ width: `${percent}%` }}
              className={cn(
                "relative h-full overflow-hidden rounded-full bg-[rgb(var(--neon))]",
                "transition-[width,opacity,background-color,box-shadow] duration-500 ease-[cubic-bezier(0.23,1,0.32,1)]",
                percent === 0 && "opacity-0",
                s.glow,
              )}
            >
              {percent < 100 && (
                <span className="absolute inset-y-0 left-0 w-1/3 bg-[linear-gradient(90deg,transparent,rgb(255_255_255/0.45),transparent)] [animation:nk-progress-sheen_2.8s_ease-in-out_infinite] motion-reduce:hidden" />
              )}
            </div>
          )}
        </div>
      </div>
    );
  },
);
Progress.displayName = "Progress";

export interface CircularProgressProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "color"> {
  /** Current value. Leave it undefined for a spinning arc. */
  value?: number;
  max?: number;
  /** Diameter in pixels. */
  size?: number;
  /** Stroke width in pixels. */
  thickness?: number;
  /** Palette name or any hex color. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
  /** Render the percentage in the center. Children replace it. */
  showValue?: boolean;
  indeterminate?: boolean;
}

const CircularProgress = React.forwardRef<HTMLDivElement, CircularProgressProps>(
  (
    {
      className,
      style,
      value,
      max = 100,
      size = 64,
      thickness = 6,
      color,
      showValue = false,
      indeterminate = false,
      children,
      ...props
    },
    ref,
  ) => {
    const isIndeterminate = indeterminate || value == null;
    const percent = isIndeterminate ? 28 : toPercent(value, max);
    const hasName = props["aria-label"] != null || props["aria-labelledby"] != null;

    const center = size / 2;
    const radius = Math.max(0, (size - thickness) / 2);
    const circumference = 2 * Math.PI * radius;
    const content =
      children ?? (showValue && !isIndeterminate ? `${Math.round(percent)}%` : null);

    return (
      <div
        ref={ref}
        role="progressbar"
        aria-valuemin={0}
        aria-valuemax={max}
        aria-valuenow={isIndeterminate ? undefined : Math.min(max, Math.max(0, value))}
        aria-label={hasName ? undefined : "Progress"}
        data-state={isIndeterminate ? "indeterminate" : percent >= 100 ? "complete" : "loading"}
        style={neonVars(color, { width: size, height: size, ...style })}
        className={cn(
          "[--neon:var(--neon-accent,34_211_238)]",
          "relative inline-grid shrink-0 place-items-center",
          className,
        )}
        {...props}
      >
        <svg
          aria-hidden
          width={size}
          height={size}
          viewBox={`0 0 ${size} ${size}`}
          fill="none"
          className={cn(
            "absolute inset-0 -rotate-90 overflow-visible [filter:drop-shadow(0_0_5px_rgb(var(--neon)/0.6))]",
            isIndeterminate &&
              "animate-spin [animation-duration:1s] motion-reduce:[animation-duration:3s]",
          )}
        >
          <circle
            cx={center}
            cy={center}
            r={radius}
            strokeWidth={thickness}
            className="stroke-white/[0.08]"
          />
          <circle
            cx={center}
            cy={center}
            r={radius}
            strokeWidth={thickness}
            strokeLinecap="round"
            strokeDasharray={circumference}
            strokeDashoffset={circumference * (1 - percent / 100)}
            className={cn(
              "[stroke:rgb(var(--neon))] transition-[stroke-dashoffset,stroke,opacity] duration-500 ease-[cubic-bezier(0.23,1,0.32,1)]",
              percent === 0 && "opacity-0",
            )}
          />
        </svg>
        {content != null && (
          <span
            className="relative font-medium tabular-nums text-white/90"
            style={{ fontSize: Math.max(10, Math.round(size * 0.2)) }}
          >
            {content}
          </span>
        )}
      </div>
    );
  },
);
CircularProgress.displayName = "CircularProgress";

export { Progress, CircularProgress };
```
