Oblivion
Oblivion
Docs
Go back
Component by
OV
Oblivion
Progress0%
TSX
progress-bar
"use client"

import { useEffect, useRef, useState } from "react"
import { cn } from "@/lib/utils"

export const componentMeta = {
  name: "Progress Bar",
  description: "An animated progress bar with percentage label and three visual variants.",
  props: {
    value:    { type: "number",                                 default: "0",         description: "Target value (0–100)" },
    variant:  { type: "'default' | 'gradient' | 'striped'",    default: '"gradient"', description: "Visual style" },
    showLabel:{ type: "boolean",                                default: "true",       description: "Show percentage label" },
    duration: { type: "number",                                 default: "1000",       description: "Animation duration in ms" },
    className:{ type: "string",                                 description: "Extra classes" },
  }
}

type Variant = "default" | "gradient" | "striped"

const TRACK_CLS = "h-2.5 w-full rounded-full bg-zinc-100 dark:bg-zinc-800 overflow-hidden"

const FILL_CLS: Record<Variant, string> = {
  default:  "h-full rounded-full bg-zinc-900 dark:bg-white transition-all",
  gradient: "h-full rounded-full bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 transition-all",
  striped:  "h-full rounded-full bg-indigo-500 dark:bg-indigo-400 transition-all ob-striped",
}

export default function ProgressBar({
  value     = 68,
  variant   = "gradient",
  showLabel = true,
  duration  = 1000,
  className,
}: {
  value?:     number
  variant?:   Variant
  showLabel?: boolean
  duration?:  number
  className?: string
}) {
  const [current, setCurrent] = useState(0)
  const ref = useRef<HTMLDivElement>(null)
  const animated = useRef(false)

  useEffect(() => {
    const el = ref.current
    if (!el) return
    const obs = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting && !animated.current) {
        animated.current = true
        const start = performance.now()
        const tick = (now: number) => {
          const pct = Math.min((now - start) / duration, 1)
          // ease-out cubic
          const eased = 1 - Math.pow(1 - pct, 3)
          setCurrent(Math.round(eased * value))
          if (pct < 1) requestAnimationFrame(tick)
        }
        requestAnimationFrame(tick)
      }
    }, { threshold: 0.5 })
    obs.observe(el)
    return () => obs.disconnect()
  }, [value, duration])

  return (
    <div ref={ref} className={cn("w-full space-y-2", className)}>
      {showLabel && (
        <div className="flex justify-between items-center">
          <span className="text-xs font-medium text-zinc-500 dark:text-zinc-400">Progress</span>
          <span className="text-xs font-bold tabular-nums text-zinc-900 dark:text-white">{current}%</span>
        </div>
      )}
      <div className={TRACK_CLS}>
        <div
          className={FILL_CLS[variant]}
          style={{ width: `${current}%`, transition: `width 16ms linear` }}
        />
      </div>
      <style dangerouslySetInnerHTML={{ __html: `
        .ob-striped {
          background-image: repeating-linear-gradient(
            45deg,
            transparent, transparent 6px,
            rgba(255,255,255,0.18) 6px, rgba(255,255,255,0.18) 12px
          );
        }
      `}} />
    </div>
  )
}

export function ProgressBarPreview() {
  const bars: Array<{ label: string; value: number; variant: Variant }> = [
    { label: "Design",      value: 92, variant: "gradient" },
    { label: "Frontend",    value: 78, variant: "striped"  },
    { label: "Backend",     value: 54, variant: "default"  },
    { label: "Deployment",  value: 31, variant: "gradient" },
  ]

  return (
    <div className="flex h-full min-h-[480px] w-full items-center justify-center bg-transparent px-10">
      <div className="w-full max-w-sm space-y-6">
        <p className="text-xs font-semibold uppercase tracking-widest text-zinc-500 dark:text-zinc-400 mb-2">
          Project status
        </p>
        {bars.map((b) => (
          <div key={b.label} className="space-y-1.5">
            <div className="flex justify-between text-xs">
              <span className="font-medium text-zinc-700 dark:text-zinc-300">{b.label}</span>
            </div>
            <ProgressBar value={b.value} variant={b.variant} showLabel={false} />
            <div className="text-right text-[10px] font-bold text-zinc-400 tabular-nums">{b.value}%</div>
          </div>
        ))}
      </div>
    </div>
  )
}
Save to favoritesCopy to Figma
React

Progress Bar

An animated progress bar with percentage label and three visual variants.

Installation

$ npx @cosmoo/oblivion add progress-bar
Dependenciesnpm install

Usage Example

tsx
import ProgressBar from "@/components/oblivion/progress-bar"

export default function Demo() {
  return (
    <div className="p-10 flex justify-center">
      <ProgressBar />
    </div>
  )
}