Image card
Media on top in a fixed aspect, with a caption and one mono meta line below.
npx shadcn@latest add https://ui.goodtabs.co/r/image-card.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/image-card
No media passed, so the 16:9 slot shows the hairline placeholder.
Square, in a grid
Three square cards side by side. The slot keeps its ratio at any width.
With a link title
A 4:3 slot holding a small live element, and a title that links somewhere.
Usage
import { ImageCard } from "@/components/ui/image-card"
<ImageCard title="Dashboard, dark theme" meta="1440 x 900" media={<img src="/shots/dashboard.png" alt="" />} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| media | ReactNode | What fills the slot: an img, a video, a demo. Falls back to a hairline pattern. | |
| title | string | The caption under the media. | |
| meta | string | One quiet mono line under the title. | |
| href | string | UrlObject | Makes the title a link. Passed to next/link. | |
| aspect | "video" | "square" | "4/3" | "video" | Shape of the media slot. |
Source
image-card/image-card.tsx
import * as React from "react";
import Link from "next/link";
import { Card, CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";
type Aspect = "video" | "square" | "4/3";
type ImageCardProps = Omit<React.ComponentProps<typeof Card>, "children" | "title"> & {
/** What fills the media slot: an img, a video, a demo. Falls back to a hairline pattern. */
media?: React.ReactNode;
/** The caption under the media. */
title: string;
/** One quiet mono line under the title: a date, a size, a credit. */
meta?: string;
/** Makes the title a link. Passed to next/link. */
href?: React.ComponentProps<typeof Link>["href"];
/** Shape of the media slot. */
aspect?: Aspect;
};
const ASPECT = { video: "aspect-video", square: "aspect-square", "4/3": "aspect-4/3" } as const;
/**
* Media on top, a caption and a mono meta line below.
* The media slot keeps its aspect ratio and clips whatever is inside to it.
*/
export function ImageCard({ media, title, meta, href, aspect = "video", className, ...props }: ImageCardProps) {
return (
<Card data-slot="image-card" size="sm" className={cn("gap-3 pt-0", className)} {...props}>
<div
data-slot="image-card-media"
className={cn(
"w-full overflow-hidden bg-muted [&_img]:size-full [&_img]:object-cover [&_video]:size-full [&_video]:object-cover",
ASPECT[aspect],
)}
>
{media ?? (
<div
aria-hidden="true"
className="size-full bg-[repeating-linear-gradient(135deg,var(--border)_0,var(--border)_1px,transparent_1px,transparent_10px)]"
/>
)}
</div>
<CardContent className="flex flex-col gap-0.5">
<p className="m-0 truncate text-sm font-medium text-foreground">
{href ? (
<Link
href={href}
className="rounded-sm transition-colors duration-100 ease-out outline-none hover:text-foreground/85 focus-visible:ring-2 focus-visible:ring-ring/50"
>
{title}
</Link>
) : (
title
)}
</p>
{meta && <p className="m-0 truncate font-mono text-xs text-muted-foreground">{meta}</p>}
</CardContent>
</Card>
);
}
For AI agents
Hand your agent this page, the markdown at /components/image-card/prompt.md, or the Copy AI prompt button above.
When to use
- Galleries of screenshots, templates or inspiration where every item has a picture
- A grid of assets with a size, date or credit under each
- Any list where the picture is the first thing to scan
When not to use
- When there is no picture; use a link card or a plain list
- When the whole card should be one click; put the link on the card, not the title
- For a hero visual, which wants a frame, not a caption
Agent notes
- Server component on shadcn's Card; an img or video passed as media is sized to cover the slot
- The placeholder is a hairline pattern drawn with var(--border), so it re-themes; it is aria-hidden
- Only the title becomes a link when href is set; the media stays inert so it can hold a demo
- aspect is a fixed set: video, square or 4/3. Add a class to the slot through the card if you need another