Progress
Free, copy-and-go Progress components built on the SevenUI Progress primitive.Read the primitive docs.
CompactTable rows and dense lists
DefaultForms, cards, and settings
LargeInstalls and full-page tasks
"use client";
import {
Progress,
ProgressLabel,
ProgressValue,
} from "@/components/ui/progress";
const sizes = [
{
label: "Compact",
hint: "Table rows and dense lists",
value: 42,
className: "[&>[data-slot=progress-track]]:h-1",
},
{
label: "Default",
hint: "Forms, cards, and settings",
value: 64,
className: "[&>[data-slot=progress-track]]:h-2",
},
{
label: "Large",
hint: "Installs and full-page tasks",
value: 86,
className: "[&>[data-slot=progress-track]]:h-3",
},
];
export default function Progress01() {
return (
<div className="flex w-full max-w-sm flex-col gap-6">
{sizes.map((size) => (
<Progress
key={size.label}
value={size.value}
className={size.className}
>
<div className="flex w-full items-baseline gap-2">
<ProgressLabel>{size.label}</ProgressLabel>
<span className="truncate text-xs text-muted-foreground">
{size.hint}
</span>
<ProgressValue />
</div>
</Progress>
))}
</div>
);
}
npx shadcn@latest add @sevenui/component/progress-01pnpm dlx shadcn@latest add @sevenui/component/progress-01yarn dlx shadcn@latest add @sevenui/component/progress-01bunx --bun shadcn@latest add @sevenui/component/progress-01Profile completion
API quota
Course: Intro to SQL5 of 16 lessons
"use client";
import {
Progress,
ProgressLabel,
ProgressValue,
} from "@/components/ui/progress";
export default function Progress02() {
return (
<div className="flex w-full max-w-sm flex-col gap-8">
{/* Label and value share a header row above the track. */}
<Progress value={58}>
<ProgressLabel>Profile completion</ProgressLabel>
<ProgressValue />
</Progress>
{/* Value trails the track on the same line. */}
<Progress
value={73}
className="flex-nowrap items-center [&>[data-slot=progress-track]]:flex-1"
>
<ProgressLabel className="shrink-0 text-xs">API quota</ProgressLabel>
<ProgressValue className="order-last ml-0 w-10 text-right text-xs" />
</Progress>
{/* Label sits under the track with a helper line. */}
<Progress
value={31}
aria-describedby="progress-02-hint"
className="gap-2"
>
<div className="order-last flex w-full items-baseline justify-between gap-3">
<ProgressLabel className="text-xs">Course: Intro to SQL</ProgressLabel>
<span
id="progress-02-hint"
className="text-xs text-muted-foreground tabular-nums"
>
5 of 16 lessons
</span>
</div>
</Progress>
</div>
);
}
npx shadcn@latest add @sevenui/component/progress-02pnpm dlx shadcn@latest add @sevenui/component/progress-02yarn dlx shadcn@latest add @sevenui/component/progress-02bunx --bun shadcn@latest add @sevenui/component/progress-02Nightly backup
Completed at 02:14. 1,284 files verified.
Workspace storage
46 of 50 GB used. Archive old projects to free space.
Contacts import
Stopped at row 3,120: missing email column.
"use client";
import * as React from "react";
import {
CircleAlert,
CircleCheck,
LoaderCircle,
TriangleAlert,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Progress,
ProgressLabel,
ProgressValue,
} from "@/components/ui/progress";
const statuses = [
{
id: "success",
label: "Nightly backup",
message: "Completed at 02:14. 1,284 files verified.",
value: 100,
icon: CircleCheck,
iconClassName: "text-success",
className: "[&_[data-slot=progress-indicator]]:bg-success",
},
{
id: "warning",
label: "Workspace storage",
message: "46 of 50 GB used. Archive old projects to free space.",
value: 92,
icon: TriangleAlert,
iconClassName: "text-warning",
className: "[&_[data-slot=progress-indicator]]:bg-warning",
},
{
id: "error",
label: "Contacts import",
message: "Stopped at row 3,120: missing email column.",
value: 64,
icon: CircleAlert,
iconClassName: "text-destructive",
className: "[&_[data-slot=progress-indicator]]:bg-destructive",
},
];
type ImportState = "error" | "running" | "success";
// The failed import can be retried: it resumes from where it stopped.
const importStates = {
running: {
message: "Resuming from row 3,120 with the mapped email column.",
icon: LoaderCircle,
iconClassName: "animate-spin text-muted-foreground motion-reduce:animate-none",
className: "",
},
success: {
message: "Imported 4,870 contacts. 12 duplicates merged.",
icon: CircleCheck,
iconClassName: "text-success",
className: "[&_[data-slot=progress-indicator]]:bg-success",
},
};
export default function Progress03() {
const [importState, setImportState] = React.useState<ImportState>("error");
const [importValue, setImportValue] = React.useState(64);
React.useEffect(() => {
if (importState !== "running") return;
const timer = setInterval(() => {
setImportValue((current) => Math.min(current + 6, 100));
}, 150);
return () => clearInterval(timer);
}, [importState]);
React.useEffect(() => {
if (importState === "running" && importValue >= 100) {
setImportState("success");
}
}, [importState, importValue]);
const items = statuses.map((status) =>
status.id === "error" && importState !== "error"
? { ...status, ...importStates[importState], value: importValue }
: status,
);
return (
<div className="flex w-full max-w-sm flex-col gap-6">
{items.map((status) => {
const Icon = status.icon;
const messageId = `progress-03-${status.id}`;
return (
<div key={status.id} className="flex gap-3">
<Icon
aria-hidden="true"
className={`mt-0.5 size-4 shrink-0 ${status.iconClassName}`}
/>
<div className="flex min-w-0 flex-1 flex-col gap-2">
<Progress
value={status.value}
aria-describedby={messageId}
className={`[&>[data-slot=progress-track]]:h-1.5 ${status.className}`}
>
<ProgressLabel>{status.label}</ProgressLabel>
<ProgressValue />
</Progress>
<div className="flex items-start justify-between gap-3">
<p
id={messageId}
aria-live={status.id === "error" ? "polite" : undefined}
className="text-xs text-muted-foreground"
>
{status.message}
</p>
{status.id === "error" && importState === "error" ? (
<Button
variant="outline"
size="xs"
className="shrink-0"
onClick={() => setImportState("running")}
>
Retry
</Button>
) : null}
</div>
</div>
</div>
);
})}
</div>
);
}
npx shadcn@latest add @sevenui/component/progress-03pnpm dlx shadcn@latest add @sevenui/component/progress-03yarn dlx shadcn@latest add @sevenui/component/progress-03bunx --bun shadcn@latest add @sevenui/component/progress-03Export ready to start
Q3 invoices, 2,418 rows as CSV.
"use client";
import * as React from "react";
import { Download, LoaderCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Progress,
ProgressLabel,
ProgressValue,
} from "@/components/ui/progress";
type Phase = "idle" | "preparing" | "exporting" | "done";
const copy: Record<Phase, { label: string; hint: string }> = {
idle: {
label: "Export ready to start",
hint: "Q3 invoices, 2,418 rows as CSV.",
},
preparing: {
label: "Preparing export",
hint: "Counting rows and applying filters.",
},
exporting: {
label: "Writing file",
hint: "Keep this tab open until the export finishes.",
},
done: {
label: "Export complete",
hint: "invoices-q3.csv is ready to download.",
},
};
export default function Progress04() {
const [phase, setPhase] = React.useState<Phase>("idle");
const [value, setValue] = React.useState(0);
// Preparing has no measurable total yet, so the bar stays indeterminate.
React.useEffect(() => {
if (phase !== "preparing") return;
const timer = setTimeout(() => setPhase("exporting"), 1600);
return () => clearTimeout(timer);
}, [phase]);
React.useEffect(() => {
if (phase !== "exporting") return;
const timer = setInterval(() => {
setValue((current) => Math.min(current + 7, 100));
}, 180);
return () => clearInterval(timer);
}, [phase]);
React.useEffect(() => {
if (phase === "exporting" && value >= 100) setPhase("done");
}, [phase, value]);
function start() {
setValue(0);
setPhase("preparing");
}
const busy = phase === "preparing" || phase === "exporting";
return (
<div className="flex w-full max-w-sm flex-col gap-4">
<Progress
value={phase === "preparing" ? null : value}
aria-busy={busy}
aria-describedby="progress-04-hint"
className="[&>[data-slot=progress-track]]:h-2 [&_[data-indeterminate][data-slot=progress-indicator]]:w-full [&_[data-indeterminate][data-slot=progress-indicator]]:bg-primary/40 [&_[data-indeterminate][data-slot=progress-indicator]]:animate-pulse [&_[data-indeterminate][data-slot=progress-indicator]]:rounded-full motion-reduce:[&_[data-indeterminate][data-slot=progress-indicator]]:animate-none"
>
<ProgressLabel className="flex items-center gap-2">
{busy ? (
<LoaderCircle
aria-hidden="true"
className="size-3.5 animate-spin text-muted-foreground motion-reduce:animate-none"
/>
) : null}
{copy[phase].label}
</ProgressLabel>
{phase === "preparing" ? (
<span className="ml-auto text-sm text-muted-foreground">
Estimating
</span>
) : (
<ProgressValue />
)}
</Progress>
<div className="flex items-center justify-between gap-3">
<p id="progress-04-hint" className="text-xs text-muted-foreground">
{copy[phase].hint}
</p>
{phase === "done" ? (
<Button size="sm" variant="outline" onClick={start}>
<Download data-icon="inline-start" aria-hidden="true" />
Export again
</Button>
) : (
<Button size="sm" onClick={start} disabled={busy}>
{busy ? "Exporting" : "Start export"}
</Button>
)}
</div>
</div>
);
}
npx shadcn@latest add @sevenui/component/progress-04pnpm dlx shadcn@latest add @sevenui/component/progress-04yarn dlx shadcn@latest add @sevenui/component/progress-04bunx --bun shadcn@latest add @sevenui/component/progress-04Training distance this month
Support tickets closed
Fundraising goal
"use client";
import {
Progress,
ProgressLabel,
ProgressValue,
} from "@/components/ui/progress";
const meters = [
{
label: "Training distance this month",
value: 128.4,
min: 0,
max: 200,
format: {
style: "unit",
unit: "kilometer",
maximumFractionDigits: 1,
} satisfies Intl.NumberFormatOptions,
suffix: "of 200 km",
},
{
label: "Support tickets closed",
value: 38,
min: 0,
max: 120,
format: { maximumFractionDigits: 0 } satisfies Intl.NumberFormatOptions,
suffix: "of 120",
},
{
label: "Fundraising goal",
value: 12650,
min: 0,
max: 20000,
format: {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
} satisfies Intl.NumberFormatOptions,
suffix: "of $20,000",
},
];
export default function Progress05() {
return (
<div className="flex w-full max-w-sm flex-col gap-6">
{meters.map((meter) => (
<Progress
key={meter.label}
value={meter.value}
min={meter.min}
max={meter.max}
format={meter.format}
locale="en-US"
getAriaValueText={(formatted) => `${formatted} ${meter.suffix}`}
className="gap-2 [&>[data-slot=progress-track]]:h-1.5"
>
<ProgressLabel className="w-full text-xs font-normal text-muted-foreground">
{meter.label}
</ProgressLabel>
<ProgressValue className="ml-0 text-base font-semibold text-foreground">
{(formatted) => (
<>
{formatted}
<span className="ml-1 text-sm font-normal text-muted-foreground">
{meter.suffix}
</span>
</>
)}
</ProgressValue>
</Progress>
))}
</div>
);
}
npx shadcn@latest add @sevenui/component/progress-05pnpm dlx shadcn@latest add @sevenui/component/progress-05yarn dlx shadcn@latest add @sevenui/component/progress-05bunx --bun shadcn@latest add @sevenui/component/progress-05Draft replies with Assist
Assist reads the whole thread and suggests a reply in your saved tone. Press Tab to accept it.
1 / 4
"use client";
import * as React from "react";
import {
ChevronLeftIcon,
ChevronRightIcon,
KeyboardIcon,
PauseIcon,
PlayIcon,
RotateCcwIcon,
SparklesIcon,
TimerIcon,
UsersIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
const SLIDES = [
{
id: "assistant",
icon: SparklesIcon,
title: "Draft replies with Assist",
body: "Assist reads the whole thread and suggests a reply in your saved tone. Press Tab to accept it.",
},
{
id: "shortcuts",
icon: KeyboardIcon,
title: "Shortcuts for everything",
body: "Press ? anywhere to see every shortcut, including new ones for snooze and assign.",
},
{
id: "sla",
icon: TimerIcon,
title: "SLA timers on every ticket",
body: "Tickets close to their first-response deadline now show a countdown in the inbox.",
},
{
id: "sharing",
icon: UsersIcon,
title: "Shared drafts",
body: "Mention a teammate in a draft to get a review before the customer sees it.",
},
];
const SLIDE_MS = 5000;
const TICK_MS = 100;
export default function Progress06() {
const [index, setIndex] = React.useState(0);
const [elapsed, setElapsed] = React.useState(0);
const [playing, setPlaying] = React.useState(true);
const last = index === SLIDES.length - 1;
const finished = last && elapsed >= SLIDE_MS;
// The timer only runs while playing and stops itself on the last slide.
React.useEffect(() => {
if (!playing || finished) return;
const timer = setInterval(() => {
setElapsed((current) => current + TICK_MS);
}, TICK_MS);
return () => clearInterval(timer);
}, [playing, finished]);
React.useEffect(() => {
if (elapsed < SLIDE_MS || last) return;
setIndex((current) => current + 1);
setElapsed(0);
}, [elapsed, last]);
function goTo(next: number) {
setIndex(Math.min(Math.max(next, 0), SLIDES.length - 1));
setElapsed(0);
}
function replay() {
goTo(0);
setPlaying(true);
}
const slide = SLIDES[index];
const Icon = slide.icon;
return (
<section
aria-label="What's new in Helpdesk 4.2"
className="flex w-full max-w-sm flex-col gap-5 rounded-xl border bg-card p-5 text-card-foreground"
>
{/* One segment per slide: finished slides are full, the current one fills over time. */}
<div className="flex gap-1.5">
{SLIDES.map((item, itemIndex) => {
const value =
itemIndex < index
? 100
: itemIndex === index
? Math.min((elapsed / SLIDE_MS) * 100, 100)
: 0;
return (
<Progress
key={item.id}
value={value}
aria-label={`Slide ${itemIndex + 1}: ${item.title}`}
className="flex-1 [&_[data-slot=progress-indicator]]:duration-100 [&_[data-slot=progress-indicator]]:ease-linear"
/>
);
})}
</div>
{/* Announce slide changes only when the user drives them, not on autoplay. */}
<div
aria-live={playing && !finished ? "off" : "polite"}
className="flex min-h-36 flex-col gap-3"
>
<span className="flex size-10 items-center justify-center rounded-lg bg-muted">
<Icon aria-hidden="true" className="size-5" />
</span>
<div className="flex flex-col gap-1">
<h3 className="font-medium">{slide.title}</h3>
<p className="text-sm text-pretty text-muted-foreground">
{slide.body}
</p>
</div>
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground tabular-nums">
{index + 1} / {SLIDES.length}
</span>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
aria-label="Previous slide"
disabled={index === 0}
onClick={() => goTo(index - 1)}
>
<ChevronLeftIcon aria-hidden="true" />
</Button>
{finished ? (
<Button
variant="ghost"
size="icon-sm"
aria-label="Replay"
onClick={replay}
>
<RotateCcwIcon aria-hidden="true" />
</Button>
) : (
<Button
variant="ghost"
size="icon-sm"
aria-label={playing ? "Pause" : "Play"}
onClick={() => setPlaying((current) => !current)}
>
{playing ? (
<PauseIcon aria-hidden="true" />
) : (
<PlayIcon aria-hidden="true" />
)}
</Button>
)}
<Button
variant="ghost"
size="icon-sm"
aria-label="Next slide"
disabled={last}
onClick={() => goTo(index + 1)}
>
<ChevronRightIcon aria-hidden="true" />
</Button>
</div>
</div>
</section>
);
}
npx shadcn@latest add @sevenui/component/progress-06pnpm dlx shadcn@latest add @sevenui/component/progress-06yarn dlx shadcn@latest add @sevenui/component/progress-06bunx --bun shadcn@latest add @sevenui/component/progress-06Spring launch emails
Churn win-back
Beta invite wave 2
"use client";
import type { CSSProperties } from "react";
import {
Progress,
ProgressLabel,
ProgressValue,
} from "@/components/ui/progress";
const campaigns = [
{ name: "Spring launch emails", sent: 8240, total: 12000 },
{ name: "Churn win-back", sent: 1830, total: 2400 },
{ name: "Beta invite wave 2", sent: 310, total: 1500 },
];
const numberFormat = new Intl.NumberFormat("en-US");
export default function Progress07() {
return (
<div className="flex w-full max-w-sm flex-col gap-3">
{campaigns.map((campaign) => {
const percent = Math.round((campaign.sent / campaign.total) * 100);
const caption = `${numberFormat.format(campaign.sent)} of ${numberFormat.format(campaign.total)} sent`;
return (
<Progress
key={campaign.name}
value={campaign.sent}
max={campaign.total}
getAriaValueText={() => caption}
className="relative [&>[data-slot=progress-track]]:h-8 [&>[data-slot=progress-track]]:rounded-md [&_[data-slot=progress-indicator]]:rounded-none"
>
<ProgressLabel className="sr-only">{campaign.name}</ProgressLabel>
<ProgressValue className="sr-only" />
{/*
Two stacked copies of the same text: the top one is clipped to
the filled width so it flips color exactly where the bar ends.
*/}
<InsideLabel
name={campaign.name}
caption={`${percent}%`}
className="text-foreground"
/>
<InsideLabel
name={campaign.name}
caption={`${percent}%`}
className="text-primary-foreground transition-[clip-path]"
style={{ clipPath: `inset(0 ${100 - percent}% 0 0)` }}
/>
</Progress>
);
})}
</div>
);
}
function InsideLabel({
name,
caption,
className,
style,
}: {
name: string;
caption: string;
className: string;
style?: CSSProperties;
}) {
return (
<div
aria-hidden="true"
style={style}
className={`pointer-events-none absolute inset-x-0 bottom-0 z-10 flex h-8 items-center justify-between gap-3 px-3 text-xs font-medium ${className}`}
>
<span className="truncate">{name}</span>
<span className="tabular-nums">{caption}</span>
</div>
);
}
npx shadcn@latest add @sevenui/component/progress-07pnpm dlx shadcn@latest add @sevenui/component/progress-07yarn dlx shadcn@latest add @sevenui/component/progress-07bunx --bun shadcn@latest add @sevenui/component/progress-07Growth team · Q3 goals
34 days leftQuarter elapsed
- PNRaise week-one activation to 45%Owned by Priya NairOn track
- DOCut self-serve churn below 3%Owned by Daniel OkaforAt risk
- LFShip in-app NPS to all workspacesOwned by Lena FischerBehind
Expected pace for day 58 (63%)
"use client";
import { cn } from "cn";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import {
Progress,
ProgressLabel,
ProgressValue,
} from "@/components/ui/progress";
const QUARTER_DAYS = 92;
const DAY = 58;
const KEY_RESULTS = [
{
id: "activation",
title: "Raise week-one activation to 45%",
owner: { name: "Priya Nair", initials: "PN" },
value: 71,
},
{
id: "churn",
title: "Cut self-serve churn below 3%",
owner: { name: "Daniel Okafor", initials: "DO" },
value: 52,
},
{
id: "nps",
title: "Ship in-app NPS to all workspaces",
owner: { name: "Lena Fischer", initials: "LF" },
value: 28,
},
];
const STATUS = {
ahead: {
label: "On track",
dot: "bg-success",
bar: "[&_[data-slot=progress-indicator]]:bg-success",
},
risk: {
label: "At risk",
dot: "bg-warning",
bar: "[&_[data-slot=progress-indicator]]:bg-warning",
},
behind: {
label: "Behind",
dot: "bg-destructive",
bar: "[&_[data-slot=progress-indicator]]:bg-destructive",
},
};
function getStatus(value: number, pace: number) {
if (value >= pace - 5) return STATUS.ahead;
if (value >= pace - 20) return STATUS.risk;
return STATUS.behind;
}
export default function Progress08() {
const pace = Math.round((DAY / QUARTER_DAYS) * 100);
return (
<section
aria-labelledby="progress-08-title"
className="flex w-full max-w-md flex-col gap-5 rounded-xl border bg-card p-5 text-card-foreground"
>
<header className="flex flex-col gap-3">
<div className="flex items-baseline justify-between gap-2">
<h3 id="progress-08-title" className="font-medium">
Growth team · Q3 goals
</h3>
<span className="shrink-0 text-xs whitespace-nowrap text-muted-foreground tabular-nums">
{QUARTER_DAYS - DAY} days left
</span>
</div>
<Progress
value={DAY}
max={QUARTER_DAYS}
getAriaValueText={() => `Day ${DAY} of ${QUARTER_DAYS}`}
className="gap-1.5 [&_[data-slot=progress-indicator]]:bg-muted-foreground/50"
>
<ProgressLabel className="text-xs font-normal text-muted-foreground">
Quarter elapsed
</ProgressLabel>
<ProgressValue className="text-xs" />
</Progress>
</header>
<ul className="flex flex-col divide-y">
{KEY_RESULTS.map((kr) => {
const status = getStatus(kr.value, pace);
return (
<li
key={kr.id}
className="flex flex-col gap-3 py-4 first:pt-0 last:pb-0"
>
<div className="flex items-start gap-3">
<Avatar size="sm" className="max-sm:hidden">
<AvatarImage src="/placeholder.svg" alt="" />
<AvatarFallback>{kr.owner.initials}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-1 flex-col">
<span className="text-sm font-medium">{kr.title}</span>
<span className="text-xs text-muted-foreground">
Owned by {kr.owner.name}
</span>
</div>
<Badge variant="outline" className="shrink-0">
<span
aria-hidden="true"
className={cn("size-1.5 rounded-full", status.dot)}
/>
{status.label}
</Badge>
</div>
<div className="relative">
<Progress
value={kr.value}
aria-label={kr.title}
getAriaValueText={(formatted) =>
`${formatted} complete, expected ${pace}% by today, ${status.label.toLowerCase()}`
}
className={cn(
"flex-nowrap items-center gap-3 [&_[data-slot=progress-track]]:order-first [&_[data-slot=progress-track]]:h-2",
status.bar,
)}
>
<ProgressValue className="w-9 shrink-0 text-right text-xs" />
</Progress>
<span
aria-hidden="true"
title={`Expected pace: ${pace}%`}
className="pointer-events-none absolute top-1/2 h-3.5 w-0.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-foreground"
style={{ left: `calc((100% - 3rem) * ${pace / 100})` }}
/>
</div>
</li>
);
})}
</ul>
<p className="flex items-center gap-2 text-xs text-muted-foreground">
<span
aria-hidden="true"
className="inline-block h-3 w-0.5 rounded-full bg-foreground"
/>
Expected pace for day {DAY} ({pace}%)
</p>
</section>
);
}
npx shadcn@latest add @sevenui/component/progress-08pnpm dlx shadcn@latest add @sevenui/component/progress-08yarn dlx shadcn@latest add @sevenui/component/progress-08bunx --bun shadcn@latest add @sevenui/component/progress-08Uploading to Marketing / Q4 launch
1 of 4 files · 87 / 213 MB
- brand-guidelines-2026.pdf8.4 MB · Uploaded
- launch-teaser-cut-03.mp455 of 146 MB
- board-deck-final.key23 of 52 MB
- homepage-hero@2x.pngWaiting
"use client";
import * as React from "react";
import {
CircleCheckIcon,
FileArchiveIcon,
FileImageIcon,
FileTextIcon,
FileVideoIcon,
RotateCwIcon,
XIcon,
} from "lucide-react";
import { cn } from "cn";
import { Button } from "@/components/ui/button";
import {
Progress,
ProgressLabel,
ProgressValue,
} from "@/components/ui/progress";
type Status = "queued" | "uploading" | "processing" | "done" | "failed";
type Upload = {
id: string;
name: string;
sizeMb: number;
icon: typeof FileTextIcon;
progress: number;
status: Status;
failAt?: number;
processingTicks: number;
};
const INITIAL: Upload[] = [
{
id: "brand",
name: "brand-guidelines-2026.pdf",
sizeMb: 8.4,
icon: FileTextIcon,
progress: 100,
status: "done",
processingTicks: 0,
},
{
id: "launch",
name: "launch-teaser-cut-03.mp4",
sizeMb: 146,
icon: FileVideoIcon,
progress: 38,
status: "uploading",
processingTicks: 0,
},
{
id: "deck",
name: "board-deck-final.key",
sizeMb: 52,
icon: FileArchiveIcon,
progress: 44,
status: "uploading",
failAt: 62,
processingTicks: 0,
},
{
id: "hero",
name: "homepage-hero@2x.png",
sizeMb: 6.1,
icon: FileImageIcon,
progress: 0,
status: "queued",
processingTicks: 0,
},
];
const MAX_PARALLEL = 2;
const TICK_MS = 450;
function step(uploads: Upload[]): Upload[] {
const next = uploads.map((file): Upload => {
if (file.status === "processing") {
return file.processingTicks >= 3
? { ...file, status: "done" }
: { ...file, processingTicks: file.processingTicks + 1 };
}
if (file.status !== "uploading") return file;
const increment = Math.max(2, Math.round(240 / file.sizeMb));
const progress = Math.min(file.progress + increment, 100);
if (file.failAt !== undefined && progress >= file.failAt) {
return { ...file, progress: file.failAt, status: "failed" };
}
if (progress === 100) {
return { ...file, progress, status: "processing" };
}
return { ...file, progress };
});
// Start queued files while a parallel slot is free.
let active = next.filter((file) => file.status === "uploading").length;
return next.map((file) => {
if (file.status === "queued" && active < MAX_PARALLEL) {
active += 1;
return { ...file, status: "uploading" };
}
return file;
});
}
const STATUS_TEXT: Record<Status, string> = {
queued: "Waiting",
uploading: "Uploading",
processing: "Scanning for viruses",
done: "Uploaded",
failed: "Connection lost",
};
function describe(file: Upload) {
switch (file.status) {
case "uploading":
return `${Math.round((file.sizeMb * file.progress) / 100)} of ${file.sizeMb} MB`;
case "done":
return `${file.sizeMb} MB · ${STATUS_TEXT.done}`;
case "failed":
return `${STATUS_TEXT.failed} at ${file.progress}%`;
default:
return STATUS_TEXT[file.status];
}
}
export default function Progress09() {
const [uploads, setUploads] = React.useState(INITIAL);
const running = uploads.some((file) =>
["queued", "uploading", "processing"].includes(file.status),
);
React.useEffect(() => {
if (!running) return;
const timer = setInterval(() => setUploads(step), TICK_MS);
return () => clearInterval(timer);
}, [running]);
const totalMb = uploads.reduce((sum, file) => sum + file.sizeMb, 0);
const sentMb = uploads.reduce(
(sum, file) => sum + (file.sizeMb * file.progress) / 100,
0,
);
const doneCount = uploads.filter((file) => file.status === "done").length;
const failedCount = uploads.filter((file) => file.status === "failed").length;
function retry(id: string) {
setUploads((current) =>
current.map((file) =>
file.id === id
? { ...file, status: "queued", progress: 0, failAt: undefined }
: file,
),
);
}
function remove(id: string) {
setUploads((current) => current.filter((file) => file.id !== id));
}
return (
<section
aria-labelledby="progress-09-title"
className="flex w-full max-w-md flex-col overflow-hidden rounded-xl border bg-card text-card-foreground"
>
<div className="flex flex-col gap-3 border-b bg-muted/40 p-4">
<h3 id="progress-09-title" className="text-sm font-medium">
Uploading to Marketing / Q4 launch
</h3>
<Progress
value={totalMb === 0 ? 0 : (sentMb / totalMb) * 100}
getAriaValueText={(formatted) =>
`${formatted} of all files uploaded`
}
className="gap-1.5 [&_[data-slot=progress-track]]:h-1.5"
>
<ProgressLabel className="text-xs font-normal text-muted-foreground tabular-nums">
{doneCount} of {uploads.length} files ·{" "}
{Math.round(sentMb)} / {Math.round(totalMb)} MB
{failedCount > 0 ? ` · ${failedCount} failed` : ""}
</ProgressLabel>
<ProgressValue className="text-xs" />
</Progress>
</div>
{uploads.length === 0 ? (
<p className="p-6 text-center text-sm text-muted-foreground">
No uploads in progress.
</p>
) : (
<ul className="flex flex-col divide-y">
{uploads.map((file) => {
const Icon = file.icon;
const failed = file.status === "failed";
const done = file.status === "done";
return (
<li key={file.id} className="flex items-start gap-3 p-4">
<span
aria-hidden="true"
className="flex size-9 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground"
>
<Icon className="size-4" />
</span>
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<Progress
value={file.status === "processing" ? null : file.progress}
getAriaValueText={(formatted, value) =>
value === null
? `${file.name}: ${STATUS_TEXT.processing}`
: `${file.name}: ${STATUS_TEXT[file.status]}, ${formatted}`
}
className={cn(
"gap-x-2 gap-y-1.5 [&[data-indeterminate]_[data-slot=progress-indicator]]:w-full [&[data-indeterminate]_[data-slot=progress-indicator]]:animate-pulse motion-reduce:[&[data-indeterminate]_[data-slot=progress-indicator]]:animate-none",
failed &&
"[&_[data-slot=progress-indicator]]:bg-destructive",
done && "[&_[data-slot=progress-track]]:hidden",
)}
>
<ProgressLabel className="min-w-0 flex-1 truncate">
{file.name}
</ProgressLabel>
<span
className={cn(
"flex w-full items-center gap-1.5 text-xs text-muted-foreground tabular-nums",
failed && "text-destructive",
)}
>
{done ? (
<CircleCheckIcon
aria-hidden="true"
className="size-3.5 text-success"
/>
) : null}
<span>{describe(file)}</span>
{file.status === "uploading" ? (
<ProgressValue className="text-xs" />
) : null}
</span>
</Progress>
</div>
{failed ? (
<Button
variant="ghost"
size="icon-sm"
aria-label={`Retry ${file.name}`}
onClick={() => retry(file.id)}
>
<RotateCwIcon aria-hidden="true" />
</Button>
) : null}
{done ? null : (
<Button
variant="ghost"
size="icon-sm"
aria-label={`Cancel ${file.name}`}
onClick={() => remove(file.id)}
>
<XIcon aria-hidden="true" />
</Button>
)}
</li>
);
})}
</ul>
)}
</section>
);
}
npx shadcn@latest add @sevenui/component/progress-09pnpm dlx shadcn@latest add @sevenui/component/progress-09yarn dlx shadcn@latest add @sevenui/component/progress-09bunx --bun shadcn@latest add @sevenui/component/progress-09Production deployment
a1f9c2eFix invoice rounding on annual plans
Elapsed 0m 41s
- Install dependencies (complete)
- Build
next build · compiling 214 routes - Run tests (pending)
- Upload static assets (pending)
- Promote to production (pending)
"use client";
import * as React from "react";
import {
CircleCheckIcon,
CircleDashedIcon,
CircleSlashIcon,
GitCommitHorizontalIcon,
LoaderCircleIcon,
} from "lucide-react";
import { cn } from "cn";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Progress,
ProgressLabel,
ProgressValue,
} from "@/components/ui/progress";
const STAGES = [
{
id: "install",
label: "Install dependencies",
speed: 22,
log: "pnpm install --frozen-lockfile · 1,284 packages",
},
{
id: "build",
label: "Build",
speed: 9,
log: "next build · compiling 214 routes",
},
{
id: "test",
label: "Run tests",
speed: 14,
log: "vitest run · 612 passed",
},
{
id: "upload",
label: "Upload static assets",
speed: 26,
log: "uploading 1,903 files to edge cache",
},
{
id: "promote",
label: "Promote to production",
speed: 34,
log: "switching alias app.northwind.dev",
},
];
const TICK_MS = 300;
type Run = {
stage: number;
stageProgress: number;
elapsed: number;
status: "running" | "done" | "canceled";
};
const START: Run = { stage: 1, stageProgress: 35, elapsed: 41.4, status: "running" };
function advance(run: Run): Run {
if (run.status !== "running") return run;
const elapsed = run.elapsed + TICK_MS / 1000;
const stageProgress = run.stageProgress + STAGES[run.stage].speed;
if (stageProgress < 100) return { ...run, elapsed, stageProgress };
if (run.stage === STAGES.length - 1) {
return { ...run, elapsed, stageProgress: 100, status: "done" };
}
return { ...run, elapsed, stage: run.stage + 1, stageProgress: 0 };
}
function formatElapsed(seconds: number) {
const whole = Math.floor(seconds);
return `${Math.floor(whole / 60)}m ${String(whole % 60).padStart(2, "0")}s`;
}
export default function Progress10() {
const [run, setRun] = React.useState<Run>(START);
React.useEffect(() => {
if (run.status !== "running") return;
const timer = setInterval(() => setRun(advance), TICK_MS);
return () => clearInterval(timer);
}, [run.status]);
const overall =
((run.stage * 100 + run.stageProgress) / (STAGES.length * 100)) * 100;
const badge = {
running: { label: "Building", dot: "bg-primary" },
done: { label: "Ready", dot: "bg-success" },
canceled: { label: "Canceled", dot: "bg-muted-foreground" },
}[run.status];
return (
<section
aria-labelledby="progress-10-title"
className="flex w-full max-w-md flex-col rounded-xl border bg-card text-card-foreground"
>
<header className="flex flex-col gap-4 p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-1">
<h3 id="progress-10-title" className="font-medium">
Production deployment
</h3>
<p className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
<GitCommitHorizontalIcon
aria-hidden="true"
className="size-3.5 shrink-0"
/>
<span className="shrink-0 font-mono">a1f9c2e</span>
<span className="truncate">Fix invoice rounding on annual plans</span>
</p>
</div>
<Badge variant="outline" className="shrink-0">
<span
aria-hidden="true"
className={cn("size-1.5 rounded-full", badge.dot)}
/>
{badge.label}
</Badge>
</div>
<Progress
value={overall}
getAriaValueText={(formatted) =>
run.status === "canceled"
? `Canceled at ${formatted}`
: `${formatted} deployed, stage ${run.stage + 1} of ${STAGES.length}`
}
className={cn(
"gap-2 [&_[data-slot=progress-track]]:h-2",
run.status === "done" &&
"[&_[data-slot=progress-indicator]]:bg-success",
run.status === "canceled" &&
"[&_[data-slot=progress-indicator]]:bg-muted-foreground/50",
)}
>
<ProgressLabel className="text-xs font-normal text-muted-foreground">
{run.status === "done" ? "Completed in " : "Elapsed "}
<span className="font-medium text-foreground tabular-nums">
{formatElapsed(run.elapsed)}
</span>
</ProgressLabel>
<ProgressValue className="text-xs" />
</Progress>
</header>
<ol className="flex flex-col border-t py-2">
{STAGES.map((stage, index) => {
const state =
index < run.stage || run.status === "done"
? "done"
: index === run.stage
? run.status === "canceled"
? "canceled"
: "active"
: "pending";
return (
<li
key={stage.id}
aria-current={state === "active" ? "step" : undefined}
className={cn(
"flex items-start gap-3 px-4 py-2",
state === "active" && "bg-muted/50",
)}
>
{state === "done" ? (
<CircleCheckIcon
aria-hidden="true"
className="mt-0.5 size-4 shrink-0 text-success"
/>
) : state === "active" ? (
<LoaderCircleIcon
aria-hidden="true"
className="mt-0.5 size-4 shrink-0 animate-spin text-primary motion-reduce:animate-none"
/>
) : state === "canceled" ? (
<CircleSlashIcon
aria-hidden="true"
className="mt-0.5 size-4 shrink-0 text-muted-foreground"
/>
) : (
<CircleDashedIcon
aria-hidden="true"
className="mt-0.5 size-4 shrink-0 text-muted-foreground/60"
/>
)}
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<span
className={cn(
"text-sm",
state === "pending" && "text-muted-foreground",
)}
>
{stage.label}
<span className="sr-only">
{state === "done"
? " (complete)"
: state === "pending"
? " (pending)"
: state === "canceled"
? " (canceled)"
: ""}
</span>
</span>
{state === "active" ? (
<>
<Progress
value={Math.min(run.stageProgress, 100)}
aria-label={`${stage.label} progress`}
className="[&_[data-slot=progress-track]]:bg-background"
/>
<code className="truncate font-mono text-xs text-muted-foreground">
{stage.log}
</code>
</>
) : null}
</div>
</li>
);
})}
</ol>
<footer className="flex items-center justify-end gap-2 border-t p-4">
{run.status === "running" ? (
<Button
variant="outline"
aria-label="Cancel deployment"
onClick={() =>
setRun((current) => ({ ...current, status: "canceled" }))
}
>
Cancel
<span className="hidden sm:inline"> deployment</span>
</Button>
) : (
<Button
variant="outline"
onClick={() =>
setRun({ stage: 0, stageProgress: 0, elapsed: 0, status: "running" })
}
>
Redeploy
</Button>
)}
<Button disabled={run.status !== "done"}>Visit site</Button>
</footer>
</section>
);
}
npx shadcn@latest add @sevenui/component/progress-10pnpm dlx shadcn@latest add @sevenui/component/progress-10yarn dlx shadcn@latest add @sevenui/component/progress-10bunx --bun shadcn@latest add @sevenui/component/progress-10