# Halo Progress

> Progress bar for React and shadcn/ui on Base UI, with a spring-animated determinate fill and a sliding indeterminate strip.

Source: https://www.cult-ui.com/docs/components/halo-progress

## Example

```tsx title="halo-progress-demo.tsx"
"use client"

import { useEffect, useState } from "react"

import { HaloProgress } from "@/components/ui/halo-progress"

export default function HaloProgressDemo() {
  const [pct, setPct] = useState(0)

  useEffect(() => {
    if (pct >= 100) {
      return
    }
    const id = window.setInterval(() => {
      setPct((p) => Math.min(100, p + 2))
    }, 120)
    return () => window.clearInterval(id)
  }, [pct])

  return (
    <main className="flex w-full flex-col items-center justify-center">
      <div className="w-full max-w-md space-y-10">
        <section className="space-y-3">
          <h2 className="font-semibold text-foreground text-lg tracking-tight">
            Determinate
          </h2>
          <p className="text-pretty text-muted-foreground text-sm">
            Shows a known completion percentage with an optional label and
            numeric value — suited to uploads and other bounded tasks.
          </p>
          <HaloProgress
            label="Uploading"
            showValue
            value={pct === 100 ? 100 : pct}
          />
          <div className="flex gap-2">
            <button
              className="rounded-md border border-border px-3 py-1.5 font-medium text-foreground text-sm fine-hover:hover:bg-muted"
              onClick={() => setPct(0)}
              type="button"
            >
              Reset
            </button>
          </div>
        </section>

        <section className="space-y-3">
          <h2 className="font-semibold text-foreground text-lg tracking-tight">
            Indeterminate
          </h2>
          <p className="text-pretty text-muted-foreground text-sm">
            Use when the duration is unknown: the bar loops until the task
            finishes or you switch to a determinate value.
          </p>
          <HaloProgress value={null} />
        </section>
      </div>
    </main>
  )
}
```

Halo Progress is a React progress bar for shadcn/ui built on Base UI Progress and Motion. Use it for file uploads, multi-step forms, or AI generation tasks.

## Installation

### CLI

```bash
npx shadcn@latest add @cult-ui/halo-progress
```

### Manual

**Install the required dependencies:**

```bash
npm install @base-ui/react motion
```

**Copy and paste the following code into your project.**

```tsx title="halo-progress.tsx"
"use client"

/**
 * Progress bar for async flows and inline status (pairs with polished toast / notification).
 * Determinate fill uses `scaleX` (transform) — not animated `width`. Indeterminate uses a
 * sliding gradient strip. Styling aligns with {@link HaloSegmented} frosted + rim chrome.
 */
import { useMemo, type ComponentProps, type ReactNode } from "react"
import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
import { motion, useReducedMotion } from "motion/react"

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

/** Matches `layoutSpring` in `halo-button.tsx` / `halo-segmented.tsx`. */
const layoutSpring = {
  type: "spring" as const,
  bounce: 0.12,
  damping: 34,
  stiffness: 420,
}

function IndeterminateBar({
  className,
  reduceMotion,
}: {
  className?: string
  reduceMotion: boolean
}) {
  return (
    <div
      className={cn(
        "pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]",
        className
      )}
    >
      <motion.div
        animate={
          reduceMotion
            ? { x: "0%" }
            : {
                x: ["-45%", "145%"],
              }
        }
        aria-hidden
        className="absolute inset-y-0 left-0 h-full w-2/5 rounded-[inherit] bg-gradient-to-r from-primary/25 via-primary to-primary/25"
        style={{ willChange: reduceMotion ? undefined : "transform" }}
        transition={{
          duration: reduceMotion ? 0.01 : 1.15,
          ease: "easeInOut",
          repeat: reduceMotion ? 0 : Number.POSITIVE_INFINITY,
        }}
      />
    </div>
  )
}

export interface HaloProgressProps
  extends Omit<ProgressPrimitive.Root.Props, "children"> {
  /** Optional label wired to Base UI progress label + `aria-labelledby`. */
  label?: ReactNode
  /** Show formatted percentage from Base UI (`tabular-nums`). */
  showValue?: boolean
  /** Frosted track + gradient rim (matches segmented / button polish). */
  translucent?: boolean
  trackClassName?: string
  indicatorClassName?: string
}

export function HaloProgress({
  className,
  label,
  showValue,
  translucent = true,
  trackClassName,
  indicatorClassName,
  value,
  min = 0,
  max = 100,
  ...rootProps
}: HaloProgressProps) {
  const reduceMotion = useReducedMotion() ?? false
  const isIndeterminate = value === null

  const pct = useMemo(() => {
    if (value === null || !Number.isFinite(value)) {
      return 0
    }
    const span = max - min
    if (span <= 0) {
      return 0
    }
    return Math.min(1, Math.max(0, (value - min) / span))
  }, [value, min, max])

  return (
    <ProgressPrimitive.Root
      className={cn("flex w-full min-w-[8rem] flex-wrap gap-2", className)}
      max={max}
      min={min}
      value={value}
      {...rootProps}
    >
      {(label != null || showValue) && (
        <div className="flex w-full items-baseline justify-between gap-3">
          {label == null ? (
            <span className="sr-only">Progress</span>
          ) : (
            <ProgressPrimitive.Label className="font-medium text-foreground text-xs/relaxed">
              {label}
            </ProgressPrimitive.Label>
          )}
          {showValue ? (
            <ProgressPrimitive.Value className="text-muted-foreground text-xs/relaxed tabular-nums" />
          ) : null}
        </div>
      )}

      <div
        className={cn(
          "relative w-full rounded-full p-px",
          !(label || showValue) && "min-w-[8rem]"
        )}
      >
        <div className="pointer-events-none absolute inset-0 overflow-hidden rounded-full">
          <motion.div
            animate={{
              opacity: translucent ? 0.55 : 0.4,
            }}
            className="absolute inset-x-0 bottom-0 h-1/2"
            transition={{ duration: 0.22, ease: "easeOut" }}
          >
            <motion.div
              animate={{ left: ["-8%", "72%", "-8%"] }}
              className="-bottom-3 absolute h-10 w-32 blur-xl"
              style={{
                background:
                  "linear-gradient(90deg, #0070f3, #7928ca, #00d4ff, #ff0080)",
              }}
              transition={{
                duration: reduceMotion ? 0.01 : 5.5,
                ease: "easeInOut",
                repeat: reduceMotion ? 0 : Number.POSITIVE_INFINITY,
              }}
            />
          </motion.div>
        </div>

        <ProgressPrimitive.Track
          className={cn(
            "relative h-2 w-full overflow-hidden rounded-full border border-border/80",
            "shadow-[0_1px_0_rgba(255,255,255,0.08)_inset,0_1px_2px_rgba(0,0,0,0.05)]",
            "dark:border-border dark:shadow-[0_1px_0_rgba(255,255,255,0.05)_inset,0_2px_10px_rgba(0,0,0,0.35)]",
            translucent && "backdrop-blur-md backdrop-saturate-150",
            trackClassName
          )}
          style={{
            background: translucent
              ? "linear-gradient(to bottom, color-mix(in oklch, var(--muted) 55%, transparent) 0%, color-mix(in oklch, var(--muted) 40%, transparent) 100%)"
              : "var(--muted)",
          }}
        >
          {isIndeterminate ? (
            <IndeterminateBar reduceMotion={reduceMotion} />
          ) : (
            <motion.div
              animate={{ scaleX: pct }}
              aria-hidden
              className={cn(
                "absolute inset-y-0 left-0 h-full w-full origin-left rounded-[inherit]",
                "bg-gradient-to-b from-primary to-primary/85",
                "shadow-[0_1px_0_rgba(255,255,255,0.2)_inset]",
                indicatorClassName
              )}
              initial={false}
              style={{ willChange: reduceMotion ? undefined : "transform" }}
              transition={reduceMotion ? { duration: 0 } : { ...layoutSpring }}
            />
          )}
        </ProgressPrimitive.Track>
      </div>
    </ProgressPrimitive.Root>
  )
}

export type HaloProgressLabelProps = ComponentProps<
  typeof ProgressPrimitive.Label
>
export type HaloProgressValueProps = ComponentProps<
  typeof ProgressPrimitive.Value
>
```

**Update the import paths to match your project setup.**
