Docs
Go back
Component by
OV
OblivionProgress0%
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>
)
}"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-barDependencies
npm 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>
)
}import ProgressBar from "@/components/oblivion/progress-bar"
export default function Demo() {
return (
<div className="p-10 flex justify-center">
<ProgressBar />
</div>
)
}