Merino Crew Sweater
Oat
$128
Free, copy-and-go Aspect Ratio components built on the SevenUI Aspect Ratio primitive.Read the primitive docs.
"use client";
import { AspectRatio } from "@/components/ui/aspect-ratio";
const presets = [
{ label: "1:1", ratio: 1, use: "Avatars, product tiles" },
{ label: "4:3", ratio: 4 / 3, use: "Photos, slides" },
{ label: "16:9", ratio: 16 / 9, use: "Video, hero banners" },
{ label: "21:9", ratio: 21 / 9, use: "Cinematic headers" },
];
export default function AspectRatio01() {
return (
<ul className="grid w-full max-w-md grid-cols-2 items-end gap-x-4 gap-y-5">
{presets.map((preset) => (
<li key={preset.label} className="flex flex-col gap-2">
<AspectRatio
ratio={preset.ratio}
className="flex items-center justify-center rounded-lg border border-dashed border-border bg-muted/50"
>
<span className="text-sm font-medium tabular-nums text-foreground">
{preset.label}
</span>
</AspectRatio>
<span className="text-xs text-muted-foreground">{preset.use}</span>
</li>
))}
</ul>
);
}
npx shadcn@latest add @sevenui/component/aspect-ratio-01pnpm dlx shadcn@latest add @sevenui/component/aspect-ratio-01yarn dlx shadcn@latest add @sevenui/component/aspect-ratio-01bunx --bun shadcn@latest add @sevenui/component/aspect-ratio-01"use client";
import { Clock } from "lucide-react";
import { AspectRatio } from "@/components/ui/aspect-ratio";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
const related = [
{ title: "Choosing a type scale for dense dashboards", minutes: 6 },
{ title: "Motion that respects reduced-motion settings", minutes: 4 },
];
export default function AspectRatio02() {
return (
<div className="flex w-full max-w-sm flex-col gap-4">
<Card className="pt-0">
<AspectRatio ratio={16 / 9} className="bg-muted">
<img
src="/placeholder.svg"
alt="Side-by-side light and dark versions of a settings screen"
className="absolute inset-0 size-full object-cover"
/>
</AspectRatio>
<CardHeader>
<Badge variant="secondary" className="mb-1">
Design systems
</Badge>
<CardTitle>Designing dark mode without inverting everything</CardTitle>
<CardDescription>
Why elevation, not brightness, should drive your dark palette.
</CardDescription>
</CardHeader>
</Card>
<ul className="flex flex-col gap-3">
{related.map((item) => (
<li key={item.title}>
<Card size="sm" className="flex-row items-center gap-3 px-3">
<AspectRatio
ratio={1}
className="w-16 shrink-0 overflow-hidden rounded-md bg-muted"
>
<img
src="/placeholder.svg"
alt=""
className="absolute inset-0 size-full object-cover"
/>
</AspectRatio>
<CardContent className="flex min-w-0 flex-col gap-1 px-0">
<span className="text-sm leading-snug font-medium">
{item.title}
</span>
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock aria-hidden="true" className="size-3" />
{item.minutes} min read
</span>
</CardContent>
</Card>
</li>
))}
</ul>
</div>
);
}
npx shadcn@latest add @sevenui/component/aspect-ratio-02pnpm dlx shadcn@latest add @sevenui/component/aspect-ratio-02yarn dlx shadcn@latest add @sevenui/component/aspect-ratio-02bunx --bun shadcn@latest add @sevenui/component/aspect-ratio-02Loading cover image.
"use client";
import { ImageOff, RotateCw } from "lucide-react";
import * as React from "react";
import { AspectRatio } from "@/components/ui/aspect-ratio";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
type MediaState = "loading" | "error" | "ready";
const states: { value: MediaState; label: string }[] = [
{ value: "loading", label: "Loading" },
{ value: "error", label: "Error" },
{ value: "ready", label: "Ready" },
];
const statusText: Record<MediaState, string> = {
loading: "Loading cover image.",
error: "The cover image failed to load.",
ready: "Cover image loaded.",
};
export default function AspectRatio03() {
const [state, setState] = React.useState<MediaState>("loading");
const [retrying, setRetrying] = React.useState(false);
React.useEffect(() => {
if (!retrying) return;
const timer = window.setTimeout(() => {
setRetrying(false);
setState("ready");
}, 1200);
return () => window.clearTimeout(timer);
}, [retrying]);
return (
<div className="flex w-full max-w-md flex-col gap-4">
<div className="flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<span id="aspect-ratio-03-label" className="text-sm font-medium">
Preview state
</span>
<ToggleGroup
aria-labelledby="aspect-ratio-03-label"
variant="outline"
size="sm"
spacing={0}
value={[state]}
onValueChange={(next) => {
if (next.length === 0) return;
setRetrying(false);
setState(next[0] as MediaState);
}}
>
{states.map((item) => (
<ToggleGroupItem
key={item.value}
value={item.value}
className="aria-pressed:bg-accent aria-pressed:text-accent-foreground"
>
{item.label}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
<AspectRatio
ratio={3 / 2}
aria-busy={state === "loading"}
className="overflow-hidden rounded-xl border border-border bg-muted"
>
{state === "loading" && (
<>
<Skeleton className="absolute inset-0 rounded-none" />
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground">
<Spinner className="size-5" />
</div>
</>
)}
{state === "error" && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 p-4 text-center sm:gap-3 sm:p-6">
<ImageOff
aria-hidden="true"
className="size-6 text-muted-foreground"
/>
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">Couldn't load the cover</p>
<p className="hidden text-xs text-muted-foreground sm:block">
The file may have moved. Try again or upload a new one.
</p>
</div>
<Button
variant="outline"
size="sm"
disabled={retrying}
onClick={() => {
setRetrying(true);
setState("loading");
}}
>
<RotateCw aria-hidden="true" data-icon="inline-start" />
Retry
</Button>
</div>
)}
{state === "ready" && (
<img
src="/placeholder.svg"
alt="Harbor at dusk with fishing boats moored along the pier"
className="absolute inset-0 size-full object-cover"
/>
)}
</AspectRatio>
<p className="sr-only" aria-live="polite">
{statusText[state]}
</p>
</div>
);
}
npx shadcn@latest add @sevenui/component/aspect-ratio-03pnpm dlx shadcn@latest add @sevenui/component/aspect-ratio-03yarn dlx shadcn@latest add @sevenui/component/aspect-ratio-03bunx --bun shadcn@latest add @sevenui/component/aspect-ratio-031.78 : 1Widescreen
"use client";
import * as React from "react";
import { AspectRatio } from "@/components/ui/aspect-ratio";
import { Label } from "@/components/ui/label";
import {
NumberField,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
} from "@/components/ui/number-field";
// Tallest the preview may grow, in pixels, so portrait ratios stay compact.
const MAX_PREVIEW_HEIGHT = 220;
const named: { ratio: number; name: string }[] = [
{ ratio: 1, name: "Square" },
{ ratio: 4 / 3, name: "Standard" },
{ ratio: 3 / 2, name: "Classic photo" },
{ ratio: 16 / 9, name: "Widescreen" },
{ ratio: 21 / 9, name: "Ultrawide" },
{ ratio: 4 / 5, name: "Portrait post" },
{ ratio: 9 / 16, name: "Vertical story" },
];
function describe(ratio: number) {
const match = named.find((item) => Math.abs(item.ratio - ratio) < 0.01);
return match ? match.name : "Custom";
}
export default function AspectRatio04() {
const [width, setWidth] = React.useState(16);
const [height, setHeight] = React.useState(9);
const ratio = width / height;
const fields = [
{ id: "aspect-ratio-04-width", label: "Width", value: width, set: setWidth },
{
id: "aspect-ratio-04-height",
label: "Height",
value: height,
set: setHeight,
},
];
return (
<div className="flex w-full max-w-md flex-col gap-5">
<div className="flex flex-wrap items-end gap-4">
{fields.map((field) => (
<div key={field.id} className="flex flex-col gap-2">
<Label htmlFor={field.id}>{field.label}</Label>
<NumberField
id={field.id}
min={1}
max={32}
value={field.value}
onValueChange={(next) => field.set(next ?? 1)}
>
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput className="w-12" />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
</div>
))}
<p className="ml-auto flex flex-col items-end text-right" aria-live="polite">
<span className="text-sm font-medium tabular-nums">
{ratio.toFixed(2)} : 1
</span>
<span className="text-xs text-muted-foreground">
{describe(ratio)}
</span>
</p>
</div>
<div
className="mx-auto w-full transition-[max-width] duration-300 ease-out motion-reduce:transition-none"
style={{ maxWidth: `${MAX_PREVIEW_HEIGHT * ratio}px` }}
>
<AspectRatio
ratio={ratio}
className="flex items-center justify-center rounded-lg border border-dashed border-border bg-muted/50"
>
<span className="text-xs tabular-nums text-muted-foreground">
{width} × {height}
</span>
</AspectRatio>
</div>
</div>
);
}
npx shadcn@latest add @sevenui/component/aspect-ratio-04pnpm dlx shadcn@latest add @sevenui/component/aspect-ratio-04yarn dlx shadcn@latest add @sevenui/component/aspect-ratio-04bunx --bun shadcn@latest add @sevenui/component/aspect-ratio-04"use client";
import { Pause, Play } from "lucide-react";
import * as React from "react";
import { AspectRatio } from "@/components/ui/aspect-ratio";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
// Share of the episode already watched, in percent.
const WATCHED = 38;
export default function AspectRatio05() {
const [playing, setPlaying] = React.useState(false);
return (
<figure className="flex w-full max-w-md flex-col gap-3">
<AspectRatio
ratio={16 / 9}
className="group overflow-hidden rounded-xl bg-muted ring-1 ring-foreground/10"
>
<img
src="/placeholder.svg"
alt="Speaker on stage in front of a slide about incremental rendering"
className="absolute inset-0 size-full object-cover transition-transform duration-500 ease-out group-hover:scale-105 motion-reduce:transition-none"
/>
<Badge className="absolute top-3 left-3">
{playing ? "Playing" : "Replay"}
</Badge>
<span className="absolute top-3 right-3 rounded-md bg-background/85 px-1.5 py-0.5 text-xs font-medium tabular-nums text-foreground">
42:17
</span>
<div className="absolute inset-0 flex items-center justify-center">
<Button
size="icon-lg"
aria-label={
playing
? "Pause Streaming server components"
: "Resume Streaming server components at 16:04"
}
onClick={() => setPlaying((value) => !value)}
className="size-12 rounded-full shadow-lg transition-transform group-hover:scale-110 motion-reduce:transition-none"
>
{playing ? (
<Pause aria-hidden="true" className="size-5 fill-current" />
) : (
<Play aria-hidden="true" className="size-5 fill-current" />
)}
</Button>
</div>
<div
aria-hidden="true"
className="absolute inset-x-0 bottom-0 h-1 bg-background/60"
>
<div className="h-full bg-primary" style={{ width: `${WATCHED}%` }} />
</div>
</AspectRatio>
<figcaption className="flex flex-col gap-0.5">
<span className="text-sm font-medium">Streaming server components</span>
<span className="text-xs text-muted-foreground">
Frontend Summit 2026 ·{" "}
{playing ? "Now playing from 16:04" : `${WATCHED}% watched, resumes at 16:04`}
</span>
</figcaption>
</figure>
);
}
npx shadcn@latest add @sevenui/component/aspect-ratio-05pnpm dlx shadcn@latest add @sevenui/component/aspect-ratio-05yarn dlx shadcn@latest add @sevenui/component/aspect-ratio-05bunx --bun shadcn@latest add @sevenui/component/aspect-ratio-054 in call · 24:08
"use client";
import { cn } from "cn";
import { Mic, MicOff, PhoneOff, Pin, Video, VideoOff } from "lucide-react";
import * as React from "react";
import { AspectRatio } from "@/components/ui/aspect-ratio";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Toggle } from "@/components/ui/toggle";
const participants = [
{ id: "maya", name: "Maya Chen", initials: "MC", camera: true, muted: false },
{ id: "jonas", name: "Jonas Weber", initials: "JW", camera: false, muted: true },
{ id: "amara", name: "Amara Osei", initials: "AO", camera: true, muted: true },
];
// The participant currently talking gets the speaking ring.
const SPEAKER_ID = "maya";
export default function AspectRatio06() {
const [pinnedId, setPinnedId] = React.useState<string | null>(null);
const [micOn, setMicOn] = React.useState(true);
const [cameraOn, setCameraOn] = React.useState(true);
const [left, setLeft] = React.useState(false);
const leaveRef = React.useRef<HTMLButtonElement>(null);
const rejoinRef = React.useRef<HTMLButtonElement>(null);
const moveFocus = React.useRef(false);
// Leaving or rejoining swaps the controls, so hand focus to the new button.
React.useEffect(() => {
if (!moveFocus.current) return;
moveFocus.current = false;
(left ? rejoinRef : leaveRef).current?.focus();
}, [left]);
const tiles = [
...participants,
{
id: "you",
name: "You",
initials: "SL",
camera: cameraOn,
muted: !micOn,
},
];
// The pinned tile moves to the front and spans the full row.
const ordered = pinnedId
? [
...tiles.filter((tile) => tile.id === pinnedId),
...tiles.filter((tile) => tile.id !== pinnedId),
]
: tiles;
return (
<section
aria-labelledby="aspect-ratio-06-title"
className="w-full max-w-lg rounded-xl border bg-card text-card-foreground"
>
<div className="px-4 pt-4 pb-3">
<h3 id="aspect-ratio-06-title" className="truncate font-medium">
Q3 roadmap review
</h3>
<p className="text-xs text-muted-foreground tabular-nums">
{left
? `You left · ${participants.length} still in call`
: `${tiles.length} in call · 24:08`}
</p>
</div>
{left ? (
<div className="flex flex-col items-center gap-3 px-4 pt-2 pb-6 text-center">
<p className="text-sm text-muted-foreground" aria-live="polite">
You left the call.
</p>
<Button
ref={rejoinRef}
size="sm"
onClick={() => {
moveFocus.current = true;
setLeft(false);
}}
>
Rejoin
</Button>
</div>
) : (
<>
<ul aria-label="Participants" className="grid grid-cols-2 gap-2 px-4">
{ordered.map((tile) => {
const pinned = tile.id === pinnedId;
const speaking = tile.id === SPEAKER_ID && !tile.muted;
return (
<li key={tile.id} className={pinned ? "col-span-2" : undefined}>
<AspectRatio
ratio={16 / 9}
className={cn(
"group overflow-hidden rounded-lg bg-muted ring-1 ring-foreground/10 transition-shadow",
speaking && "ring-2 ring-primary",
)}
>
{tile.camera ? (
<img
src="/placeholder.svg"
alt=""
className="absolute inset-0 size-full object-cover"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<Avatar size={pinned ? "lg" : "default"}>
<AvatarFallback className="bg-background text-foreground">
{tile.initials}
</AvatarFallback>
</Avatar>
</div>
)}
<span className="absolute bottom-1.5 left-1.5 flex max-w-[calc(100%-0.75rem)] items-center gap-1 rounded-md bg-background/90 px-1.5 py-0.5 text-xs font-medium text-foreground">
{tile.muted ? (
<MicOff
aria-hidden="true"
className="size-3 shrink-0 text-destructive"
/>
) : null}
<span className="truncate">{tile.name}</span>
<span className="sr-only">
{tile.muted ? ", muted" : ""}
{tile.camera ? "" : ", camera off"}
{speaking ? ", speaking" : ""}
</span>
</span>
<Button
variant="outline"
size="icon-xs"
aria-label={
pinned ? `Unpin ${tile.name}` : `Pin ${tile.name}`
}
aria-pressed={pinned}
onClick={() => setPinnedId(pinned ? null : tile.id)}
className="absolute top-1.5 right-1.5 bg-background/90 dark:bg-background/90 opacity-0 group-hover:opacity-100 focus-visible:opacity-100 aria-pressed:opacity-100 pointer-coarse:opacity-100"
>
<Pin aria-hidden="true" />
</Button>
</AspectRatio>
</li>
);
})}
</ul>
<div className="flex items-center justify-center gap-2 p-4">
<Toggle
variant="outline"
aria-label="Microphone"
pressed={micOn}
onPressedChange={setMicOn}
>
{micOn ? <Mic aria-hidden="true" /> : <MicOff aria-hidden="true" />}
</Toggle>
<Toggle
variant="outline"
aria-label="Camera"
pressed={cameraOn}
onPressedChange={setCameraOn}
>
{cameraOn ? (
<Video aria-hidden="true" />
) : (
<VideoOff aria-hidden="true" />
)}
</Toggle>
<Button
ref={leaveRef}
variant="destructive"
size="sm"
onClick={() => {
moveFocus.current = true;
setLeft(true);
setPinnedId(null);
}}
>
<PhoneOff aria-hidden="true" data-icon="inline-start" />
Leave
</Button>
</div>
</>
)}
</section>
);
}
npx shadcn@latest add @sevenui/component/aspect-ratio-06pnpm dlx shadcn@latest add @sevenui/component/aspect-ratio-06yarn dlx shadcn@latest add @sevenui/component/aspect-ratio-06bunx --bun shadcn@latest add @sevenui/component/aspect-ratio-06"use client";
import * as React from "react";
import { AspectRatio } from "@/components/ui/aspect-ratio";
import { Slider } from "@/components/ui/slider";
export default function AspectRatio07() {
const [position, setPosition] = React.useState(50);
return (
<div className="flex w-full max-w-md flex-col gap-4">
<AspectRatio
ratio={3 / 2}
className="overflow-hidden rounded-xl bg-muted ring-1 ring-foreground/10"
>
<img
src="/placeholder.svg"
alt="Living room after color grading, warm and balanced"
className="absolute inset-0 size-full object-cover"
/>
<img
src="/placeholder.svg"
alt="Living room before color grading, flat and desaturated"
className="absolute inset-0 size-full object-cover grayscale"
style={{ clipPath: `inset(0 ${100 - position}% 0 0)` }}
/>
<div
aria-hidden="true"
className="absolute inset-y-0 w-0.5 -translate-x-1/2 bg-background shadow-sm"
style={{ left: `${position}%` }}
/>
<span className="absolute top-3 left-3 rounded-md bg-background/85 px-2 py-0.5 text-xs font-medium text-foreground">
Before
</span>
<span className="absolute top-3 right-3 rounded-md bg-background/85 px-2 py-0.5 text-xs font-medium text-foreground">
After
</span>
</AspectRatio>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span id="aspect-ratio-07-label" className="text-sm font-medium">
Compare edit
</span>
<span
aria-hidden="true"
className="text-sm tabular-nums text-muted-foreground"
>
{position}% original
</span>
</div>
<Slider
aria-labelledby="aspect-ratio-07-label"
value={[position]}
onValueChange={(value) =>
setPosition(typeof value === "number" ? value : value[0])
}
/>
</div>
</div>
);
}
npx shadcn@latest add @sevenui/component/aspect-ratio-07pnpm dlx shadcn@latest add @sevenui/component/aspect-ratio-07yarn dlx shadcn@latest add @sevenui/component/aspect-ratio-07bunx --bun shadcn@latest add @sevenui/component/aspect-ratio-07Oat
$128
"use client";
import { Heart, ShoppingBag } from "lucide-react";
import * as React from "react";
import { AspectRatio } from "@/components/ui/aspect-ratio";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
const colorways = [
{ id: "oat", label: "Oat", swatch: "bg-muted dark:bg-foreground" },
{ id: "charcoal", label: "Charcoal", swatch: "bg-foreground dark:bg-muted" },
{ id: "moss", label: "Moss", swatch: "bg-chart-2" },
];
export default function AspectRatio08() {
const [colorway, setColorway] = React.useState(colorways[0]);
const [saved, setSaved] = React.useState(false);
const [added, setAdded] = React.useState(false);
return (
<article className="w-full max-w-72">
<div className="relative overflow-hidden rounded-xl border bg-muted">
<AspectRatio ratio={4 / 5}>
<img
src="/placeholder.svg"
alt={`Merino crew sweater in ${colorway.label.toLowerCase()}, front view`}
className="absolute inset-0 size-full object-cover"
/>
</AspectRatio>
<Badge className="absolute top-3 left-3">New season</Badge>
<Button
variant="outline"
size="icon-sm"
aria-label={saved ? "Remove from wishlist" : "Save to wishlist"}
aria-pressed={saved}
onClick={() => setSaved((value) => !value)}
className="absolute top-3 right-3 rounded-full bg-background/90 dark:bg-background/90 dark:hover:bg-muted"
>
<Heart
aria-hidden="true"
className={saved ? "fill-current text-destructive" : undefined}
/>
</Button>
</div>
<div className="mt-3 flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="truncate text-sm font-medium">Merino Crew Sweater</h3>
<p className="text-sm text-muted-foreground">{colorway.label}</p>
</div>
<p className="text-sm font-semibold tabular-nums">$128</p>
</div>
<fieldset className="mt-3">
<legend className="sr-only">Color</legend>
<div className="flex gap-2">
{colorways.map((option) => (
<button
key={option.id}
type="button"
aria-label={option.label}
aria-pressed={colorway.id === option.id}
onClick={() => {
setColorway(option);
setAdded(false);
}}
className="flex size-7 items-center justify-center rounded-full border border-transparent outline-none transition-colors hover:border-border focus-visible:ring-3 focus-visible:ring-ring/50 aria-pressed:border-foreground"
>
<span
className={`size-5 rounded-full border border-border ${option.swatch}`}
/>
</button>
))}
</div>
</fieldset>
<Button className="mt-4 w-full" onClick={() => setAdded(true)}>
<ShoppingBag aria-hidden="true" data-icon="inline-start" />
{added ? "Added to bag" : "Add to bag"}
</Button>
<p className="sr-only" aria-live="polite">
{added ? `Merino Crew Sweater in ${colorway.label} added to bag.` : ""}
</p>
</article>
);
}
npx shadcn@latest add @sevenui/component/aspect-ratio-08pnpm dlx shadcn@latest add @sevenui/component/aspect-ratio-08yarn dlx shadcn@latest add @sevenui/component/aspect-ratio-08bunx --bun shadcn@latest add @sevenui/component/aspect-ratio-08Shown at the top of your public profile and in team directories.
team-offsite-lisbon.jpg
3:1 ratio · 1500 × 500 px recommended · JPG or PNG up to 5 MB
"use client";
import { ImageUp, Trash2 } from "lucide-react";
import * as React from "react";
import { AspectRatio } from "@/components/ui/aspect-ratio";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
export default function AspectRatio09() {
const inputRef = React.useRef<HTMLInputElement>(null);
const [cover, setCover] = React.useState<string | null>("/placeholder.svg");
const [fileName, setFileName] = React.useState("team-offsite-lisbon.jpg");
// Revoke object URLs created for local previews when they are replaced.
React.useEffect(() => {
return () => {
if (cover?.startsWith("blob:")) URL.revokeObjectURL(cover);
};
}, [cover]);
return (
<section
aria-labelledby="aspect-ratio-09-title"
className="w-full max-w-lg rounded-xl border bg-card text-card-foreground"
>
<div className="border-b p-4">
<h3 id="aspect-ratio-09-title" className="font-medium">
Profile cover
</h3>
<p className="text-sm text-muted-foreground">
Shown at the top of your public profile and in team directories.
</p>
</div>
<div className="p-4">
<div className="relative">
<AspectRatio
ratio={3 / 1}
className="overflow-hidden rounded-lg border bg-muted"
>
{cover ? (
<img
src={cover}
alt="Current profile cover"
className="absolute inset-0 size-full object-cover"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
No cover image
</div>
)}
</AspectRatio>
<Avatar className="absolute -bottom-7 left-4 size-16 ring-4 ring-card">
<AvatarImage src="/placeholder.svg" alt="Priya Raman" />
<AvatarFallback>PR</AvatarFallback>
</Avatar>
</div>
<div className="mt-10 flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0 text-sm">
<p className="truncate font-medium">
{cover ? fileName : "Using the default cover"}
</p>
<p className="text-muted-foreground">
3:1 ratio · 1500 × 500 px recommended · JPG or PNG up to 5 MB
</p>
</div>
<div className="flex gap-2">
<input
ref={inputRef}
id="aspect-ratio-09-file"
type="file"
accept="image/png, image/jpeg"
className="sr-only"
tabIndex={-1}
aria-hidden="true"
onChange={(event) => {
const file = event.target.files?.[0];
if (!file) return;
setCover(URL.createObjectURL(file));
setFileName(file.name);
event.target.value = "";
}}
/>
<Button
variant="outline"
size="sm"
onClick={() => inputRef.current?.click()}
>
<ImageUp aria-hidden="true" data-icon="inline-start" />
{cover ? "Replace" : "Upload"}
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label="Remove cover image"
disabled={!cover}
onClick={() => setCover(null)}
>
<Trash2 aria-hidden="true" />
</Button>
</div>
</div>
</div>
</section>
);
}
npx shadcn@latest add @sevenui/component/aspect-ratio-09pnpm dlx shadcn@latest add @sevenui/component/aspect-ratio-09yarn dlx shadcn@latest add @sevenui/component/aspect-ratio-09bunx --bun shadcn@latest add @sevenui/component/aspect-ratio-09The lamp arrived like this. Box was already crushed at delivery.
Thanks, Daniel. The photos are clear enough to approve a replacement without a return. A new lamp ships today.
Nora · Support · 9:43 AM"use client";
import { Paperclip } from "lucide-react";
import * as React from "react";
import { AspectRatio } from "@/components/ui/aspect-ratio";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
const photos = [
{ id: "box", caption: "Outer box, crushed corner" },
{ id: "lamp", caption: "Lamp shade, cracked rim" },
{ id: "base", caption: "Base plate, scratched finish" },
{ id: "label", caption: "Shipping label, order SR-48213" },
{ id: "cable", caption: "Power cable, bent connector" },
];
const visible = photos.slice(0, 3);
const hidden = photos.length - visible.length;
export default function AspectRatio10() {
const [openIndex, setOpenIndex] = React.useState<number | null>(null);
// Remember the last photo so the dialog keeps its content while it closes.
const [lastIndex, setLastIndex] = React.useState(0);
const shownIndex = openIndex ?? lastIndex;
const active = photos[shownIndex];
const openPhoto = (index: number) => {
setLastIndex(index);
setOpenIndex(index);
};
return (
<div className="flex w-full max-w-sm flex-col gap-4">
<div className="flex flex-col items-end gap-1">
<div className="w-full max-w-64 rounded-2xl rounded-br-md bg-primary p-1 text-primary-foreground">
<ul aria-label="Attached photos" className="grid grid-cols-2 gap-1">
{visible.map((photo, index) => {
const isLast = index === visible.length - 1 && hidden > 0;
return (
<li key={photo.id} className={index === 0 ? "col-span-2" : undefined}>
<button
type="button"
onClick={() => openPhoto(index)}
aria-label={
isLast
? `Open ${photo.caption} and ${hidden} more photos`
: `Open ${photo.caption}`
}
className="block w-full overflow-hidden rounded-xl outline-none focus-visible:ring-3 focus-visible:ring-ring"
>
<AspectRatio ratio={index === 0 ? 2 / 1 : 1} className="bg-muted">
<img
src="/placeholder.svg"
alt=""
className="absolute inset-0 size-full object-cover"
/>
{isLast ? (
<span className="absolute inset-0 flex items-center justify-center bg-foreground/60 text-lg font-semibold text-background">
+{hidden}
</span>
) : null}
</AspectRatio>
</button>
</li>
);
})}
</ul>
<p className="px-2.5 pt-2 pb-1.5 text-sm">
The lamp arrived like this. Box was already crushed at delivery.
</p>
</div>
<span className="text-xs text-muted-foreground">
You · 9:41 AM · <Paperclip aria-hidden="true" className="inline size-3" />{" "}
{photos.length} photos
</span>
</div>
<div className="flex flex-col items-start gap-1">
<p className="max-w-64 rounded-2xl rounded-bl-md bg-muted px-3 py-2 text-sm">
Thanks, Daniel. The photos are clear enough to approve a replacement
without a return. A new lamp ships today.
</p>
<span className="text-xs text-muted-foreground">Nora · Support · 9:43 AM</span>
</div>
<Dialog
open={openIndex !== null}
onOpenChange={(open) => {
if (!open) setOpenIndex(null);
}}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{active.caption}</DialogTitle>
<DialogDescription>
Photo {shownIndex + 1} of {photos.length} · Attached to
ticket SR-7730
</DialogDescription>
</DialogHeader>
<AspectRatio ratio={4 / 3} className="overflow-hidden rounded-lg bg-muted">
<img
src="/placeholder.svg"
alt={active.caption}
className="absolute inset-0 size-full object-contain"
/>
</AspectRatio>
<ul aria-label="All photos" className="grid grid-cols-5 gap-2">
{photos.map((photo, index) => (
<li key={photo.id}>
<button
type="button"
aria-label={photo.caption}
aria-current={index === shownIndex ? "true" : undefined}
onClick={() => openPhoto(index)}
className="block w-full overflow-hidden rounded-md opacity-60 outline-none transition-opacity hover:opacity-100 focus-visible:ring-3 focus-visible:ring-ring/50 aria-[current=true]:opacity-100 aria-[current=true]:ring-2 aria-[current=true]:ring-primary"
>
<AspectRatio ratio={1} className="bg-muted">
<img
src="/placeholder.svg"
alt=""
className="absolute inset-0 size-full object-cover"
/>
</AspectRatio>
</button>
</li>
))}
</ul>
</DialogContent>
</Dialog>
</div>
);
}
npx shadcn@latest add @sevenui/component/aspect-ratio-10pnpm dlx shadcn@latest add @sevenui/component/aspect-ratio-10yarn dlx shadcn@latest add @sevenui/component/aspect-ratio-10bunx --bun shadcn@latest add @sevenui/component/aspect-ratio-102 selected
"use client";
import { Check, Download, FileText, Film, Trash2, X } from "lucide-react";
import * as React from "react";
import { AspectRatio } from "@/components/ui/aspect-ratio";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
type FileKind = "image" | "video" | "document";
const initialFiles: { id: string; name: string; size: string; kind: FileKind }[] = [
{ id: "hero", name: "spring-hero.png", size: "2.4 MB", kind: "image" },
{ id: "teaser", name: "launch-teaser.mp4", size: "48.1 MB", kind: "video" },
{ id: "brief", name: "campaign-brief.pdf", size: "860 KB", kind: "document" },
{ id: "banner", name: "email-banner.jpg", size: "1.1 MB", kind: "image" },
{ id: "lookbook", name: "lookbook-cover.png", size: "3.7 MB", kind: "image" },
{ id: "copy", name: "product-copy.pdf", size: "212 KB", kind: "document" },
];
const kindIcon = { video: Film, document: FileText };
export default function AspectRatio11() {
const [files, setFiles] = React.useState(initialFiles);
const [selected, setSelected] = React.useState<string[]>(["hero", "banner"]);
const [downloaded, setDownloaded] = React.useState(false);
// The download confirmation clears itself after a moment.
React.useEffect(() => {
if (!downloaded) return;
const timer = window.setTimeout(() => setDownloaded(false), 2000);
return () => window.clearTimeout(timer);
}, [downloaded]);
const toggle = (id: string, checked: boolean) =>
setSelected((current) =>
checked ? [...current, id] : current.filter((value) => value !== id),
);
return (
<section
aria-label="Spring campaign assets"
className="w-full max-w-xl rounded-xl border bg-card text-card-foreground"
>
<div className="flex min-h-14 items-center justify-between gap-2 border-b px-4 py-2">
{selected.length > 0 ? (
<>
<p className="text-sm font-medium" aria-live="polite">
{downloaded
? `Downloading ${selected.length} ${selected.length === 1 ? "file" : "files"}`
: `${selected.length} selected`}
</p>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon-sm"
aria-label="Download selected"
onClick={() => setDownloaded(true)}
>
{downloaded ? (
<Check aria-hidden="true" />
) : (
<Download aria-hidden="true" />
)}
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label="Delete selected"
onClick={() => {
setFiles((current) =>
current.filter((file) => !selected.includes(file.id)),
);
setSelected([]);
setDownloaded(false);
}}
>
<Trash2 aria-hidden="true" />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label="Clear selection"
onClick={() => {
setSelected([]);
setDownloaded(false);
}}
>
<X aria-hidden="true" />
</Button>
</div>
</>
) : (
<div>
<h3 className="text-sm font-medium">
Spring campaign assets
</h3>
<p className="text-xs text-muted-foreground">
{files.length} files · Shared with Marketing
</p>
</div>
)}
</div>
{files.length === 0 ? (
<div className="flex flex-col items-center gap-3 p-8 text-center">
<p className="text-sm text-muted-foreground">
This folder is empty. Drop files here to upload them.
</p>
<Button variant="outline" size="sm" onClick={() => setFiles(initialFiles)}>
Restore files
</Button>
</div>
) : (
<ul className="grid grid-cols-2 gap-3 p-4 sm:grid-cols-3">
{files.map((file) => {
const checked = selected.includes(file.id);
const Icon = file.kind === "image" ? null : kindIcon[file.kind];
const inputId = `aspect-ratio-11-${file.id}`;
return (
<li
key={file.id}
data-selected={checked || undefined}
className="group relative rounded-lg border p-1.5 transition-colors hover:bg-muted/50 data-selected:border-primary data-selected:bg-primary/5"
>
<AspectRatio ratio={4 / 3} className="overflow-hidden rounded-md bg-muted">
{Icon ? (
<div className="absolute inset-0 flex items-center justify-center">
<Icon aria-hidden="true" className="size-8 text-muted-foreground" />
</div>
) : (
<img
src="/placeholder.svg"
alt=""
className="absolute inset-0 size-full object-cover"
/>
)}
<span className="absolute top-1.5 left-1.5 flex rounded-sm bg-background/90 p-0.5">
<Checkbox
id={inputId}
checked={checked}
onCheckedChange={(value) => toggle(file.id, value)}
/>
</span>
</AspectRatio>
<label htmlFor={inputId} className="block cursor-pointer px-0.5 pt-1.5">
<span className="block truncate text-sm font-medium">{file.name}</span>
<span className="block text-xs text-muted-foreground tabular-nums">
{file.size}
</span>
</label>
</li>
);
})}
</ul>
)}
</section>
);
}
npx shadcn@latest add @sevenui/component/aspect-ratio-11pnpm dlx shadcn@latest add @sevenui/component/aspect-ratio-11yarn dlx shadcn@latest add @sevenui/component/aspect-ratio-11bunx --bun shadcn@latest add @sevenui/component/aspect-ratio-11fieldhouse.coffee
Sponsored
"use client";
import { Check, Heart, MessageCircle, Send } from "lucide-react";
import * as React from "react";
import { AspectRatio } from "@/components/ui/aspect-ratio";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
const crops = [
{ id: "square", label: "Square", ratio: 1, export: "1080 × 1080" },
{ id: "portrait", label: "Portrait", ratio: 4 / 5, export: "1080 × 1350" },
{ id: "landscape", label: "Landscape", ratio: 1.91, export: "1200 × 628" },
];
export default function AspectRatio12() {
const [cropId, setCropId] = React.useState("portrait");
// The crop last applied to the post; Reset returns to it.
const [appliedId, setAppliedId] = React.useState<string | null>(null);
const baseline = appliedId ?? "portrait";
const applied = appliedId === cropId;
const crop = crops.find((option) => option.id === cropId) ?? crops[0];
return (
<section
aria-labelledby="aspect-ratio-12-title"
className="w-full max-w-sm rounded-xl border bg-card text-card-foreground"
>
<div className="grid gap-3 border-b p-4">
<div className="flex flex-wrap items-baseline justify-between gap-x-2 gap-y-0.5">
<h3 id="aspect-ratio-12-title" className="whitespace-nowrap font-medium">
Crop for feed
</h3>
<span className="whitespace-nowrap text-xs text-muted-foreground tabular-nums">
Exports at {crop.export} px
</span>
</div>
<ToggleGroup
aria-label="Crop ratio"
variant="outline"
size="sm"
spacing={0}
value={[cropId]}
onValueChange={(next) => {
if (next[0]) setCropId(next[0]);
}}
className="w-full"
>
{crops.map((option) => (
<ToggleGroupItem key={option.id} value={option.id} className="flex-1">
{option.label}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
<div className="bg-muted/40 p-4">
<figure
aria-label="Post preview"
className="mx-auto w-full max-w-72 overflow-hidden rounded-lg border bg-background"
>
<div className="flex items-center gap-2 p-2.5">
<Avatar className="size-7">
<AvatarImage src="/placeholder.svg" alt="" />
<AvatarFallback>FH</AvatarFallback>
</Avatar>
<div className="text-xs leading-tight">
<p className="font-medium">fieldhouse.coffee</p>
<p className="text-muted-foreground">Sponsored</p>
</div>
</div>
<AspectRatio
ratio={crop.ratio}
className="overflow-hidden bg-muted transition-[aspect-ratio] duration-300 ease-out motion-reduce:transition-none"
>
<img
src="/placeholder.svg"
alt="Pour-over coffee on a wooden counter"
className="absolute inset-0 size-full object-cover"
/>
</AspectRatio>
<div className="flex gap-1 px-1.5 pt-1.5" aria-hidden="true">
<Heart className="m-1 size-4" />
<MessageCircle className="m-1 size-4" />
<Send className="m-1 size-4" />
</div>
<figcaption className="px-2.5 pt-1 pb-3 text-xs">
<span className="font-medium">fieldhouse.coffee</span> New Ethiopian
single origin lands Friday. Bright, floral, a little jammy.
</figcaption>
</figure>
</div>
<div className="flex justify-end gap-2 border-t p-3">
<Button
variant="ghost"
size="sm"
disabled={cropId === baseline}
focusableWhenDisabled
onClick={() => setCropId(baseline)}
>
Reset
</Button>
<Button
size="sm"
disabled={applied}
focusableWhenDisabled
onClick={() => setAppliedId(cropId)}
>
{applied ? (
<>
<Check aria-hidden="true" data-icon="inline-start" />
Crop applied
</>
) : (
"Apply crop"
)}
</Button>
</div>
</section>
);
}
npx shadcn@latest add @sevenui/component/aspect-ratio-12pnpm dlx shadcn@latest add @sevenui/component/aspect-ratio-12yarn dlx shadcn@latest add @sevenui/component/aspect-ratio-12bunx --bun shadcn@latest add @sevenui/component/aspect-ratio-12Trail Runner 4 · Size 10 · Ready in 2 hours
Pickup at Mission District
"use client";
import { Check, Clock, MapPin, Navigation } from "lucide-react";
import * as React from "react";
import { AspectRatio } from "@/components/ui/aspect-ratio";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
// Pin positions are percentages of the map, so they stay anchored at any width.
const stores = [
{
id: "mission",
name: "Mission District",
address: "2170 Mission St",
hours: "Open until 8 PM",
open: true,
stock: "12 in stock",
x: 38,
y: 62,
},
{
id: "hayes",
name: "Hayes Valley",
address: "451 Hayes St",
hours: "Opens 10 AM",
open: false,
stock: "3 in stock",
x: 30,
y: 38,
},
{
id: "embarcadero",
name: "Embarcadero Center",
address: "4 Embarcadero Ctr",
hours: "Open until 7 PM",
open: true,
stock: "Low stock",
x: 76,
y: 24,
},
];
export default function AspectRatio13() {
const [activeId, setActiveId] = React.useState("mission");
const [reservedId, setReservedId] = React.useState<string | null>(null);
const active = stores.find((store) => store.id === activeId) ?? stores[0];
const reserved = reservedId === active.id;
const directionsHref = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(`${active.address}, San Francisco, CA`)}`;
return (
<section
aria-labelledby="aspect-ratio-13-title"
className="w-full max-w-lg overflow-hidden rounded-xl border bg-card text-card-foreground"
>
<div className="p-4">
<h3 id="aspect-ratio-13-title" className="font-medium">
Pick up in store
</h3>
<p className="text-sm text-muted-foreground">
Trail Runner 4 · Size 10 · Ready in 2 hours
</p>
</div>
<AspectRatio ratio={16 / 9} className="border-y bg-muted">
<img
src="/placeholder.svg"
alt="Map of San Francisco store locations"
className="absolute inset-0 size-full object-cover opacity-70"
/>
{stores.map((store) => {
const selected = store.id === activeId;
return (
<button
key={store.id}
type="button"
aria-label={`${store.name} on map`}
aria-pressed={selected}
onClick={() => setActiveId(store.id)}
style={{ left: `${store.x}%`, top: `${store.y}%` }}
className="absolute flex size-8 -translate-x-1/2 -translate-y-full items-center justify-center rounded-full border bg-background text-foreground shadow-md outline-none transition-transform hover:scale-110 focus-visible:ring-3 focus-visible:ring-ring/50 aria-pressed:z-10 aria-pressed:scale-110 aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground"
>
<MapPin aria-hidden="true" className="size-4" />
</button>
);
})}
</AspectRatio>
<ul aria-label="Nearby stores" className="divide-y">
{stores.map((store) => {
const selected = store.id === activeId;
return (
<li key={store.id}>
<button
type="button"
aria-pressed={selected}
onClick={() => setActiveId(store.id)}
className="flex w-full items-start gap-3 px-4 py-3 text-left outline-none transition-colors hover:bg-muted/60 focus-visible:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:ring-inset aria-pressed:bg-muted"
>
<span
aria-hidden="true"
className={`mt-1.5 size-2 shrink-0 rounded-full ${selected ? "bg-primary" : "bg-border"}`}
/>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium">{store.name}</span>
<span className="block truncate text-sm text-muted-foreground">
{store.address}
</span>
<span className="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
<Clock aria-hidden="true" className="size-3" />
{store.hours}
</span>
</span>
<Badge variant={store.open ? "secondary" : "outline"}>{store.stock}</Badge>
</button>
</li>
);
})}
</ul>
<div className="flex flex-wrap items-center justify-between gap-2 border-t p-4">
<p className="text-sm" aria-live="polite">
<span className="text-muted-foreground">
{reserved ? "Reserved at " : "Pickup at "}
</span>
<span className="font-medium">{active.name}</span>
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
nativeButton={false}
render={<a href={directionsHref} target="_blank" rel="noreferrer" />}
>
<Navigation aria-hidden="true" data-icon="inline-start" />
Directions
</Button>
<Button
size="sm"
disabled={!active.open || reserved}
focusableWhenDisabled
onClick={() => setReservedId(active.id)}
>
{reserved ? (
<>
<Check aria-hidden="true" data-icon="inline-start" />
Reserved
</>
) : active.open ? (
"Reserve"
) : (
"Closed now"
)}
</Button>
</div>
</div>
</section>
);
}
npx shadcn@latest add @sevenui/component/aspect-ratio-13pnpm dlx shadcn@latest add @sevenui/component/aspect-ratio-13yarn dlx shadcn@latest add @sevenui/component/aspect-ratio-13bunx --bun shadcn@latest add @sevenui/component/aspect-ratio-13