Card
Dark frosted-glass surface card — flat at rest, soft float + lift on hover.
A charcoal translucent pane over a blurred backdrop. Flat at rest (no shadow, no
border); interactive adds a hover lift + a soft float shadow. It's a surface
primitive — compose list rows, tiles, and panels on top of it.
Installation
npx shadcn add https://acrylic-ui.vercel.app/r/card.jsonUsage
"use client"import { Cloud, Pause } from "lucide-react"import { Card } from "@/registry/acrylic/card"import { ExampleBackdrop } from "@/components/example-backdrop"// Two real surfaces, not two labels: a static Weather-style summary tile above a// tappable Now-Playing mini-card — an iOS widget stack. The difference between// "static" and "interactive" reads through behavior (only one lifts on hover), the// way Apple demonstrates use rather than annotating the widget.export default function CardDemo() { return ( <ExampleBackdrop> <div className="flex w-full max-w-[20rem] flex-col gap-4 text-foreground"> {/* Static — a Weather summary. Flat, no lift. */} <Card className="flex items-center justify-between gap-3 p-4"> <div> <div className="text-[13px] font-medium text-muted-foreground">San Francisco</div> <div className="mt-0.5 text-[40px] font-semibold leading-none tracking-tight tabular-nums"> 18° </div> <div className="mt-1.5 text-[12px] text-muted-foreground">Mostly Clear · H:20° L:13°</div> </div> <Cloud className="size-10 shrink-0 text-muted-foreground" strokeWidth={1.25} /> </Card> {/* Interactive — Now Playing. Hover to lift. */} <Card interactive className="flex items-center gap-3 p-3"> {/* eslint-disable-next-line @next/next/no-img-element */} <img src="https://picsum.photos/seed/midnightcity/160/160" alt="" loading="lazy" className="size-14 shrink-0 rounded-lg object-cover" /> <div className="min-w-0 flex-1"> <div className="truncate text-[14px] font-semibold">Midnight City</div> <div className="truncate text-[12px] text-muted-foreground"> M83 · Hurry Up, We're Dreaming </div> </div> <div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-white/10"> <Pause className="size-4 fill-current" /> </div> </Card> </div> </ExampleBackdrop> )}Example — image-thumbnail row
ImageThumbCard: a list-row composition — a 2×2 image-thumbnail tile with a count
badge, a title / subtitle / meta stack, and a trailing action that fades in on
hover. interactive gives each row the Card's hover lift. Copy it and swap in your
own thumbnails and fields.
"use client"import { Trash2 } from "lucide-react"import { Button } from "@/registry/acrylic/button"import { Card } from "@/registry/acrylic/card"import { ExampleBackdrop } from "@/components/example-backdrop"// ImageThumbCard — a Photos "Albums" list row on the acrylic Card: a 2×2 photo// mosaic with a count badge, a title / place / date stack, and a trailing action// that fades in on hover. `interactive` gives the row the Card's hover lift, so it// reads as a clickable surface. Generic — pass any four photos + fields.function ImageThumbCard({ title, place, date, count, photos,}: { title: string place: string date: string count: number photos: string[] // four thumbnail image URLs}) { return ( <Card interactive role="button" tabIndex={0} className="group flex w-full items-center gap-4 p-4 text-left text-foreground" > {/* leading media — a 2×2 photo mosaic with a count badge */} <div className="relative size-[72px] shrink-0"> <div className="grid size-full grid-cols-2 grid-rows-2 gap-0.5 overflow-hidden rounded-md bg-white/5"> {photos.map((src, i) => ( // eslint-disable-next-line @next/next/no-img-element <img key={i} src={src} alt="" loading="lazy" className="size-full object-cover" /> ))} </div> <span className="absolute -right-1.5 -top-1.5 min-w-[22px] rounded-full bg-primary px-1.5 py-0.5 text-center text-[10px] font-semibold leading-[14px] tabular-nums text-primary-foreground shadow-[0_2px_6px_rgba(0,0,0,0.4)]"> {count} </span> </div> <div className="flex min-w-0 flex-1 flex-col gap-1 pr-8"> <div className="truncate text-[15px] font-semibold leading-5 [letter-spacing:var(--text-title3-tracking)]"> {title} </div> <div className="truncate text-[13px] leading-[18px] text-muted-foreground">{place}</div> <div className="mt-1 text-[11px] leading-[14px] [letter-spacing:var(--text-subheadline-tracking)] tabular-nums text-[var(--label-tertiary)]"> {date} </div> </div> {/* trailing action — a red acrylic ghost icon button (xl), fades in on hover */} <Button icon size="xl" variant="ghost" aria-label={`Delete ${title}`} onClick={(e) => e.stopPropagation()} className="absolute right-3 top-1/2 -translate-y-1/2 text-destructive opacity-0 transition-opacity duration-200 group-hover:opacity-100" > <Trash2 /> </Button> </Card> )}const seed = (s: string) => `https://picsum.photos/seed/${s}/160/160`const items = [ { title: "Iceland", place: "Ring Road · Reykjavík", date: "August 2024", count: 248, photos: ["ice1", "ice2", "ice3", "ice4"].map(seed), }, { title: "Golden Hour", place: "Studio portraits", date: "September 1", count: 56, photos: ["gold1", "gold2", "gold3", "gold4"].map(seed), },]export default function CardImageThumb() { return ( <ExampleBackdrop className="flex-col"> <div className="flex w-full max-w-xl flex-col gap-3"> {items.map((item) => ( <ImageThumbCard key={item.title} {...item} /> ))} </div> </ExampleBackdrop> )}Example — card in card
A nested composition (the iOS grouped-list pattern): an outer frosted Card opts
into nestedSurface and holds a recessed inner card. The inner surface is not
a second blurred Card — stacking another backdrop-blur over the already-translucent
glass double-samples it and leaves a darkened seam. Instead it's a flat wash that
recesses the surface, reading as a secondary surface on the same pane.
5 miles · Marina Green
"use client"import { Flag, MoreHorizontal, Plus } from "lucide-react"import { cn } from "@/lib/utils"import { Badge } from "@/registry/acrylic/badge"import { Button } from "@/registry/acrylic/button"import { Card } from "@/registry/acrylic/card"import { ExampleBackdrop } from "@/components/example-backdrop"// Card-in-card: the outer Card is the frosted glass (backdrop-blur). `nestedSurface`// explicitly opts it into nested-card treatment, so real Cards inside it drop their// blur and tint one step darker via `--acr-card-nested`. Each nesting level steps// darker, so even a 3-deep stack keeps every layer visually distinct. Content is a// Reminders-style list of lists — the nesting mechanism is the point; the copy is// just something human to hang it on./** One reminder, rendered as a real nested <Card>. */function ReminderRow({ title, note, time, flagged,}: { title: string note?: string time: string flagged?: boolean}) { return ( <Card className="p-2.5 leading-tight"> <div className="flex items-center gap-2"> <span className="size-3.5 shrink-0 rounded-full border-[1.5px] border-primary/70" /> <span className="text-[13px] font-medium">{title}</span> {flagged ? <Flag className="size-3 shrink-0 fill-orange-400 text-orange-400" /> : null} <span className="ms-auto text-[11px] tabular-nums text-muted-foreground">{time}</span> </div> {note ? <p className="mt-1 ps-[22px] text-[11px] text-muted-foreground">{note}</p> : null} </Card> )}/** Outer list header (dot + name + count + options) shared by both stacks. */function ListHeader({ name, count, tint }: { name: string; count: number; tint: string }) { return ( <div className="flex items-center gap-2 px-0.5"> <span className={cn("size-2.5 shrink-0 rounded-full", tint)} /> <span className="text-[13px] font-semibold">{name}</span> <Badge variant="secondary" size="sm" className="min-w-5 tabular-nums"> {count} </Badge> <Button icon size="medium" variant="ghost" aria-label="List options" className="ms-auto text-muted-foreground hover:text-foreground" > <MoreHorizontal /> </Button> </div> )}/** Shared "new reminder" footer row — accent-tinted, like Reminders' add action. */function AddRow() { return ( <button type="button" className="flex items-center gap-1.5 rounded-lg px-1 py-0.5 text-[13px] font-medium text-primary transition-colors hover:text-primary/80" > <Plus className="size-4" strokeWidth={2.5} /> New Reminder </button> )}export default function CardNested() { return ( <ExampleBackdrop className="!flex-row flex-wrap items-start justify-center gap-6"> {/* Single nest: outer Card → one nested Card per reminder. */} <Card nestedSurface className="flex w-72 shrink-0 flex-col gap-2.5 p-3 text-foreground"> <ListHeader name="Today" count={3} tint="bg-sky-400" /> <ReminderRow title="Morning run" note="5 miles · Marina Green" time="7:00 AM" /> <ReminderRow title="Design review" time="11:30 AM" flagged /> <ReminderRow title="Call Mom" time="6:00 PM" /> <AddRow /> </Card> {/* Three layers deep: outer Card → middle Card → inner Card. The middle layer must stay a clearly distinct surface, not wash out. */} <Card nestedSurface className="flex w-72 shrink-0 flex-col gap-2.5 p-3 text-foreground"> <ListHeader name="Trips" count={1} tint="bg-orange-400" /> <Card nestedSurface className="flex flex-col gap-2.5 p-2.5"> <span className="px-0.5 text-[12px] font-semibold text-muted-foreground"> Lisbon · May </span> <ReminderRow title="Confirm Airbnb check-in" time="Apr 2" /> </Card> <AddRow /> </Card> </ExampleBackdrop> )}Example — cover image card
The shadcn/ui Card · Image example, restyled on the acrylic Card: a flush cover
image under a dark scrim, a Featured badge, title + description, and a full-width
footer action — with the body as a frosted glass surface over the wallpaper.
"use client"import { Badge } from "@/registry/acrylic/badge"import { Button } from "@/registry/acrylic/button"import { Card, CardAction, CardDescription, CardFooter, CardHeader, CardTitle,} from "@/registry/acrylic/card"import { ExampleBackdrop } from "@/components/example-backdrop"// An App Store Today / Apple TV -style cover card on the acrylic Card composition:// a flush, full-bleed photograph up top, a Live badge as CardAction, title +// description, and a full-width footer action. The Card is the frosted glass// surface, so the body shows the wallpaper through. The Card has no padding of its// own, so we add `flex flex-col gap-6 py-6` (pt-0 lets the photo sit flush at top).// A real photograph carries the card — no grayscale/brightness crutches; a light// top scrim only lifts the depth where the media meets the frosted body.export default function CardCover() { return ( <ExampleBackdrop> <Card className="relative mx-auto flex w-full max-w-sm flex-col overflow-hidden pt-0 pb-[18px]"> {/* eslint-disable-next-line @next/next/no-img-element */} <img src="https://picsum.photos/seed/sunsetset/640/360" alt="Concert cover" className="aspect-video w-full object-cover" /> <div className="pointer-events-none absolute inset-x-0 top-0 z-10 aspect-video bg-gradient-to-b from-black/20 to-transparent" /> {/* Caption hugs the image (14px), then a calmer gap to the action (18px) — an Apple event-card rhythm, not shadcn's uniform 24px gap-6. */} <CardHeader className="mt-3.5"> <CardAction> <Badge variant="secondary">Live</Badge> </CardAction> <CardTitle>Sunset Sessions</CardTitle> <CardDescription> Ólafur Arnalds, live from Reykjavík — tonight at 9:00. </CardDescription> </CardHeader> <CardFooter className="mt-[18px]"> <Button className="w-full">Set Reminder</Button> </CardFooter> </Card> </ExampleBackdrop> )}Example — gallery / media card
A media tile with two live segmented controls. Size swaps the cover's
aspect-ratio (16:9 / 3:4 / 1:1) — one source stays uniform while different sources
each keep their own shape, un-cropped — and style toggles default (caption
under the media) against overlay (caption on a bottom scrim). Title and subtitle
reuse the Card's own CardTitle / CardDescription — the same type as the
Cover-image-card above — so the family reads as one; the corner chip is the acrylic
Badge tinted for on-media contrast.
"use client"import * as React from "react"import { Badge } from "@/registry/acrylic/badge"import { ButtonGroup, ButtonGroupItem } from "@/registry/acrylic/button-group"import { Card, CardDescription, CardMedia, CardMediaOverlay, CardTitle } from "@/registry/acrylic/card"import { ExampleBackdrop } from "@/components/example-backdrop"// A gallery / media-card recipe on the acrylic Card, with two live segmented// controls: a SIZE group that swaps the cover's `aspect-ratio` (16:9 / 3:4 / 1:1 —// one source stays uniform, different sources keep their own ratio), and a STYLE// group that toggles `default` (caption under the media) vs `overlay` (caption on a// bottom scrim). Title + subtitle reuse the Card's own `CardTitle` / `CardDescription`// type — the same as the Cover-image-card example — so the whole family reads as one.const RATIOS = { "16:9": "16 / 9", "3:4": "3 / 4", "1:1": "1 / 1" } as consttype RatioKey = keyof typeof RATIOStype Style = "default" | "overlay"type Cover = { src: string; title: string; sub: string; badge?: string }const COVERS: Cover[] = [ { src: "https://picsum.photos/seed/lisboa/600/600", title: "48 Hours in Lisbon", sub: "Studio K · 120K views · 3d ago", badge: "12:06" }, { src: "https://picsum.photos/seed/kyoto/600/600", title: "Kyoto in Autumn", sub: "@aoi · 22 photos", badge: "Album" }, { src: "https://picsum.photos/seed/tapes/600/600", title: "Midnight Tapes", sub: "Khruangbin · 12 tracks" }, { src: "https://picsum.photos/seed/cinema/600/600", title: "Cinematic Camera Moves", sub: "Film Lab · 84K views", badge: "18:32" }, { src: "https://picsum.photos/seed/kamakura/600/600", title: "Dusk on the Kamakura Coast", sub: "@aoi · 18 photos" }, { src: "https://picsum.photos/seed/lofi/600/600", title: "City Lo-Fi, Vol. 3", sub: "Various Artists · 20 tracks" },]// Hover feedback WITHOUT the lift: the Card's `interactive` prop bakes in a// `hover:-translate-y-px` displacement — we drop `interactive` and keep only the// soft float shadow (on ::before) so the tile gains depth on hover but never moves.const HOVER_FLOAT = "before:pointer-events-none before:absolute before:inset-0 before:-z-10 before:rounded-xl " + "before:shadow-[0_12px_28px_rgba(0,0,0,0.28)] before:opacity-0 before:transition-opacity " + "before:[transition-timing-function:var(--acr-spring-default)] before:[transition-duration:var(--acr-spring-default-duration)] hover:before:opacity-100"// Compact media-caption type — a thumbnail caption, not a full CardHeader. Reuse the// Card's CardTitle/CardDescription for family consistency but step the size down// (title3 15px → 13px, body 13px → 12px) so it reads as a caption under the media.const CAPTION_TITLE = "self-stretch truncate text-[13px] font-semibold leading-snug"const CAPTION_SUB = "truncate text-[12px] leading-snug"function MediaCard({ cover, ratio, style }: { cover: Cover; ratio: string; style: Style }) { const overlay = style === "overlay" return ( <Card role="button" tabIndex={0} className={`group flex flex-col overflow-hidden p-0 text-left focus:outline-none ${HOVER_FLOAT}`} > <CardMedia ratio={ratio} src={cover.src}> {cover.badge ? ( <Badge size="sm" className="absolute right-1.5 top-1.5 border-transparent bg-black/60 text-white tabular-nums backdrop-blur-sm" > {cover.badge} </Badge> ) : null} {/* overlay caption — the scrim + on-media retyping (white, 13/12, truncate) live in CardMediaOverlay, so the same CardTitle / CardDescription just nest in. */} {overlay ? ( <CardMediaOverlay> <CardTitle>{cover.title}</CardTitle> <CardDescription>{cover.sub}</CardDescription> </CardMediaOverlay> ) : null} </CardMedia> {/* default caption — under the media, so it's plain layout: no component needed, just the compact caption type on the shared CardTitle / CardDescription. */} {!overlay ? ( <div className="flex flex-col gap-0.5 px-3 pb-3 pt-2.5"> <CardTitle className={CAPTION_TITLE}>{cover.title}</CardTitle> <CardDescription className={CAPTION_SUB}>{cover.sub}</CardDescription> </div> ) : null} </Card> )}export default function CardGallery() { const [ratio, setRatio] = React.useState<RatioKey>("16:9") const [style, setStyle] = React.useState<Style>("default") return ( <ExampleBackdrop> <div className="flex w-full max-w-3xl flex-col gap-5 text-foreground"> <div className="flex flex-wrap items-center gap-3"> <ButtonGroup variant="segmented" size="small" value={ratio} onValueChange={(v) => setRatio(v as RatioKey)}> {(Object.keys(RATIOS) as RatioKey[]).map((k) => ( <ButtonGroupItem key={k} value={k} className="tabular-nums"> {k} </ButtonGroupItem> ))} </ButtonGroup> <ButtonGroup variant="segmented" size="small" value={style} onValueChange={(v) => setStyle(v as Style)}> <ButtonGroupItem value="default">Default</ButtonGroupItem> <ButtonGroupItem value="overlay">Overlay</ButtonGroupItem> </ButtonGroup> </div> <div className="grid grid-cols-2 gap-4 sm:grid-cols-3"> {COVERS.map((cover, i) => ( <MediaCard key={i} cover={cover} ratio={RATIOS[ratio]} style={style} /> ))} </div> </div> </ExampleBackdrop> )}API Reference
The frosted Card follows the shadcn/ui Card anatomy — a surface root plus
Header / Title / Description / Action / Content / Footer sub-parts. Only the root
diverges (it adds interactive + nestedSurface); every sub-part is a
layout/typography wrapper that renders a <div> with a data-slot styling hook and
forwards all native <div> props (className, onClick, style, …). className
is for layout only — color always flows through the --acr-* tokens.
Card
The dark-glass surface root — flat at rest (no border, no rest shadow), a soft
float lift on hover when interactive. Renders <div data-slot="card">.
| Prop | Type | Default | Description |
|---|---|---|---|
interactive | boolean | false | Hover lift + soft float shadow, for clickable cards. |
nestedSurface | boolean | false | Opt child Cards into the nested-surface treatment — they drop their own blur and tint one step recessed (the card-in-card pattern). Sets data-nested-surface. |
className | string | — | Merged onto the surface (layout only). |
...props | React.HTMLAttributes<HTMLDivElement> | — | Forwarded to the root <div>. |
<Card interactive>
<CardHeader>
<CardTitle>Title</CardTitle>
<CardDescription>Description</CardDescription>
<CardAction>
<Button icon variant="ghost" aria-label="More" />
</CardAction>
</CardHeader>
<CardContent>…</CardContent>
<CardFooter>…</CardFooter>
</Card>CardHeader
Grid header for the title / description stack; when a CardAction is present it
auto-adds a trailing column (has-data-[slot=card-action]:grid-cols-[1fr_auto]) to
hold it. Renders <div data-slot="card-header">.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Merged onto the header grid. |
...props | React.ComponentProps<"div"> | — | Forwarded to the <div>. |
CardTitle
The 15px semibold title (self-center within the header grid). Renders
<div data-slot="card-title">.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Merged onto the title. |
...props | React.ComponentProps<"div"> | — | Forwarded to the <div>. |
CardDescription
The 13px text-muted-foreground description. Renders
<div data-slot="card-description">.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Merged onto the description. |
...props | React.ComponentProps<"div"> | — | Forwarded to the <div>. |
CardAction
Top-right action slot inside CardHeader (a menu or icon button), pinned to the
header's trailing column (col-start-2 row-start-1 justify-self-end). Renders
<div data-slot="card-action">.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Merged onto the action wrapper. |
...props | React.ComponentProps<"div"> | — | Forwarded to the <div>. |
CardContent
The card body wrapper — horizontal padding only; the root owns the vertical rhythm,
so compose it with flex flex-col gap-*. Renders <div data-slot="card-content">.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Merged onto the content. |
...props | React.ComponentProps<"div"> | — | Forwarded to the <div>. |
CardFooter
Footer row for actions, aligned on a single line (flex items-center). Renders
<div data-slot="card-footer">.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Merged onto the footer. |
...props | React.ComponentProps<"div"> | — | Forwarded to the <div>. |
CardMedia
The fixed-ratio cover frame for the gallery / media-card anatomy. Give it src and it
owns the cover image — object-cover filling and cropping the ratio box — with the same
load-error strategy as MediaBox: a failed load retries with
exponential backoff, then after maxRetries shows fallback (default ImageOff); a
missing src with a fallback shows the placeholder immediately (the avatar-with-no-photo
case). Without src it's just a frame — a direct <img> child is still auto-covered
([&>img]:size-full [&>img]:object-cover). Either way a CardMediaOverlay, corner
Badge, or any positioned child rides inside it. Pass ratio to lock the aspect so a
grid of tiles stays uniform. Renders <div data-slot="card-media">.
| Prop | Type | Default | Description |
|---|---|---|---|
ratio | string | — | CSS aspect-ratio for the frame, e.g. "16 / 9", "2 / 3", "1 / 1". Merged into style (an explicit style.aspectRatio still wins). |
src | string | — | Cover image URL. When set, CardMedia renders and manages the object-cover <img> (retry-on-error + fallback). Omit to hand it your own media as children. |
alt | string | "" | Alt text for the owned image. |
fallback | React.ReactNode | <ImageOff /> | Placeholder shown after retries are exhausted, or immediately when src is absent. |
imageClassName | string | — | Merged onto the owned <img>. |
maxRetries | number | 2 | Failed loads to retry with backoff before giving up. |
retryDelayMs | number | 800 | Base backoff delay in ms; doubles each attempt. |
onNaturalSize | (width, height) => void | — | Fired with the owned image's intrinsic pixel size once it loads — lets a parent (e.g. MediaBox) size a frame to the media's natural aspect. |
className | string | — | Merged onto the frame (layout only). |
...props | React.ComponentProps<"div"> | — | Forwarded to the <div>. |
<Card interactive className="overflow-hidden p-0">
<CardMedia ratio="2 / 3" src={src} alt={name} fallback={<UserRound className="size-8" />}>
<CardMediaOverlay>
<CardTitle>{name}</CardTitle>
<CardDescription>{role}</CardDescription>
</CardMediaOverlay>
</CardMedia>
</Card>CardMediaOverlay
The caption-on-media variant: a bottom scrim (bg-gradient-to-t from-black/85) placed
inside CardMedia that darkens the media so text stays legible. Nest the same
CardTitle / CardDescription inside — the overlay retypes them for on-media
contrast: white / white-70, stepped down to the caption scale (title 15→13,
description 13→12) and truncated. The "caption under the media" variant needs no
component — it's CardTitle / CardDescription in a padded div below CardMedia.
Renders <div data-slot="card-media-overlay">.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Merged onto the scrim (layout only). |
...props | React.ComponentProps<"div"> | — | Forwarded to the <div>. |