Spinner
Free, copy-and-go Spinner components built on the SevenUI Spinner primitive.Read the primitive docs.
- xsBadges, table cells
- smSmall buttons
- mdButtons, inputs
- lgCards, panels
- xlFull-page loads
"use client";
import { Spinner } from "@/components/ui/spinner";
const sizes = [
{ name: "xs", className: "size-3", usage: "Badges, table cells" },
{ name: "sm", className: "size-3.5", usage: "Small buttons" },
{ name: "md", className: "size-4", usage: "Buttons, inputs" },
{ name: "lg", className: "size-6", usage: "Cards, panels" },
{ name: "xl", className: "size-8", usage: "Full-page loads" },
];
export default function Spinner01() {
return (
<ul className="grid w-full max-w-md grid-cols-5 gap-2">
{sizes.map((size) => (
<li
key={size.name}
className="flex flex-col items-center gap-3 text-center"
>
<div className="flex size-12 items-center justify-center">
<Spinner
aria-label={`Loading, ${size.name} size`}
className={size.className}
/>
</div>
<div className="flex flex-col gap-0.5">
<span className="font-mono text-xs font-medium">{size.name}</span>
<span className="text-[0.7rem] leading-tight text-balance text-muted-foreground">
{size.usage}
</span>
</div>
</li>
))}
</ul>
);
}
npx shadcn@latest add @sevenui/component/spinner-01pnpm dlx shadcn@latest add @sevenui/component/spinner-01yarn dlx shadcn@latest add @sevenui/component/spinner-01bunx --bun shadcn@latest add @sevenui/component/spinner-01- NeutralLoading messages…
- MutedFetching older activity…
- PrimaryPublishing changes…
- SuccessVerifying payment…
- WarningRetrying connection…
- DestructiveDeleting workspace…
"use client";
import { Spinner } from "@/components/ui/spinner";
const tones = [
{
name: "Neutral",
label: "Loading messages",
className: "text-foreground",
},
{
name: "Muted",
label: "Fetching older activity",
className: "text-muted-foreground",
},
{
name: "Primary",
label: "Publishing changes",
className: "text-primary",
},
{
name: "Success",
label: "Verifying payment",
className: "text-success",
},
{
name: "Warning",
label: "Retrying connection",
className: "text-warning",
},
{
name: "Destructive",
label: "Deleting workspace",
className: "text-destructive",
},
];
export default function Spinner02() {
return (
<ul className="grid w-full max-w-sm grid-cols-1 gap-x-6 gap-y-3 sm:grid-cols-2">
{tones.map((tone) => (
<li key={tone.name} className="flex items-center gap-3">
<Spinner aria-label={tone.label} className={tone.className} />
<div className="flex min-w-0 flex-col">
<span className="text-sm font-medium">{tone.name}</span>
<span className="truncate text-xs text-muted-foreground">
{tone.label}…
</span>
</div>
</li>
))}
</ul>
);
}
npx shadcn@latest add @sevenui/component/spinner-02pnpm dlx shadcn@latest add @sevenui/component/spinner-02yarn dlx shadcn@latest add @sevenui/component/spinner-02bunx --bun shadcn@latest add @sevenui/component/spinner-02Stroke weight
- Hairline
- Regular
- Bold
With track
- Subtle
- Primary
- Filled
"use client";
import { Spinner } from "@/components/ui/spinner";
const weights = [
{ name: "Hairline", strokeWidth: 1 },
{ name: "Regular", strokeWidth: 2 },
{ name: "Bold", strokeWidth: 3 },
];
export default function Spinner03() {
return (
<div className="flex w-full max-w-sm flex-col gap-6">
<div className="flex flex-col gap-3">
<p className="text-sm font-medium">Stroke weight</p>
<ul className="flex items-center justify-between gap-4">
{weights.map((weight) => (
<li key={weight.name} className="flex flex-col items-center gap-2">
<Spinner
aria-label={`Loading, ${weight.name.toLowerCase()} stroke`}
strokeWidth={weight.strokeWidth}
className="size-7"
/>
<span className="text-xs text-muted-foreground">
{weight.name}
</span>
</li>
))}
</ul>
</div>
<div className="flex flex-col gap-3">
<p className="text-sm font-medium">With track</p>
<ul className="flex items-end justify-between gap-4">
<li className="flex flex-col items-center gap-2">
<span className="relative flex size-7 items-center justify-center">
<span
aria-hidden="true"
className="absolute inset-[2.5px] rounded-full border-2 border-muted"
/>
<Spinner
aria-label="Loading, subtle track"
className="relative size-7"
/>
</span>
<span className="text-xs text-muted-foreground">Subtle</span>
</li>
<li className="flex flex-col items-center gap-2">
<span className="relative flex size-7 items-center justify-center">
<span
aria-hidden="true"
className="absolute inset-[2.5px] rounded-full border-2 border-primary/20"
/>
<Spinner
aria-label="Loading, primary track"
className="relative size-7 text-primary"
/>
</span>
<span className="text-xs text-muted-foreground">Primary</span>
</li>
<li className="flex flex-col items-center gap-2">
<span className="flex size-9 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-sm">
<Spinner aria-label="Loading, filled" className="size-5" />
</span>
<span className="text-xs text-muted-foreground">Filled</span>
</li>
</ul>
</div>
</div>
);
}
npx shadcn@latest add @sevenui/component/spinner-03pnpm dlx shadcn@latest add @sevenui/component/spinner-03yarn dlx shadcn@latest add @sevenui/component/spinner-03bunx --bun shadcn@latest add @sevenui/component/spinner-03- api-gatewayeu-central-1Deploying
- billing-workerus-east-1Syncing
- search-indexerap-south-1Provisioning
- email-relayus-west-2Rolling back
"use client";
import { Badge } from "@/components/ui/badge";
import { Spinner } from "@/components/ui/spinner";
const services = [
{
name: "api-gateway",
region: "eu-central-1",
status: "Deploying",
variant: "default" as const,
},
{
name: "billing-worker",
region: "us-east-1",
status: "Syncing",
variant: "secondary" as const,
},
{
name: "search-indexer",
region: "ap-south-1",
status: "Provisioning",
variant: "outline" as const,
},
{
name: "email-relay",
region: "us-west-2",
status: "Rolling back",
variant: "destructive" as const,
},
];
export default function Spinner04() {
return (
<ul className="w-full max-w-sm divide-y divide-border rounded-xl border border-border bg-card">
{services.map((service) => (
<li
key={service.name}
className="flex items-center justify-between gap-3 px-4 py-3"
>
<div className="flex min-w-0 flex-col">
<span className="truncate font-mono text-sm">{service.name}</span>
<span className="text-xs text-muted-foreground">
{service.region}
</span>
</div>
<Badge variant={service.variant}>
<Spinner
aria-label={`${service.name}: ${service.status}`}
data-icon="inline-start"
/>
{service.status}
</Badge>
</li>
))}
</ul>
);
}
npx shadcn@latest add @sevenui/component/spinner-04pnpm dlx shadcn@latest add @sevenui/component/spinner-04yarn dlx shadcn@latest add @sevenui/component/spinner-04bunx --bun shadcn@latest add @sevenui/component/spinner-04Ready
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
const actions = [
{
id: "publish",
variant: "default" as const,
idle: "Publish",
busy: "Publishing…",
},
{
id: "export",
variant: "outline" as const,
idle: "Export CSV",
busy: "Exporting…",
},
{
id: "sync",
variant: "secondary" as const,
idle: "Sync now",
busy: "Syncing…",
},
{
id: "archive",
variant: "destructive" as const,
idle: "Archive",
busy: "Archiving…",
},
];
export default function Spinner05() {
const [pending, setPending] = useState<string | null>(null);
useEffect(() => {
if (!pending) return;
const timer = window.setTimeout(() => setPending(null), 2000);
return () => window.clearTimeout(timer);
}, [pending]);
return (
<div className="flex w-full max-w-lg flex-col items-center gap-4">
<div className="flex flex-wrap items-center justify-center gap-2">
{actions.map((action) => {
const isBusy = pending === action.id;
return (
<Button
key={action.id}
variant={action.variant}
disabled={pending !== null}
aria-busy={isBusy || undefined}
onClick={() => setPending(action.id)}
className="min-w-28"
>
{isBusy ? (
<Spinner aria-hidden="true" data-icon="inline-start" />
) : null}
{isBusy ? action.busy : action.idle}
</Button>
);
})}
</div>
<div className="flex items-center gap-2">
<Button size="icon" variant="outline" disabled aria-label="Refreshing">
<Spinner aria-hidden="true" />
</Button>
<Button size="sm" variant="ghost" disabled>
<Spinner aria-hidden="true" data-icon="inline-start" />
Loading more
</Button>
</div>
<p aria-live="polite" className="sr-only">
{pending
? actions.find((action) => action.id === pending)?.busy
: "Ready"}
</p>
</div>
);
}
npx shadcn@latest add @sevenui/component/spinner-05pnpm dlx shadcn@latest add @sevenui/component/spinner-05yarn dlx shadcn@latest add @sevenui/component/spinner-05bunx --bun shadcn@latest add @sevenui/component/spinner-05Checking availability…
Try design to see a taken handle.
"use client";
import { CircleAlertIcon, CircleCheckIcon } from "lucide-react";
import { useEffect, useState } from "react";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@/components/ui/input-group";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
const takenHandles = ["admin", "sevenui", "support", "design"];
type Status = "idle" | "checking" | "available" | "taken";
export default function Spinner06() {
const [value, setValue] = useState("maya-lin");
const [status, setStatus] = useState<Status>("checking");
useEffect(() => {
const handle = value.trim().toLowerCase();
if (handle.length < 3) {
setStatus("idle");
return;
}
setStatus("checking");
const timer = window.setTimeout(() => {
setStatus(takenHandles.includes(handle) ? "taken" : "available");
}, 900);
return () => window.clearTimeout(timer);
}, [value]);
const message = {
idle: "Use at least 3 characters.",
checking: "Checking availability…",
available: `sevenui.dev/@${value.trim()} is available.`,
taken: "That handle is taken. Try adding your team name.",
}[status];
return (
<div className="flex w-full max-w-xs flex-col gap-2">
<Label htmlFor="spinner-06-handle">Profile handle</Label>
<InputGroup>
<InputGroupAddon>
<InputGroupText>@</InputGroupText>
</InputGroupAddon>
<InputGroupInput
id="spinner-06-handle"
value={value}
onChange={(event) => setValue(event.target.value)}
aria-invalid={status === "taken" || undefined}
aria-describedby="spinner-06-status"
autoComplete="off"
spellCheck={false}
/>
<InputGroupAddon align="inline-end">
{status === "checking" ? (
<Spinner aria-hidden="true" className="text-muted-foreground" />
) : null}
{status === "available" ? (
<CircleCheckIcon aria-hidden="true" className="text-success" />
) : null}
{status === "taken" ? (
<CircleAlertIcon aria-hidden="true" className="text-destructive" />
) : null}
</InputGroupAddon>
</InputGroup>
<p
id="spinner-06-status"
aria-live="polite"
className={
status === "taken"
? "text-xs text-destructive"
: "text-xs text-muted-foreground"
}
>
{message}
</p>
<p className="text-xs text-muted-foreground">
Try <span className="font-mono">design</span> to see a taken handle.
</p>
</div>
);
}
npx shadcn@latest add @sevenui/component/spinner-06pnpm dlx shadcn@latest add @sevenui/component/spinner-06yarn dlx shadcn@latest add @sevenui/component/spinner-06bunx --bun shadcn@latest add @sevenui/component/spinner-06ML
Maya LinPNG or JPG, up to 5 MB
"use client";
import { CameraIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
export default function Spinner07() {
const [uploading, setUploading] = useState(false);
useEffect(() => {
if (!uploading) return;
const timer = window.setTimeout(() => setUploading(false), 2400);
return () => window.clearTimeout(timer);
}, [uploading]);
return (
<div className="flex w-full max-w-sm items-center gap-4 rounded-xl border border-border bg-card p-4">
<div className="relative shrink-0">
<Avatar className="size-16" aria-busy={uploading || undefined}>
<AvatarImage src="/placeholder.svg" alt="Maya Lin" />
<AvatarFallback className="text-base">ML</AvatarFallback>
</Avatar>
{uploading ? (
<span className="absolute inset-0 z-10 flex items-center justify-center rounded-full bg-background/70 backdrop-blur-[2px]">
<Spinner aria-label="Uploading photo" className="size-6" />
</span>
) : null}
</div>
<div className="flex min-w-0 flex-1 flex-col gap-2">
<div className="flex flex-col">
<span className="truncate text-sm font-medium">Maya Lin</span>
<span className="truncate text-xs text-muted-foreground">
{uploading
? "Uploading portrait.jpg · 1.8 MB"
: "PNG or JPG, up to 5 MB"}
</span>
</div>
<Button
size="sm"
variant="outline"
className="w-fit"
disabled={uploading}
onClick={() => setUploading(true)}
>
<CameraIcon aria-hidden="true" data-icon="inline-start" />
{uploading ? "Uploading…" : "Change photo"}
</Button>
</div>
</div>
);
}
npx shadcn@latest add @sevenui/component/spinner-07pnpm dlx shadcn@latest add @sevenui/component/spinner-07yarn dlx shadcn@latest add @sevenui/component/spinner-07bunx --bun shadcn@latest add @sevenui/component/spinner-07Setting up Northwind CRM
Importing 1,204 contacts from contacts.csv…
Step 1 of 4 · usually under a minute. You can close this tab.
"use client";
import { CircleCheckIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
const tasks = [
"Importing 1,204 contacts from contacts.csv",
"Matching contacts to 186 companies",
"Creating the Sales and Renewals pipelines",
"Inviting 4 teammates",
];
export default function Spinner08() {
const [task, setTask] = useState(0);
const [opening, setOpening] = useState(false);
const done = task >= tasks.length;
useEffect(() => {
if (task >= tasks.length) return;
const timer = window.setTimeout(() => setTask((step) => step + 1), 1600);
return () => window.clearTimeout(timer);
}, [task]);
useEffect(() => {
if (!opening) return;
const timer = window.setTimeout(() => setOpening(false), 1800);
return () => window.clearTimeout(timer);
}, [opening]);
return (
<section
aria-labelledby="spinner-08-title"
aria-busy={!done}
className="flex w-full max-w-sm flex-col items-center gap-5 px-4 py-8 text-center"
>
<div className="relative flex size-14 items-center justify-center">
{done ? (
<CircleCheckIcon aria-hidden="true" className="size-10 text-success" />
) : (
<>
<span
aria-hidden="true"
className="absolute inset-[5px] rounded-full border-[3px] border-muted"
/>
<Spinner
aria-hidden="true"
strokeWidth={1.75}
className="relative size-14 text-primary"
/>
</>
)}
</div>
<div className="flex flex-col gap-1.5">
<h3
id="spinner-08-title"
className="text-lg font-semibold text-balance"
>
{done ? "Northwind CRM is ready" : "Setting up Northwind CRM"}
</h3>
<p
aria-live="polite"
className="min-h-10 text-sm text-muted-foreground text-balance"
>
{done
? "Your contacts, companies and pipelines are waiting for you."
: `${tasks[task]}…`}
</p>
</div>
{done ? (
<div className="flex flex-wrap justify-center gap-2">
<Button
disabled={opening}
aria-busy={opening || undefined}
onClick={() => setOpening(true)}
>
{opening ? (
<Spinner aria-hidden="true" data-icon="inline-start" />
) : null}
{opening ? "Opening workspace…" : "Open workspace"}
</Button>
<Button
variant="ghost"
disabled={opening}
onClick={() => setTask(0)}
>
Run setup again
</Button>
</div>
) : (
<p className="text-xs text-muted-foreground tabular-nums">
Step {task + 1} of {tasks.length} · usually under a minute. You can
close this tab.
</p>
)}
</section>
);
}
npx shadcn@latest add @sevenui/component/spinner-08pnpm dlx shadcn@latest add @sevenui/component/spinner-08yarn dlx shadcn@latest add @sevenui/component/spinner-08bunx --bun shadcn@latest add @sevenui/component/spinner-08"use client";
import { CheckIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
type Status = "idle" | "saving" | "saved";
const preferences = [
{
id: "mentions",
title: "Mentions and replies",
description: "When someone @mentions you or replies to your comment.",
enabled: true,
},
{
id: "assigned",
title: "Assigned issues",
description: "When an issue is assigned to you or its priority changes.",
enabled: true,
},
{
id: "digest",
title: "Weekly digest",
description: "A Monday summary of activity across your projects.",
enabled: false,
},
{
id: "product",
title: "Product updates",
description: "New features and changelog highlights, about twice a month.",
enabled: false,
},
];
export default function Spinner09() {
const [values, setValues] = useState<Record<string, boolean>>(() =>
Object.fromEntries(preferences.map((p) => [p.id, p.enabled])),
);
const [status, setStatus] = useState<Record<string, Status>>({});
const timers = useRef<Record<string, number[]>>({});
useEffect(() => {
const pending = timers.current;
return () => {
for (const ids of Object.values(pending)) {
for (const id of ids) window.clearTimeout(id);
}
};
}, []);
function toggle(id: string, checked: boolean) {
// A new change restarts this row's save cycle, so an older "idle" timer
// can't cut the new save short.
for (const timerId of timers.current[id] ?? []) {
window.clearTimeout(timerId);
}
setValues((prev) => ({ ...prev, [id]: checked }));
setStatus((prev) => ({ ...prev, [id]: "saving" }));
timers.current[id] = [
window.setTimeout(
() => setStatus((prev) => ({ ...prev, [id]: "saved" })),
900,
),
window.setTimeout(
() => setStatus((prev) => ({ ...prev, [id]: "idle" })),
2600,
),
];
}
const savingCount = Object.values(status).filter(
(s) => s === "saving",
).length;
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Email notifications</CardTitle>
<CardDescription>
Changes save automatically to maya@acme.co.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-0 divide-y divide-border">
{preferences.map((pref) => {
const state = status[pref.id] ?? "idle";
const labelId = `spinner-09-${pref.id}-label`;
const descriptionId = `spinner-09-${pref.id}-description`;
return (
<div
key={pref.id}
className="flex items-start justify-between gap-4 py-3 first:pt-0 last:pb-0"
>
<div className="grid min-w-0 gap-0.5">
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
<span id={labelId} className="text-sm font-medium">
{pref.title}
</span>
{state === "saving" ? (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Spinner
className="size-3"
aria-label={`Saving ${pref.title}`}
/>
Saving
</span>
) : null}
{state === "saved" ? (
<span
role="status"
className="inline-flex items-center gap-1 text-xs text-muted-foreground"
>
<CheckIcon
aria-hidden="true"
className="size-3 text-success"
/>
Saved
</span>
) : null}
</div>
<p
id={descriptionId}
className="text-sm text-muted-foreground text-pretty"
>
{pref.description}
</p>
</div>
<Switch
aria-labelledby={labelId}
aria-describedby={descriptionId}
checked={values[pref.id]}
disabled={state === "saving"}
onCheckedChange={(checked) => toggle(pref.id, checked)}
className="mt-0.5"
/>
</div>
);
})}
</CardContent>
<p className="sr-only" aria-live="polite">
{savingCount > 0 ? "Saving changes" : ""}
</p>
</Card>
);
}
npx shadcn@latest add @sevenui/component/spinner-09pnpm dlx shadcn@latest add @sevenui/component/spinner-09yarn dlx shadcn@latest add @sevenui/component/spinner-09bunx --bun shadcn@latest add @sevenui/component/spinner-09"use client";
import { RefreshCwIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Spinner } from "@/components/ui/spinner";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
type Range = "24h" | "7d" | "30d";
const ranges: { value: Range; label: string; long: string }[] = [
{ value: "24h", label: "24h", long: "last 24 hours" },
{ value: "7d", label: "7d", long: "last 7 days" },
{ value: "30d", label: "30d", long: "last 30 days" },
];
const data: Record<Range, { path: string; views: number; change: number }[]> = {
"24h": [
{ path: "/pricing", views: 1_284, change: 12 },
{ path: "/docs/getting-started", views: 962, change: 4 },
{ path: "/blog/usage-based-billing", views: 711, change: 38 },
{ path: "/changelog", views: 406, change: -6 },
],
"7d": [
{ path: "/pricing", views: 8_930, change: 7 },
{ path: "/blog/usage-based-billing", views: 6_115, change: 64 },
{ path: "/docs/getting-started", views: 5_870, change: 2 },
{ path: "/integrations/stripe", views: 2_342, change: -3 },
],
"30d": [
{ path: "/pricing", views: 36_402, change: 9 },
{ path: "/docs/getting-started", views: 24_117, change: 11 },
{ path: "/blog/usage-based-billing", views: 19_880, change: 120 },
{ path: "/integrations/stripe", views: 10_245, change: 5 },
],
};
const number = new Intl.NumberFormat("en-US");
export default function Spinner10() {
const [range, setRange] = useState<Range>("7d");
const [shown, setShown] = useState<Range>("7d");
const [loading, setLoading] = useState(false);
const [updated, setUpdated] = useState("Updated 4 min ago");
const timer = useRef<number | undefined>(undefined);
useEffect(() => () => window.clearTimeout(timer.current), []);
function load(next: Range) {
setRange(next);
setLoading(true);
window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => {
setShown(next);
setLoading(false);
setUpdated("Updated just now");
}, 1000);
}
const label = ranges.find((r) => r.value === range)?.long ?? "";
return (
<Card className="w-full max-w-lg">
<CardHeader>
<CardTitle>Top pages</CardTitle>
<CardDescription>Unique page views, {label}</CardDescription>
<CardAction>
<Button
variant="ghost"
size="icon-sm"
aria-label="Refresh top pages"
disabled={loading}
onClick={() => load(range)}
>
{loading ? (
<Spinner aria-hidden="true" role="presentation" />
) : (
<RefreshCwIcon aria-hidden="true" />
)}
</Button>
</CardAction>
</CardHeader>
<CardContent className="grid gap-3">
<ToggleGroup
variant="outline"
size="sm"
value={[range]}
onValueChange={(value) => {
const next = value[0] as Range | undefined;
if (next && next !== range) load(next);
}}
aria-label="Time range"
>
{ranges.map((r) => (
<ToggleGroupItem
key={r.value}
value={r.value}
aria-label={`Show ${r.long}`}
>
{r.label}
</ToggleGroupItem>
))}
</ToggleGroup>
<div className="relative" aria-busy={loading}>
<Table
className={
loading ? "opacity-40 transition-opacity" : "transition-opacity"
}
>
<TableHeader>
<TableRow>
<TableHead>Page</TableHead>
<TableHead className="text-right">Views</TableHead>
<TableHead className="text-right">Change</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data[shown].map((row) => (
<TableRow key={row.path}>
<TableCell className="max-w-24 truncate font-medium sm:max-w-40">
{row.path}
</TableCell>
<TableCell className="text-right tabular-nums">
{number.format(row.views)}
</TableCell>
<TableCell
className={`text-right tabular-nums ${
row.change < 0 ? "text-destructive" : "text-success"
}`}
>
{row.change > 0 ? "+" : ""}
{row.change}%
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{loading ? (
<div className="absolute inset-0 flex items-center justify-center">
<div className="flex items-center gap-2 rounded-lg border border-border bg-popover px-3 py-2 text-sm text-popover-foreground shadow-md">
<Spinner aria-label={`Loading ${label}`} />
Loading {label}
</div>
</div>
) : null}
</div>
</CardContent>
<CardFooter className="text-xs text-muted-foreground">
<span aria-live="polite">{loading ? "Refreshing…" : updated}</span>
</CardFooter>
</Card>
);
}
npx shadcn@latest add @sevenui/component/spinner-10pnpm dlx shadcn@latest add @sevenui/component/spinner-10yarn dlx shadcn@latest add @sevenui/component/spinner-10bunx --bun shadcn@latest add @sevenui/component/spinner-10Adding to “Board meeting · October”
Processing 4 of 4 files
- Q3 board deck.pdf4.2 MB · Uploading
- Revenue by region.xlsx812 KB · Uploading
- Product walkthrough.mp448.6 MB · Uploading
- Team offsite.jpg2.9 MB · Uploading
"use client";
import {
CircleAlertIcon,
CircleCheckIcon,
FileImageIcon,
FileSpreadsheetIcon,
FileTextIcon,
FileVideoIcon,
} from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
type Stage = "uploading" | "scanning" | "preview" | "ready" | "failed";
type QueuedFile = {
id: string;
name: string;
size: string;
icon: typeof FileTextIcon;
stage: Stage;
failsOnce?: boolean;
};
const stageLabel: Record<Stage, string> = {
uploading: "Uploading",
scanning: "Scanning for malware",
preview: "Generating preview",
ready: "Ready",
failed: "Couldn't generate a preview",
};
const nextStage: Partial<Record<Stage, Stage>> = {
uploading: "scanning",
scanning: "preview",
preview: "ready",
};
const initialFiles: QueuedFile[] = [
{
id: "f1",
name: "Q3 board deck.pdf",
size: "4.2 MB",
icon: FileTextIcon,
stage: "uploading",
},
{
id: "f2",
name: "Revenue by region.xlsx",
size: "812 KB",
icon: FileSpreadsheetIcon,
stage: "uploading",
},
{
id: "f3",
name: "Product walkthrough.mp4",
size: "48.6 MB",
icon: FileVideoIcon,
stage: "uploading",
failsOnce: true,
},
{
id: "f4",
name: "Team offsite.jpg",
size: "2.9 MB",
icon: FileImageIcon,
stage: "uploading",
},
];
export default function Spinner11() {
const [files, setFiles] = useState(initialFiles);
const timers = useRef<number[]>([]);
const filesRef = useRef(files);
useEffect(() => {
filesRef.current = files;
}, [files]);
const advance = useCallback((id: string, delay: number) => {
timers.current.push(
window.setTimeout(() => {
const file = filesRef.current.find((item) => item.id === id);
if (!file) return;
const fails = file.stage === "preview" && file.failsOnce;
const next: Stage | undefined = fails
? "failed"
: nextStage[file.stage];
if (!next) return;
setFiles((prev) =>
prev.map((item) =>
item.id === id
? {
...item,
stage: next,
failsOnce: fails ? false : item.failsOnce,
}
: item,
),
);
if (next !== "ready" && next !== "failed") {
advance(id, 900 + Math.random() * 700);
}
}, delay),
);
}, []);
const start = useCallback(() => {
initialFiles.forEach((file, index) => {
advance(file.id, 700 + index * 450);
});
}, [advance]);
useEffect(() => {
start();
const pending = timers.current;
return () => {
for (const id of pending) window.clearTimeout(id);
};
}, [start]);
function retry(id: string) {
setFiles((prev) =>
prev.map((file) =>
file.id === id ? { ...file, stage: "preview" } : file,
),
);
advance(id, 1200);
}
function replay() {
for (const id of timers.current) window.clearTimeout(id);
timers.current.length = 0;
setFiles(initialFiles);
start();
}
const ready = files.filter((file) => file.stage === "ready").length;
const failed = files.filter((file) => file.stage === "failed").length;
const working = files.length - ready - failed;
return (
<section
aria-labelledby="spinner-11-title"
className="w-full max-w-md overflow-hidden rounded-xl border border-border bg-card text-card-foreground"
>
<header className="flex items-center justify-between gap-3 border-b border-border px-4 py-3">
<div className="grid min-w-0 gap-0.5">
<h3 id="spinner-11-title" className="text-sm font-medium text-balance">
Adding to “Board meeting · October”
</h3>
<p className="text-xs text-muted-foreground" aria-live="polite">
{working > 0
? `Processing ${working} of ${files.length} files`
: `${ready} of ${files.length} files ready`}
{failed > 0 ? ` · ${failed} needs attention` : ""}
</p>
</div>
{working > 0 ? (
<Spinner
className="size-5 text-muted-foreground"
aria-label="Processing files"
/>
) : (
<Button size="sm" variant="outline" onClick={replay}>
Upload again
</Button>
)}
</header>
<ul className="divide-y divide-border">
{files.map((file) => {
const Icon = file.icon;
const busy = file.stage !== "ready" && file.stage !== "failed";
return (
<li
key={file.id}
aria-busy={busy}
className="flex items-center gap-3 px-4 py-3"
>
<span className="flex size-9 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
<Icon aria-hidden="true" className="size-4" />
</span>
<div className="grid min-w-0 flex-1 gap-0.5">
<span className="truncate text-sm font-medium">
{file.name}
</span>
<span
className={`flex items-center gap-1.5 text-xs ${
file.stage === "failed"
? "text-destructive"
: "text-muted-foreground"
}`}
>
{busy ? (
<Spinner
className="size-3"
aria-label={`${stageLabel[file.stage]}: ${file.name}`}
/>
) : null}
<span className="min-w-0">
{file.size} · {stageLabel[file.stage]}
</span>
</span>
</div>
{file.stage === "ready" ? (
<CircleCheckIcon
aria-label="Ready"
role="img"
className="size-4 shrink-0 text-success"
/>
) : null}
{file.stage === "failed" ? (
<div className="flex shrink-0 items-center gap-2">
<CircleAlertIcon
aria-hidden="true"
className="size-4 text-destructive"
/>
<Button
size="xs"
variant="outline"
onClick={() => retry(file.id)}
aria-label={`Retry ${file.name}`}
>
Retry
</Button>
</div>
) : null}
</li>
);
})}
</ul>
</section>
);
}
npx shadcn@latest add @sevenui/component/spinner-11pnpm dlx shadcn@latest add @sevenui/component/spinner-11yarn dlx shadcn@latest add @sevenui/component/spinner-11bunx --bun shadcn@latest add @sevenui/component/spinner-11Ava Ramirez
Parcelry support · replies in about 2 min
Hi Jordan, I'm Ava from Parcelry support. What can I help you with today?
"use client";
import { ArrowUpIcon, CheckCheckIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group";
import { Spinner } from "@/components/ui/spinner";
type ChatMessage = {
id: number;
from: "agent" | "customer";
text: string;
status?: "sending" | "delivered";
};
const initialMessages: ChatMessage[] = [
{
id: 1,
from: "agent",
text: "Hi Jordan, I'm Ava from Parcelry support. What can I help you with today?",
},
];
const agentReplies = [
"Found it. Order PX-48213 left our Denver warehouse this morning and is due Thursday by 8 pm. I've sent the tracking link to your email.",
"Got it. I've added that to your ticket PR-2291, and a teammate will follow up by email within a day.",
];
export default function Spinner12() {
const [messages, setMessages] = useState(initialMessages);
const [draft, setDraft] = useState("Where is my order PX-48213?");
const [agentWorking, setAgentWorking] = useState(false);
const timers = useRef<number[]>([]);
const nextId = useRef(2);
const replyCount = useRef(0);
const scroller = useRef<HTMLDivElement>(null);
useEffect(() => {
const pending = timers.current;
return () => {
for (const id of pending) window.clearTimeout(id);
};
}, []);
useEffect(() => {
const node = scroller.current;
if (node && (messages.length > 0 || agentWorking)) {
node.scrollTop = node.scrollHeight;
}
}, [messages, agentWorking]);
function later(fn: () => void, ms: number) {
timers.current.push(window.setTimeout(fn, ms));
}
function send(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const text = draft.trim();
if (!text) return;
const id = nextId.current++;
setMessages((prev) => [
...prev,
{ id, from: "customer", text, status: "sending" },
]);
setDraft("");
later(() => {
setMessages((prev) =>
prev.map((m) => (m.id === id ? { ...m, status: "delivered" } : m)),
);
setAgentWorking(true);
}, 800);
later(() => {
const text =
agentReplies[Math.min(replyCount.current++, agentReplies.length - 1)];
setAgentWorking(false);
setMessages((prev) => [
...prev,
{ id: nextId.current++, from: "agent", text },
]);
}, 3000);
}
return (
<section
aria-labelledby="spinner-12-title"
className="flex h-[26rem] w-full max-w-sm flex-col overflow-hidden rounded-xl border border-border bg-card text-card-foreground"
>
<header className="flex items-center gap-3 border-b border-border px-4 py-3">
<Avatar>
<AvatarImage src="/placeholder.svg" alt="" />
<AvatarFallback>AR</AvatarFallback>
</Avatar>
<div className="grid gap-0.5">
<h3 id="spinner-12-title" className="text-sm font-medium">
Ava Ramirez
</h3>
<p className="text-xs text-muted-foreground">
Parcelry support · replies in about 2 min
</p>
</div>
</header>
<div
ref={scroller}
role="log"
aria-label="Conversation with Ava"
className="flex flex-1 flex-col gap-3 overflow-y-auto px-4 py-4"
>
{messages.map((message) =>
message.from === "agent" ? (
<p
key={message.id}
className="max-w-[85%] self-start rounded-2xl rounded-bl-md bg-muted px-3 py-2 text-sm"
>
{message.text}
</p>
) : (
<div
key={message.id}
className="flex max-w-[85%] flex-col items-end gap-1 self-end"
>
<p
className={`rounded-2xl rounded-br-md bg-primary px-3 py-2 text-sm text-primary-foreground transition-opacity ${
message.status === "sending" ? "opacity-70" : ""
}`}
>
{message.text}
</p>
<span className="flex items-center gap-1 text-[0.6875rem] text-muted-foreground">
{message.status === "sending" ? (
<>
<Spinner className="size-3" aria-label="Sending message" />
Sending
</>
) : (
<>
<CheckCheckIcon aria-hidden="true" className="size-3" />
Delivered
</>
)}
</span>
</div>
),
)}
{agentWorking ? (
<div className="flex items-center gap-2 self-start rounded-full border border-border px-3 py-1.5 text-xs text-muted-foreground">
<Spinner className="size-3.5" aria-label="Ava is looking this up" />
Ava is checking shipment records
</div>
) : null}
</div>
<form onSubmit={send} className="border-t border-border p-3">
<InputGroup>
<InputGroupInput
aria-label="Message"
placeholder="Write a message"
value={draft}
onChange={(event) => setDraft(event.target.value)}
/>
<InputGroupAddon align="inline-end">
<InputGroupButton
type="submit"
size="icon-xs"
variant="default"
aria-label="Send message"
disabled={!draft.trim()}
className="rounded-full"
>
<ArrowUpIcon aria-hidden="true" />
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</form>
</section>
);
}
npx shadcn@latest add @sevenui/component/spinner-12pnpm dlx shadcn@latest add @sevenui/component/spinner-12yarn dlx shadcn@latest add @sevenui/component/spinner-12bunx --bun shadcn@latest add @sevenui/component/spinner-12