Kestrel Outdoor
Trail Runner 2 · Graphite
Checking price for size 10
Size (US)
Free delivery over $100, returns within 30 days
Free, copy-and-go Skeleton components built on the SevenUI Skeleton primitive.Read the primitive docs.
"use client";
import { Skeleton } from "@/components/ui/skeleton";
const densities = [
{
name: "Compact",
detail: "32px rows",
avatar: "size-6",
title: "h-2.5 w-28",
meta: "h-2 w-16",
gap: "gap-2.5 py-1",
},
{
name: "Default",
detail: "48px rows",
avatar: "size-9",
title: "h-3 w-36",
meta: "h-2.5 w-24",
gap: "gap-3 py-1.5",
},
{
name: "Comfortable",
detail: "64px rows",
avatar: "size-12",
title: "h-3.5 w-44",
meta: "h-3 w-28",
gap: "gap-4 py-2",
},
];
export default function Skeleton01() {
return (
<div
role="status"
aria-label="Loading team members"
className="flex w-full max-w-sm flex-col divide-y divide-border"
>
<span className="sr-only">Loading team members…</span>
{densities.map((density) => (
<section
key={density.name}
aria-label={`${density.name} density`}
className="flex flex-col gap-1 py-3 first-of-type:pt-0 last:pb-0"
>
<div className="flex items-baseline justify-between text-xs">
<span className="font-medium">{density.name}</span>
<span className="text-muted-foreground tabular-nums">
{density.detail}
</span>
</div>
<div className={`flex items-center ${density.gap}`}>
<Skeleton
aria-hidden="true"
className={`shrink-0 rounded-full ${density.avatar}`}
/>
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<Skeleton
aria-hidden="true"
className={`max-w-full ${density.title}`}
/>
<Skeleton
aria-hidden="true"
className={`max-w-full ${density.meta}`}
/>
</div>
<Skeleton aria-hidden="true" className="h-2.5 w-10 shrink-0" />
</div>
</section>
))}
</div>
);
}
npx shadcn@latest add @sevenui/component/skeleton-01pnpm dlx shadcn@latest add @sevenui/component/skeleton-01yarn dlx shadcn@latest add @sevenui/component/skeleton-01bunx --bun shadcn@latest add @sevenui/component/skeleton-01"use client";
import { cn } from "cn";
import { Skeleton } from "@/components/ui/skeleton";
// The same placeholder re-toned for four surfaces. The default bg-muted
// disappears on a muted panel, so each surface picks its own contrast.
const surfaces = [
{
name: "Bare",
frame: "",
bone: "",
},
{
name: "Outlined",
frame: "rounded-xl border border-border p-4",
bone: "",
},
{
name: "Filled",
frame: "rounded-xl bg-muted p-4",
bone: "bg-background",
},
{
name: "Elevated",
frame: "rounded-xl bg-card p-4 shadow-md ring-1 ring-foreground/5",
bone: "bg-muted",
},
];
export default function Skeleton02() {
return (
<div
role="status"
aria-label="Loading invoice summaries"
className="grid w-full max-w-md grid-cols-1 gap-4 sm:grid-cols-2"
>
{surfaces.map((surface) => (
<figure key={surface.name} className="flex flex-col gap-2">
<div className={cn("flex flex-col gap-3", surface.frame)}>
<div className="flex items-center justify-between gap-3">
<Skeleton
aria-hidden="true"
className={cn("h-3 w-20", surface.bone)}
/>
<Skeleton
aria-hidden="true"
className={cn("h-4 w-12 rounded-full", surface.bone)}
/>
</div>
<Skeleton
aria-hidden="true"
className={cn("h-6 w-28", surface.bone)}
/>
<Skeleton
aria-hidden="true"
className={cn("h-2.5 w-full", surface.bone)}
/>
</div>
<figcaption className="text-xs text-muted-foreground">
{surface.name}
</figcaption>
</figure>
))}
<span className="sr-only">Loading invoice summaries…</span>
</div>
);
}
npx shadcn@latest add @sevenui/component/skeleton-02pnpm dlx shadcn@latest add @sevenui/component/skeleton-02yarn dlx shadcn@latest add @sevenui/component/skeleton-02bunx --bun shadcn@latest add @sevenui/component/skeleton-02"use client";
import { FileTextIcon, ImageIcon, PlayIcon } from "lucide-react";
import { AspectRatio } from "@/components/ui/aspect-ratio";
import { Skeleton } from "@/components/ui/skeleton";
// Relative bar heights for the voice-memo waveform placeholder.
const waveform = [
40, 65, 30, 80, 55, 90, 45, 70, 35, 60, 85, 50, 25, 75, 40, 65, 30, 55,
];
export default function Skeleton03() {
return (
<div
role="status"
aria-label="Loading shared media"
className="grid w-full max-w-md grid-cols-2 gap-3"
>
<AspectRatio ratio={16 / 9} className="col-span-2">
<Skeleton
aria-hidden="true"
className="absolute inset-0 flex items-center justify-center rounded-xl"
>
<span className="flex size-11 items-center justify-center rounded-full bg-background/70">
<PlayIcon className="size-4 translate-x-px text-muted-foreground" />
</span>
</Skeleton>
</AspectRatio>
<AspectRatio ratio={1}>
<Skeleton
aria-hidden="true"
className="absolute inset-0 flex items-center justify-center rounded-xl"
>
<ImageIcon className="size-6 text-muted-foreground/60" />
</Skeleton>
</AspectRatio>
<div aria-hidden="true" className="flex flex-col gap-3">
<div className="flex flex-1 items-center gap-3 rounded-xl border border-border p-3">
<Skeleton className="flex size-9 shrink-0 items-center justify-center">
<FileTextIcon className="size-4 text-muted-foreground/60" />
</Skeleton>
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<Skeleton className="h-2.5 w-full" />
<Skeleton className="h-2 w-2/3" />
</div>
</div>
<div className="flex flex-1 items-center gap-2 rounded-xl border border-border px-3">
<Skeleton className="size-6 shrink-0 rounded-full" />
<div className="flex h-8 min-w-0 flex-1 items-center gap-px overflow-hidden">
{waveform.map((height, index) => (
<Skeleton
// biome-ignore lint/suspicious/noArrayIndexKey: static decorative bars
key={index}
className="w-1 shrink-0 rounded-full"
style={{ height: `${height}%` }}
/>
))}
</div>
</div>
</div>
<span className="sr-only">Loading shared media…</span>
</div>
);
}
npx shadcn@latest add @sevenui/component/skeleton-03pnpm dlx shadcn@latest add @sevenui/component/skeleton-03yarn dlx shadcn@latest add @sevenui/component/skeleton-03bunx --bun shadcn@latest add @sevenui/component/skeleton-03"use client";
import { cn } from "cn";
import { CreditCardIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
// Inline skeletons stand in for single values inside otherwise-ready copy.
// Widths are in `ch` so each bone matches the length of the value it replaces.
function Value({
width,
label,
className,
}: {
width: string;
label: string;
className?: string;
}) {
return (
<Skeleton
role="img"
aria-label={label}
className={cn(
"inline-block h-[1em] translate-y-[0.15em] rounded-sm align-baseline",
className,
)}
style={{ width }}
/>
);
}
export default function Skeleton04() {
return (
<div className="flex w-full max-w-sm flex-col gap-4 rounded-xl border border-border p-5">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<h3 className="text-sm font-medium">Team plan</h3>
<div className="text-sm text-muted-foreground">
<span className="whitespace-nowrap">
Renews on <Value width="9ch" label="Loading renewal date" />
</span>{" "}
<span className="whitespace-nowrap">
for <Value width="6ch" label="Loading renewal amount" />
</span>
</div>
</div>
<Badge variant="secondary" className="gap-1.5">
<Skeleton aria-hidden="true" className="h-2 w-2 rounded-full" />
<Skeleton
role="img"
aria-label="Loading seat count"
className="h-2 w-10 rounded-full"
/>
</Badge>
</div>
<dl className="grid grid-cols-2 gap-3 text-sm">
<div className="flex flex-col gap-1 rounded-lg bg-muted/50 p-3">
<dt className="text-xs text-muted-foreground">Seats used</dt>
<dd className="font-medium tabular-nums">
<Value width="5ch" label="Loading seats used" />
</dd>
</div>
<div className="flex flex-col gap-1 rounded-lg bg-muted/50 p-3">
<dt className="text-xs text-muted-foreground">Storage</dt>
<dd className="font-medium tabular-nums">
<Value width="8ch" label="Loading storage usage" />
</dd>
</div>
</dl>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CreditCardIcon aria-hidden="true" className="size-4 shrink-0" />
<span>
Visa ending in <Value width="4ch" label="Loading card digits" />
</span>
</div>
<Button disabled className="w-full">
Pay{" "}
<Value
width="6ch"
label="Loading amount due"
className="translate-y-0 bg-primary-foreground/25"
/>{" "}
now
</Button>
</div>
);
}
npx shadcn@latest add @sevenui/component/skeleton-04pnpm dlx shadcn@latest add @sevenui/component/skeleton-04yarn dlx shadcn@latest add @sevenui/component/skeleton-04bunx --bun shadcn@latest add @sevenui/component/skeleton-04Every bone breathes together. The default.
"use client";
import * as React from "react";
import { cn } from "cn";
import { Skeleton } from "@/components/ui/skeleton";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
type Mode = "pulse" | "wave" | "static";
const modes: { value: Mode; label: string; hint: string }[] = [
{
value: "pulse",
label: "Pulse",
hint: "Every bone breathes together. The default.",
},
{
value: "wave",
label: "Wave",
hint: "Delays ripple down the list so the eye reads top to bottom.",
},
{
value: "static",
label: "Static",
hint: "No motion, for dense screens or reduced-motion preferences.",
},
];
// Widths of the comment body lines, one entry per comment.
const comments = [
{ name: "w-24", lines: ["w-full", "w-4/5"] },
{ name: "w-20", lines: ["w-11/12"] },
{ name: "w-28", lines: ["w-full", "w-3/4", "w-1/2"] },
];
export default function Skeleton05() {
const [mode, setMode] = React.useState<Mode>("pulse");
const active = modes.find((item) => item.value === mode) ?? modes[0];
let bone = 0;
// Each call returns props for the next bone in reading order.
const boneProps = (className: string) => {
const index = bone++;
return {
"aria-hidden": true as const,
className: cn(
className,
"motion-reduce:animate-none",
mode === "static" && "animate-none",
),
style:
mode === "wave" ? { animationDelay: `${index * 120}ms` } : undefined,
};
};
return (
<div className="flex w-full max-w-sm flex-col gap-4">
<div className="flex flex-col gap-2">
<span id="skeleton-motion-label" className="text-sm font-medium">
Loading motion
</span>
<ToggleGroup
aria-labelledby="skeleton-motion-label"
variant="outline"
spacing={0}
value={[mode]}
onValueChange={(next) => {
if (next.length > 0) setMode(next[0] as Mode);
}}
className="w-full"
>
{modes.map((item) => (
<ToggleGroupItem
key={item.value}
value={item.value}
className="flex-1"
>
{item.label}
</ToggleGroupItem>
))}
</ToggleGroup>
<p className="text-xs text-muted-foreground">{active.hint}</p>
</div>
<div
role="status"
aria-label="Loading comments"
className="flex flex-col gap-4 rounded-xl border border-border p-4"
>
{comments.map((comment, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static placeholder rows
<div key={index} className="flex gap-3">
<Skeleton {...boneProps("size-8 shrink-0 rounded-full")} />
<div className="flex flex-1 flex-col gap-2 pt-1">
<div className="flex items-center gap-2">
<Skeleton {...boneProps(`h-3 ${comment.name}`)} />
<Skeleton {...boneProps("h-2.5 w-10")} />
</div>
{comment.lines.map((width, line) => (
<Skeleton
// biome-ignore lint/suspicious/noArrayIndexKey: static placeholder lines
key={line}
{...boneProps(`h-2.5 ${width}`)}
/>
))}
</div>
</div>
))}
<span className="sr-only">Loading comments…</span>
</div>
</div>
);
}
npx shadcn@latest add @sevenui/component/skeleton-05pnpm dlx shadcn@latest add @sevenui/component/skeleton-05yarn dlx shadcn@latest add @sevenui/component/skeleton-05bunx --bun shadcn@latest add @sevenui/component/skeleton-05"use client";
import * as React from "react";
import { cn } from "cn";
import {
CircleAlertIcon,
PlusIcon,
ReceiptIcon,
RotateCwIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
type State = "loading" | "error" | "empty";
const states: { value: State; label: string }[] = [
{ value: "loading", label: "Loading" },
{ value: "error", label: "Error" },
{ value: "empty", label: "Empty" },
];
// Column widths for the invoice rows: number, customer, amount.
const rows = [
["w-14", "w-24", "w-12"],
["w-14", "w-32", "w-10"],
["w-14", "w-20", "w-14"],
["w-14", "w-28", "w-12"],
];
// Pulsing bones while loading, frozen ghosts once the request has settled.
function InvoiceRows({ state }: { state: State }) {
const bone = cn(
"motion-reduce:animate-none",
state === "error" && "animate-none opacity-50",
state === "empty" &&
"animate-none border border-dashed border-border bg-transparent",
);
return (
<div aria-hidden="true" className="flex flex-col divide-y divide-border">
{rows.map((widths, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static placeholder rows
<div key={index} className="flex items-center gap-4 px-4 py-3">
<Skeleton className={cn("h-3", widths[0], bone)} />
<Skeleton className={cn("h-3 flex-none", widths[1], bone)} />
<Skeleton className={cn("ml-auto h-3", widths[2], bone)} />
</div>
))}
</div>
);
}
export default function Skeleton06() {
const [state, setState] = React.useState<State>("loading");
return (
<Tabs
value={state}
onValueChange={(value) => setState(value as State)}
className="w-full max-w-sm"
>
<TabsList className="w-full">
{states.map((item) => (
<TabsTrigger key={item.value} value={item.value}>
{item.label}
</TabsTrigger>
))}
</TabsList>
<div className="relative mt-2 overflow-hidden rounded-xl border border-border">
<div className="flex items-center justify-between border-b border-border bg-muted/40 px-4 py-2.5 text-xs font-medium text-muted-foreground">
<span>Recent invoices</span>
<span>Amount</span>
</div>
<TabsContent value="loading">
<div role="status" aria-label="Loading invoices">
<InvoiceRows state="loading" />
<span className="sr-only">Loading invoices…</span>
</div>
</TabsContent>
<TabsContent value="error" className="relative">
<InvoiceRows state="error" />
<div
role="alert"
className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-background/80 px-6 text-center"
>
<CircleAlertIcon
aria-hidden="true"
className="size-5 text-destructive"
/>
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">Couldn't load invoices</p>
<p className="text-xs text-muted-foreground">
The billing service timed out. Your data is safe.
</p>
</div>
<Button
size="sm"
variant="outline"
onClick={() => setState("loading")}
>
<RotateCwIcon aria-hidden="true" data-icon="inline-start" />
Try again
</Button>
</div>
</TabsContent>
<TabsContent value="empty" className="relative">
<InvoiceRows state="empty" />
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-background/70 px-6 text-center">
<ReceiptIcon
aria-hidden="true"
className="size-5 text-muted-foreground"
/>
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">No invoices yet</p>
<p className="text-xs text-muted-foreground">
Invoices you send will be listed here.
</p>
</div>
{/* Creating a draft refetches the list, so it goes back to loading. */}
<Button size="sm" onClick={() => setState("loading")}>
<PlusIcon aria-hidden="true" data-icon="inline-start" />
New invoice
</Button>
</div>
</TabsContent>
</div>
</Tabs>
);
}
npx shadcn@latest add @sevenui/component/skeleton-06pnpm dlx shadcn@latest add @sevenui/component/skeleton-06yarn dlx shadcn@latest add @sevenui/component/skeleton-06bunx --bun shadcn@latest add @sevenui/component/skeleton-06Loading profile
"use client";
import * as React from "react";
import { cn } from "cn";
import { MapPinIcon } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
const stats = [
{ label: "Reviews", value: "128" },
{ label: "Merged", value: "342" },
{ label: "Streak", value: "19d" },
];
// Skeleton and content share one grid cell, so swapping them crossfades
// in place and the card never changes size.
const layer =
"col-start-1 row-start-1 transition-[opacity,filter] duration-300 ease-out motion-reduce:transition-none";
export default function Skeleton07() {
const [loaded, setLoaded] = React.useState(false);
return (
<div className="flex w-full max-w-sm flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col gap-0.5">
<Label htmlFor="skeleton-07-loaded">Content loaded</Label>
<span className="text-xs text-muted-foreground">
Toggle to compare the placeholder with the real profile.
</span>
</div>
<Switch
id="skeleton-07-loaded"
checked={loaded}
onCheckedChange={setLoaded}
/>
</div>
<p role="status" className="sr-only">
{loaded ? "Profile loaded" : "Loading profile"}
</p>
<div
aria-busy={!loaded}
className="grid rounded-xl border border-border bg-card p-5"
>
<div
aria-hidden="true"
className={cn(
layer,
"flex flex-col gap-4",
loaded && "pointer-events-none opacity-0 blur-sm",
)}
>
<div className="flex items-center gap-3">
<Skeleton className="size-10 shrink-0 rounded-full" />
<div className="flex flex-1 flex-col gap-1.5 py-0.5">
<Skeleton className="h-3.5 w-28" />
<Skeleton className="h-3 w-20" />
</div>
<Skeleton className="h-5 w-16 rounded-full" />
</div>
<div className="grid grid-cols-3 gap-2">
{stats.map((stat) => (
<div
key={stat.label}
className="flex flex-col gap-1.5 rounded-lg bg-muted/40 p-2.5"
>
<Skeleton className="h-4 w-8 bg-background" />
<Skeleton className="h-3 w-12 bg-background" />
</div>
))}
</div>
</div>
<div
aria-hidden={!loaded}
className={cn(
layer,
"flex flex-col gap-4",
!loaded && "pointer-events-none opacity-0 blur-sm",
)}
>
<div className="flex items-center gap-3">
<Avatar size="lg">
<AvatarImage src="/placeholder.svg" alt="" />
<AvatarFallback>MA</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-medium">Mira Alvarez</span>
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<MapPinIcon aria-hidden="true" className="size-3" />
Lisbon
</span>
</div>
<Badge variant="secondary">Maintainer</Badge>
</div>
<dl className="grid grid-cols-3 gap-2">
{stats.map((stat) => (
<div
key={stat.label}
className="flex flex-col-reverse gap-0.5 rounded-lg bg-muted/40 p-2.5"
>
<dt className="text-xs text-muted-foreground">{stat.label}</dt>
<dd className="text-sm font-semibold tabular-nums">
{stat.value}
</dd>
</div>
))}
</dl>
</div>
</div>
</div>
);
}
npx shadcn@latest add @sevenui/component/skeleton-07pnpm dlx shadcn@latest add @sevenui/component/skeleton-07yarn dlx shadcn@latest add @sevenui/component/skeleton-07bunx --bun shadcn@latest add @sevenui/component/skeleton-07Loaded 0 of 4 sections
"use client";
import * as React from "react";
import { RotateCwIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
// Each section resolves on its own schedule, like independent API calls.
const sections = [
{
id: "revenue",
label: "Net revenue",
value: "$48,290",
note: "+8.2% vs. August",
delay: 700,
},
{
id: "subscribers",
label: "Subscribers",
value: "1,284",
note: "36 joined this week",
delay: 1300,
},
{
id: "churn",
label: "Churn",
value: "2.1%",
note: "Down from 2.6%",
delay: 1800,
},
{
id: "payout",
label: "Next payout",
value: "$12,940",
note: "Arrives Sep 30",
delay: 2500,
},
];
export default function Skeleton08() {
const [ready, setReady] = React.useState<string[]>([]);
const [run, setRun] = React.useState(0);
React.useEffect(() => {
// `run` restarts the sequence each time Reload is pressed.
void run;
setReady([]);
const timers = sections.map((section) =>
window.setTimeout(
() => setReady((current) => [...current, section.id]),
section.delay,
),
);
return () => {
for (const timer of timers) window.clearTimeout(timer);
};
}, [run]);
const done = ready.length === sections.length;
return (
<div className="flex w-full max-w-sm flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<div className="flex flex-col">
<h3 className="text-sm font-medium">September overview</h3>
<p
aria-live="polite"
className="text-xs text-muted-foreground tabular-nums"
>
{done
? "All sections up to date"
: `Loaded ${ready.length} of ${sections.length} sections`}
</p>
</div>
<Button
size="sm"
variant="outline"
disabled={!done}
onClick={() => {
setReady([]);
setRun((count) => count + 1);
}}
>
<RotateCwIcon aria-hidden="true" data-icon="inline-start" />
Reload
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
{sections.map((section) => {
const isReady = ready.includes(section.id);
return (
<section
key={section.id}
aria-label={section.label}
aria-busy={!isReady}
className="flex min-h-24 flex-col gap-1.5 rounded-xl border border-border bg-card p-3.5"
>
<span className="text-xs text-muted-foreground">
{section.label}
</span>
{isReady ? (
<div className="flex animate-in flex-col gap-1 fade-in-0 duration-300 motion-reduce:animate-none">
<span className="text-lg font-semibold tabular-nums">
{section.value}
</span>
<span className="text-xs text-muted-foreground">
{section.note}
</span>
</div>
) : (
<div aria-hidden="true" className="flex flex-col gap-2 pt-1">
<Skeleton className="h-5 w-20" />
<Skeleton className="h-3 w-full max-w-28" />
</div>
)}
</section>
);
})}
</div>
</div>
);
}
npx shadcn@latest add @sevenui/component/skeleton-08pnpm dlx shadcn@latest add @sevenui/component/skeleton-08yarn dlx shadcn@latest add @sevenui/component/skeleton-08bunx --bun shadcn@latest add @sevenui/component/skeleton-08"use client";
import * as React from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
const profile = {
name: "Priya Raman",
email: "priya@northwind.io",
title: "Staff Product Designer",
};
const fields = [
{ key: "name", label: "Full name", type: "text", autoComplete: "name" },
{ key: "email", label: "Email", type: "email", autoComplete: "email" },
{
key: "title",
label: "Job title",
type: "text",
autoComplete: "organization-title",
},
] as const;
export default function Skeleton09() {
const id = React.useId();
const [loading, setLoading] = React.useState(true);
const [saved, setSaved] = React.useState(profile);
const [values, setValues] = React.useState(profile);
const [justSaved, setJustSaved] = React.useState(false);
const [photo, setPhoto] = React.useState("/placeholder.svg");
const fileRef = React.useRef<HTMLInputElement>(null);
// Release the previous preview URL when the photo changes or on unmount.
React.useEffect(() => {
if (!photo.startsWith("blob:")) return;
return () => URL.revokeObjectURL(photo);
}, [photo]);
React.useEffect(() => {
if (!loading) return;
const timer = window.setTimeout(() => setLoading(false), 1400);
return () => window.clearTimeout(timer);
}, [loading]);
const dirty = (Object.keys(profile) as (keyof typeof profile)[]).some(
(key) => values[key] !== saved[key],
);
return (
<form
aria-labelledby={`${id}-title`}
aria-busy={loading}
onSubmit={(event) => {
event.preventDefault();
setSaved(values);
setJustSaved(true);
}}
className="w-full max-w-md rounded-xl border border-border bg-card text-card-foreground"
>
<div className="flex flex-col gap-1 border-b border-border p-5">
<h3 id={`${id}-title`} className="text-base font-semibold">
Profile
</h3>
<p className="text-sm text-muted-foreground">
This is how teammates see you across the workspace.
</p>
</div>
<p role="status" className="sr-only">
{loading ? "Loading your profile" : ""}
</p>
{loading ? (
<div aria-hidden="true" className="flex flex-col gap-5 p-5">
<div className="flex items-center gap-4">
<Skeleton className="size-14 rounded-full" />
<div className="flex flex-col gap-2">
<Skeleton className="h-7 w-28 rounded-lg" />
<Skeleton className="h-3 w-36" />
</div>
</div>
{fields.map((field) => (
<div key={field.key} className="flex flex-col gap-2">
<Skeleton className="h-3.5 w-20" />
<Skeleton className="h-8 w-full rounded-lg" />
</div>
))}
</div>
) : (
<div className="flex flex-col gap-5 p-5">
<div className="flex items-center gap-4">
<Avatar className="size-14">
<AvatarImage src={photo} alt="" />
<AvatarFallback>PR</AvatarFallback>
</Avatar>
<div className="flex flex-col items-start gap-1">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileRef.current?.click()}
>
Change photo
</Button>
<input
ref={fileRef}
type="file"
accept="image/png,image/jpeg"
tabIndex={-1}
aria-hidden="true"
className="sr-only"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) setPhoto(URL.createObjectURL(file));
event.target.value = "";
}}
/>
<p className="text-xs text-muted-foreground">
PNG or JPG, up to 2 MB.
</p>
</div>
</div>
{fields.map((field) => (
<div key={field.key} className="flex flex-col gap-2">
<Label htmlFor={`${id}-${field.key}`}>{field.label}</Label>
<Input
id={`${id}-${field.key}`}
type={field.type}
autoComplete={field.autoComplete}
value={values[field.key]}
onChange={(event) => {
setJustSaved(false);
setValues((current) => ({
...current,
[field.key]: event.target.value,
}));
}}
/>
</div>
))}
</div>
)}
<div className="flex items-center justify-end gap-2 border-t border-border px-5 py-3">
<p
role="status"
className="mr-auto text-xs text-muted-foreground"
>
{justSaved && !dirty ? "Changes saved" : ""}
</p>
<Button
type="button"
variant="ghost"
disabled={loading || !dirty}
onClick={() => setValues(saved)}
>
Discard
</Button>
<Button type="submit" disabled={loading || !dirty}>
Save changes
</Button>
</div>
</form>
);
}
npx shadcn@latest add @sevenui/component/skeleton-09pnpm dlx shadcn@latest add @sevenui/component/skeleton-09yarn dlx shadcn@latest add @sevenui/component/skeleton-09bunx --bun shadcn@latest add @sevenui/component/skeleton-09Kestrel Outdoor
Free delivery over $100, returns within 30 days
"use client";
import { BellRing, Check, ShoppingBag, Truck } from "lucide-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
// Price and stock are fetched per size; everything else is known up front.
const inventory: Record<string, { price: string; stock: number }> = {
"8": { price: "$138.00", stock: 14 },
"9": { price: "$138.00", stock: 3 },
"10": { price: "$124.20", stock: 22 },
"11": { price: "$138.00", stock: 0 },
"12": { price: "$144.00", stock: 6 },
};
const sizes = Object.keys(inventory);
export default function Skeleton10() {
const id = React.useId();
const [size, setSize] = React.useState("10");
const [pending, setPending] = React.useState(true);
// Sizes added to the bag, and sold-out sizes with a restock alert.
const [bagged, setBagged] = React.useState<string[]>([]);
const [alerts, setAlerts] = React.useState<string[]>([]);
React.useEffect(() => {
if (!pending) return;
const timer = window.setTimeout(() => setPending(false), 900);
return () => window.clearTimeout(timer);
}, [pending]);
const item = inventory[size];
const soldOut = item.stock === 0;
const done = soldOut ? alerts.includes(size) : bagged.includes(size);
return (
<article className="grid w-full max-w-2xl overflow-hidden rounded-xl border border-border bg-card text-card-foreground sm:grid-cols-2">
<img
src="/placeholder.svg"
alt="Trail Runner 2 in graphite, side view"
className="aspect-square w-full bg-muted object-cover sm:aspect-auto sm:h-full"
/>
<div className="flex flex-col gap-5 p-5">
<div className="flex flex-col gap-1">
<p className="text-sm text-muted-foreground">Kestrel Outdoor</p>
<h3 className="text-lg font-semibold leading-tight">
Trail Runner 2 · Graphite
</h3>
<div className="mt-1 flex h-7 items-center" aria-live="polite">
{pending ? (
<>
<Skeleton aria-hidden="true" className="h-6 w-24" />
<span className="sr-only">Checking price for size {size}</span>
</>
) : (
<p className="text-xl font-semibold tabular-nums">
{item.price}
{item.price !== "$138.00" ? (
<span className="ml-2 text-sm font-normal text-muted-foreground line-through">
$138.00
</span>
) : null}
</p>
)}
</div>
</div>
<div className="flex flex-col gap-2">
<span id={`${id}-size`} className="text-sm font-medium">
Size (US)
</span>
<ToggleGroup
aria-labelledby={`${id}-size`}
variant="outline"
spacing={1}
value={[size]}
onValueChange={(next) => {
if (next.length === 0 || next[0] === size) return;
setSize(next[0]);
setPending(true);
}}
className="flex-wrap"
>
{sizes.map((value) => (
<ToggleGroupItem
key={value}
value={value}
className="min-w-10 tabular-nums"
>
{value}
</ToggleGroupItem>
))}
</ToggleGroup>
<div className="flex h-5 items-center text-sm">
{pending ? (
<Skeleton aria-hidden="true" className="h-3.5 w-32" />
) : soldOut ? (
<span className="text-destructive">Sold out in this size</span>
) : item.stock < 5 ? (
<span className="flex items-center gap-1.5 font-medium">
<span
aria-hidden="true"
className="size-1.5 rounded-full bg-warning"
/>
Only {item.stock} left
</span>
) : (
<span className="text-muted-foreground">In stock</span>
)}
</div>
</div>
<div className="mt-auto flex flex-col gap-3">
<Button
size="lg"
className="w-full"
disabled={pending}
variant={soldOut && !pending ? "outline" : "default"}
focusableWhenDisabled
onClick={() => {
if (done) return;
if (soldOut) setAlerts((current) => [...current, size]);
else setBagged((current) => [...current, size]);
}}
>
{pending ? (
<ShoppingBag aria-hidden="true" />
) : done ? (
<Check aria-hidden="true" />
) : soldOut ? (
<BellRing aria-hidden="true" />
) : (
<ShoppingBag aria-hidden="true" />
)}
{pending
? "Add to bag"
: soldOut
? done
? "We'll email you"
: "Notify me when back"
: done
? `Added size ${size} to bag`
: "Add to bag"}
</Button>
<p className="flex items-center gap-2 text-xs text-muted-foreground">
<Truck aria-hidden="true" className="size-3.5" />
Free delivery over $100, returns within 30 days
</p>
</div>
</div>
</article>
);
}
npx shadcn@latest add @sevenui/component/skeleton-10pnpm dlx shadcn@latest add @sevenui/component/skeleton-10yarn dlx shadcn@latest add @sevenui/component/skeleton-10bunx --bun shadcn@latest add @sevenui/component/skeleton-10Leo Kowalski
Billing support
Leo: Found it. The second charge was a retry that should have been voided.
Leo: I've refunded $96.00. It will show on your statement in 3 to 5 days.
You: Perfect, thanks Leo!
"use client";
import { ChevronUp } from "lucide-react";
import * as React from "react";
import { cn } from "cn";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
type Message = { id: string; from: "agent" | "customer"; text: string };
const earlier: Message[] = [
{
id: "e1",
from: "customer",
text: "Hi, my invoice for September shows two charges for the Team plan.",
},
{
id: "e2",
from: "agent",
text: "Sorry about that. Could you share the last four digits of the card?",
},
{ id: "e3", from: "customer", text: "Sure, it ends in 4417." },
];
const recent: Message[] = [
{
id: "r1",
from: "agent",
text: "Found it. The second charge was a retry that should have been voided.",
},
{
id: "r2",
from: "agent",
text: "I've refunded $96.00. It will show on your statement in 3 to 5 days.",
},
{ id: "r3", from: "customer", text: "Perfect, thanks Leo!" },
];
// Mirrors the shape of the messages about to load: side and rough length.
const placeholders = [
{ id: "p1", side: "customer", size: "h-14 w-56" },
{ id: "p2", side: "agent", size: "h-14 w-52" },
{ id: "p3", side: "customer", size: "h-9 w-36" },
] as const;
function Bubble({ message }: { message: Message }) {
const mine = message.from === "customer";
return (
<li className={cn("flex items-end gap-2", mine && "flex-row-reverse")}>
{mine ? null : (
<Avatar className="size-6">
<AvatarImage src="/placeholder.svg" alt="" />
<AvatarFallback className="text-[10px]">LK</AvatarFallback>
</Avatar>
)}
<p
className={cn(
"max-w-[80%] rounded-2xl px-3 py-2 text-sm leading-snug",
mine
? "rounded-br-md bg-primary text-primary-foreground"
: "rounded-bl-md bg-muted",
)}
>
<span className="sr-only">{mine ? "You: " : "Leo: "}</span>
{message.text}
</p>
</li>
);
}
export default function Skeleton11() {
const [status, setStatus] = React.useState<"idle" | "loading" | "done">(
"idle",
);
React.useEffect(() => {
if (status !== "loading") return;
const timer = window.setTimeout(() => setStatus("done"), 1300);
return () => window.clearTimeout(timer);
}, [status]);
return (
<section
aria-label="Support conversation with Leo from Billing"
className="flex 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 className="size-8">
<AvatarImage src="/placeholder.svg" alt="" />
<AvatarFallback>LK</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="text-sm font-medium leading-tight">Leo Kowalski</p>
<p className="text-xs text-muted-foreground">Billing support</p>
</div>
</header>
<div className="flex max-h-96 flex-col gap-3 overflow-y-auto p-4">
{status === "idle" ? (
<Button
variant="ghost"
size="sm"
className="self-center text-muted-foreground"
onClick={() => setStatus("loading")}
>
<ChevronUp aria-hidden="true" />
Load earlier messages
</Button>
) : status === "done" ? (
<p className="self-center text-xs text-muted-foreground">
Conversation started Tuesday, 9:41 AM
</p>
) : null}
<p role="status" className="sr-only">
{status === "loading"
? "Loading earlier messages"
: status === "done"
? "Earlier messages loaded"
: ""}
</p>
<ul
role="log"
aria-busy={status === "loading"}
className="flex flex-col gap-3"
>
{status === "loading"
? placeholders.map((row) => (
<li
key={row.id}
aria-hidden="true"
className={cn(
"flex items-end gap-2",
row.side === "customer" && "flex-row-reverse",
)}
>
{row.side === "agent" ? (
<Skeleton className="size-6 shrink-0 rounded-full" />
) : null}
<Skeleton
className={cn(
"max-w-[80%] rounded-2xl",
row.side === "customer"
? "rounded-br-md"
: "rounded-bl-md",
row.size,
)}
/>
</li>
))
: null}
{status === "done"
? earlier.map((message) => (
<Bubble key={message.id} message={message} />
))
: null}
{recent.map((message) => (
<Bubble key={message.id} message={message} />
))}
</ul>
</div>
</section>
);
}
npx shadcn@latest add @sevenui/component/skeleton-11pnpm dlx shadcn@latest add @sevenui/component/skeleton-11yarn dlx shadcn@latest add @sevenui/component/skeleton-11bunx --bun shadcn@latest add @sevenui/component/skeleton-115 seats used of 8 on the Growth plan.
5 members found
| Member | Role |
|---|---|
AO Amara Okafor amara@lumen.dev | Owner |
JW Jonas Weber jonas@lumen.dev | Admin |
SM Sofia Marín sofia@lumen.dev | Member |
KW Kenji Watanabe kenji@lumen.dev | Member |
HB Hannah Brooks hannah@contractor.io | Guest |
"use client";
import { Search } from "lucide-react";
import * as React from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
const members = [
{
name: "Amara Okafor",
email: "amara@lumen.dev",
role: "Owner",
initials: "AO",
},
{
name: "Jonas Weber",
email: "jonas@lumen.dev",
role: "Admin",
initials: "JW",
},
{
name: "Sofia Marín",
email: "sofia@lumen.dev",
role: "Member",
initials: "SM",
},
{
name: "Kenji Watanabe",
email: "kenji@lumen.dev",
role: "Member",
initials: "KW",
},
{
name: "Hannah Brooks",
email: "hannah@contractor.io",
role: "Guest",
initials: "HB",
},
];
const skeletonRows = ["s1", "s2", "s3"];
export default function Skeleton12() {
const id = React.useId();
const [query, setQuery] = React.useState("");
const [applied, setApplied] = React.useState("");
// The server search runs once typing settles; rows stay skeletons until then.
React.useEffect(() => {
const timer = window.setTimeout(() => setApplied(query), 700);
return () => window.clearTimeout(timer);
}, [query]);
const searching = query !== applied;
const needle = applied.trim().toLowerCase();
const results = members.filter(
(member) =>
member.name.toLowerCase().includes(needle) ||
member.email.toLowerCase().includes(needle),
);
return (
<section
aria-labelledby={`${id}-title`}
className="flex w-full max-w-xl flex-col gap-4 rounded-xl border border-border bg-card p-4 text-card-foreground sm:p-5"
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h3 id={`${id}-title`} className="text-base font-semibold">
Team members
</h3>
<p className="text-sm text-muted-foreground">
5 seats used of 8 on the Growth plan.
</p>
</div>
<div className="relative w-full sm:w-56">
<Search
aria-hidden="true"
className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
type="search"
aria-label="Search members by name or email"
placeholder="Search members"
value={query}
onChange={(event) => setQuery(event.target.value)}
className="pl-8"
/>
</div>
</div>
<p role="status" className="sr-only">
{searching
? "Searching members"
: `${results.length} ${results.length === 1 ? "member" : "members"} found`}
</p>
<Table aria-busy={searching} className="table-fixed">
<TableHeader>
<TableRow>
<TableHead>Member</TableHead>
<TableHead className="w-20 text-right sm:w-24">Role</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{searching ? (
skeletonRows.map((row) => (
<TableRow key={row} aria-hidden="true">
<TableCell>
<div className="flex items-center gap-3">
<Skeleton className="size-8 shrink-0 rounded-full" />
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<Skeleton className="h-3.5 w-28 max-w-full" />
<Skeleton className="h-3 w-36 max-w-full" />
</div>
</div>
</TableCell>
<TableCell>
<Skeleton className="ml-auto h-5 w-14 rounded-full" />
</TableCell>
</TableRow>
))
) : results.length === 0 ? (
<TableRow>
<TableCell
colSpan={2}
className="h-24 text-center whitespace-normal text-muted-foreground"
>
No one matches “{applied}”. Invite them from Settings → Seats.
</TableCell>
</TableRow>
) : (
results.map((member) => (
<TableRow key={member.email}>
<TableCell>
<div className="flex items-center gap-3">
<Avatar className="size-8 shrink-0">
<AvatarImage src="/placeholder.svg" alt="" />
<AvatarFallback>{member.initials}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="truncate font-medium">{member.name}</p>
<p className="truncate text-xs text-muted-foreground">
{member.email}
</p>
</div>
</div>
</TableCell>
<TableCell className="text-right">
<Badge
variant={member.role === "Owner" ? "default" : "secondary"}
>
{member.role}
</Badge>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</section>
);
}
npx shadcn@latest add @sevenui/component/skeleton-12pnpm dlx shadcn@latest add @sevenui/component/skeleton-12yarn dlx shadcn@latest add @sevenui/component/skeleton-12bunx --bun shadcn@latest add @sevenui/component/skeleton-12Loading last 7 days
"use client";
import { ArrowDownRight, ArrowUpRight } from "lucide-react";
import * as React from "react";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
type Range = "7d" | "30d" | "90d";
const ranges: Record<
Range,
{
label: string;
total: string;
delta: number;
unit: string;
bars: number[];
}
> = {
"7d": {
label: "Last 7 days",
total: "1,284",
delta: 12.4,
unit: "day",
bars: [142, 168, 201, 188, 236, 174, 175],
},
"30d": {
label: "Last 30 days",
total: "5,019",
delta: -3.1,
unit: "3 days",
bars: [512, 468, 530, 494, 441, 506, 552, 489, 520, 507],
},
"90d": {
label: "Last 90 days",
total: "16,742",
delta: 21.8,
unit: "week",
bars: [
1080, 1122, 1195, 1240, 1302, 1288, 1356, 1410, 1398, 1472, 1455, 1424,
],
},
};
// Fixed heights so the loading chart reads as a chart, not a flat block.
const skeletonBars = [45, 62, 38, 70, 55, 80, 48, 66, 58, 74];
export default function Skeleton13() {
const [range, setRange] = React.useState<Range>("7d");
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
if (!loading) return;
const timer = window.setTimeout(() => setLoading(false), 1000);
return () => window.clearTimeout(timer);
}, [loading]);
return (
<section
aria-label="Signups"
className="w-full max-w-md rounded-xl border border-border bg-card p-5 text-card-foreground"
>
<Tabs
value={range}
onValueChange={(next) => {
setRange(next as Range);
setLoading(true);
}}
>
<div className="flex flex-wrap items-center justify-between gap-3">
<h3 className="text-sm font-medium text-muted-foreground">
New signups
</h3>
<TabsList aria-label="Date range">
{(Object.keys(ranges) as Range[]).map((key) => (
<TabsTrigger key={key} value={key} className="px-2.5 tabular-nums">
{key}
</TabsTrigger>
))}
</TabsList>
</div>
{(Object.keys(ranges) as Range[]).map((key) => {
const data = ranges[key];
const max = Math.max(...data.bars);
const up = data.delta >= 0;
return (
<TabsContent key={key} value={key} aria-busy={loading}>
<p role="status" className="sr-only">
{loading ? `Loading ${data.label.toLowerCase()}` : ""}
</p>
{loading ? (
<div aria-hidden="true" className="mt-3 flex flex-col gap-5">
<div className="flex items-end gap-3">
<Skeleton className="h-8 w-24" />
<Skeleton className="mb-1 h-4 w-14 rounded-full" />
</div>
<div className="flex h-36 items-end gap-1.5 border-b border-border">
{skeletonBars.map((height) => (
<Skeleton
key={height}
className="flex-1 rounded-b-none rounded-t-sm"
style={{ height: `${height}%` }}
/>
))}
</div>
<Skeleton className="h-3 w-40" />
</div>
) : (
<div className="mt-3 flex flex-col gap-5">
<div className="flex items-end gap-3">
<p className="text-3xl font-semibold tabular-nums leading-none">
{data.total}
</p>
<p className="flex items-center gap-0.5 text-sm font-medium tabular-nums">
{up ? (
<ArrowUpRight
aria-hidden="true"
className="size-4 text-success"
/>
) : (
<ArrowDownRight
aria-hidden="true"
className="size-4 text-destructive"
/>
)}
<span className="sr-only">
{up ? "Up" : "Down"} by
</span>
{Math.abs(data.delta)}%
</p>
</div>
<div
role="img"
aria-label={`Signups per ${data.unit}, ${data.label.toLowerCase()}: ${data.bars.join(", ")}`}
className="flex h-36 items-end gap-1.5 border-b border-border"
>
{data.bars.map((value, index) => (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: bars are positional buckets
key={index}
title={`${value.toLocaleString("en-US")} signups`}
className="flex-1 rounded-t-sm bg-chart-1 transition-opacity hover:opacity-80"
style={{ height: `${(value / max) * 100}%` }}
/>
))}
</div>
<p className="text-xs text-muted-foreground">
{data.label}, grouped by {data.unit}, compared with the
previous period.
</p>
</div>
)}
</TabsContent>
);
})}
</Tabs>
</section>
);
}
npx shadcn@latest add @sevenui/component/skeleton-13pnpm dlx shadcn@latest add @sevenui/component/skeleton-13yarn dlx shadcn@latest add @sevenui/component/skeleton-13bunx --bun shadcn@latest add @sevenui/component/skeleton-132 files
moodboard-v3.png
2.4 MB · 2400×1600
brand-guidelines.pdf
8.1 MB · 42 pages
"use client";
import { FileText, Film, ImageIcon, Upload } from "lucide-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
type Kind = "image" | "video" | "doc";
type FileItem = {
id: string;
name: string;
kind: Kind;
meta: string;
processing: boolean;
};
const existing: FileItem[] = [
{
id: "f1",
name: "moodboard-v3.png",
kind: "image",
meta: "2.4 MB · 2400×1600",
processing: false,
},
{
id: "f2",
name: "brand-guidelines.pdf",
kind: "doc",
meta: "8.1 MB · 42 pages",
processing: false,
},
];
// The next batch a user drops in: names are known instantly, previews are not.
const batch: Omit<FileItem, "id" | "processing">[] = [
{ name: "launch-teaser.mp4", kind: "video", meta: "36.2 MB · 0:48" },
{ name: "hero-desktop.jpg", kind: "image", meta: "1.9 MB · 2880×1620" },
{ name: "press-kit.pdf", kind: "doc", meta: "4.7 MB · 12 pages" },
];
const icons = { image: ImageIcon, video: Film, doc: FileText };
export default function Skeleton14() {
const [files, setFiles] = React.useState(existing);
const [uploads, setUploads] = React.useState(0);
const nextPending = files.find((file) => file.processing)?.id;
const processingCount = files.filter((file) => file.processing).length;
// Previews finish one at a time, like a real thumbnail worker queue.
React.useEffect(() => {
if (!nextPending) return;
const timer = window.setTimeout(() => {
setFiles((current) =>
current.map((file) =>
file.id === nextPending ? { ...file, processing: false } : file,
),
);
}, 900);
return () => window.clearTimeout(timer);
}, [nextPending]);
function addBatch() {
const round = uploads + 1;
setUploads(round);
setFiles((current) => [
...batch.map((file, index) => ({
...file,
id: `u${round}-${index}`,
processing: true,
})),
...current,
]);
}
return (
<section
aria-label="Campaign assets"
className="flex w-full max-w-xl flex-col gap-4 rounded-xl border border-border bg-card p-4 text-card-foreground sm:p-5"
>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-base font-semibold">Spring campaign</h3>
<p className="text-sm text-muted-foreground tabular-nums">
{files.length} files
</p>
</div>
<Button
variant="outline"
onClick={addBatch}
disabled={processingCount > 0}
focusableWhenDisabled
>
<Upload aria-hidden="true" />
Upload 3 files
</Button>
</div>
<p role="status" className="sr-only">
{processingCount > 0
? `Generating previews, ${processingCount} remaining`
: uploads > 0
? "All previews ready"
: ""}
</p>
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{files.map((file) => {
const Icon = icons[file.kind];
return (
<li
key={file.id}
aria-busy={file.processing}
className="flex min-w-0 flex-col overflow-hidden rounded-lg border border-border bg-background"
>
{file.processing ? (
<Skeleton
aria-hidden="true"
className="aspect-[4/3] w-full rounded-none"
/>
) : file.kind === "doc" ? (
<div className="flex aspect-[4/3] w-full items-center justify-center bg-muted">
<FileText
aria-hidden="true"
className="size-8 text-muted-foreground"
/>
</div>
) : (
<img
src="/placeholder.svg"
alt=""
className="aspect-[4/3] w-full bg-muted object-cover"
/>
)}
<div className="flex min-w-0 flex-col gap-1 p-2.5">
<p className="flex min-w-0 items-center gap-1.5 text-sm font-medium">
<Icon
aria-hidden="true"
className="size-3.5 shrink-0 text-muted-foreground"
/>
<span className="truncate">{file.name}</span>
</p>
{file.processing ? (
<>
<Skeleton aria-hidden="true" className="my-0.5 h-3 w-3/4" />
<span className="sr-only">Generating preview</span>
</>
) : (
<p className="truncate text-xs text-muted-foreground tabular-nums">
{file.meta}
</p>
)}
</div>
</li>
);
})}
</ul>
</section>
);
}
npx shadcn@latest add @sevenui/component/skeleton-14pnpm dlx shadcn@latest add @sevenui/component/skeleton-14yarn dlx shadcn@latest add @sevenui/component/skeleton-14bunx --bun shadcn@latest add @sevenui/component/skeleton-14Loading events
"use client";
import { ChevronLeft, ChevronRight, Video } from "lucide-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
type CalendarEvent = {
title: string;
start: number; // hours from 9:00
length: number; // hours
video?: boolean;
};
const days: { label: string; short: string; events: CalendarEvent[] }[] = [
{
label: "Wednesday, September 23",
short: "Wed, Sep 23",
events: [
{ title: "Design critique", start: 0.5, length: 1, video: true },
{ title: "Lunch with Ines", start: 3, length: 1 },
{ title: "Roadmap review", start: 5, length: 1.5, video: true },
],
},
{
label: "Thursday, September 24",
short: "Thu, Sep 24",
events: [
{ title: "Standup", start: 0, length: 0.5, video: true },
{ title: "Interview: Senior iOS", start: 1.5, length: 1, video: true },
{ title: "Focus time", start: 4, length: 2 },
],
},
{
label: "Friday, September 25",
short: "Fri, Sep 25",
events: [
{ title: "Customer call: Arden", start: 1, length: 0.5, video: true },
{ title: "Sprint demo", start: 6, length: 1, video: true },
],
},
];
const hours = [
"9 AM",
"10 AM",
"11 AM",
"12 PM",
"1 PM",
"2 PM",
"3 PM",
"4 PM",
];
const HOUR = 44; // px per hour row
// Neutral placeholder blocks; real slots are unknown until the day loads.
const placeholders = [
{ id: "p1", top: 0.5, height: 1, width: "w-3/4" },
{ id: "p2", top: 2.5, height: 1.5, width: "w-2/3" },
{ id: "p3", top: 5, height: 1, width: "w-4/5" },
];
function formatTime(offset: number) {
const total = 9 * 60 + offset * 60;
const hour24 = Math.floor(total / 60);
const minutes = total % 60;
const hour12 = hour24 > 12 ? hour24 - 12 : hour24;
return `${hour12}:${minutes.toString().padStart(2, "0")}`;
}
export default function Skeleton15() {
const [dayIndex, setDayIndex] = React.useState(1);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
if (!loading) return;
const timer = window.setTimeout(() => setLoading(false), 1000);
return () => window.clearTimeout(timer);
}, [loading]);
const day = days[dayIndex];
function go(step: number) {
setDayIndex((index) => index + step);
setLoading(true);
}
return (
<section
aria-label="Day schedule"
className="w-full max-w-sm overflow-hidden rounded-xl border border-border bg-card text-card-foreground"
>
<header className="flex items-center justify-between gap-2 border-b border-border px-3 py-2.5">
<Button
variant="ghost"
size="icon-sm"
aria-label="Previous day"
disabled={dayIndex === 0}
onClick={() => go(-1)}
>
<ChevronLeft aria-hidden="true" />
</Button>
<h3 className="text-sm font-semibold" aria-live="polite">
<span className="sm:hidden">{day.short}</span>
<span className="hidden sm:inline">{day.label}</span>
</h3>
<Button
variant="ghost"
size="icon-sm"
aria-label="Next day"
disabled={dayIndex === days.length - 1}
onClick={() => go(1)}
>
<ChevronRight aria-hidden="true" />
</Button>
</header>
<p role="status" className="sr-only">
{loading ? "Loading events" : `${day.events.length} events`}
</p>
<div className="flex p-3">
<ol
aria-hidden="true"
className="relative w-12 shrink-0"
style={{ height: HOUR * hours.length }}
>
{hours.map((hour, index) => (
<li
key={hour}
className="absolute left-0 text-[11px] leading-none text-muted-foreground tabular-nums"
style={{ top: index === 0 ? 0 : index * HOUR - 5 }}
>
{hour}
</li>
))}
</ol>
<div
aria-busy={loading}
className="relative flex-1 border-l border-border"
style={{ height: HOUR * hours.length }}
>
{hours.map((hour, index) => (
<div
key={hour}
aria-hidden="true"
className="absolute inset-x-0 border-t border-border/60"
style={{ top: index * HOUR }}
/>
))}
{loading ? (
<div aria-hidden="true">
{placeholders.map((block) => (
<Skeleton
key={block.id}
className={`absolute left-2 rounded-lg ${block.width}`}
style={{
top: block.top * HOUR + 2,
height: block.height * HOUR - 4,
}}
/>
))}
</div>
) : (
<ul>
{day.events.map((event) => (
<li
key={event.title}
className="absolute inset-x-2 flex flex-col justify-center overflow-hidden rounded-lg bg-card bg-linear-to-r from-chart-2/15 to-chart-2/15 px-2.5 text-foreground ring-1 ring-chart-2/30 ring-inset"
style={{
top: event.start * HOUR + 2,
height: event.length * HOUR - 4,
}}
>
<p className="flex items-center gap-1.5 truncate text-xs font-medium">
{event.video ? (
<>
<Video aria-hidden="true" className="size-3 shrink-0" />
<span className="sr-only">Video call:</span>
</>
) : null}
<span className="truncate">{event.title}</span>
</p>
{event.length >= 1 ? (
<p className="text-[11px] text-muted-foreground tabular-nums">
{formatTime(event.start)} –{" "}
{formatTime(event.start + event.length)}
</p>
) : (
<span className="sr-only">
{formatTime(event.start)} to{" "}
{formatTime(event.start + event.length)}
</span>
)}
</li>
))}
</ul>
)}
</div>
</div>
</section>
);
}
npx shadcn@latest add @sevenui/component/skeleton-15pnpm dlx shadcn@latest add @sevenui/component/skeleton-15yarn dlx shadcn@latest add @sevenui/component/skeleton-15bunx --bun shadcn@latest add @sevenui/component/skeleton-15