Inset Card
A card whose photo sits in a padded frame and fills it on hover, closing the gap to its detail box.
$npx shadcn@latest add https://framebrick.co/r/inset-card.jsonFrom the Balancee template, see it in context.
import { InsetCard } from "@/components/ui/inset-card";
// Demo photo from Unsplash (free under the Unsplash License), served by this site so no request leaves it.
/* The photo sits in a padded frame; point at the card and the padding, the gap and the corners close into one card. */
export default function InsetCardDemo() {
return (
<InsetCard href="#" ratio="5 / 2" media={<img src="/demo/media-accordion-design.webp" alt="" />} className="w-60">
<span className="font-medium">Business strategy</span>
<span className="text-sm text-foreground/75">A plan your team can follow.</span>
</InsetCard>
);
}Examples
Single surface
One card: the photo is padded inside it and fills the top of it on hover; the text does not move.
import { InsetCard } from "@/components/ui/inset-card";
// Demo photo from Unsplash (free under the Unsplash License), served by this site so no request leaves it.
/* One surface: the photo is padded inside the card and fills the top of it on hover; the text does not move. */
export default function InsetCardSingleDemo() {
return (
<InsetCard join={false} href="#" ratio="5 / 2" media={<img src="/demo/media-accordion-build.webp" alt="" />} className="w-60">
<span className="font-medium">Notes from the studio</span>
<span className="text-sm text-foreground/75">Planning a small site in a week.</span>
</InsetCard>
);
}<InsetCard join={false} href="/journal/studio-notes" media={<Image src={photo} alt="" fill />}>…</InsetCard>Installation
$ npx shadcn@latest add https://framebrick.co/r/inset-card.json1. Install the dependencies.
npm install motion clsx tailwind-merge2. Add
cn()tolib/utils.ts(skip if shadcn already created it).import { clsx, type ClassValue } from "clsx"; import { extendTailwindMerge } from "tailwind-merge"; /* tailwind-merge must know the site's type scale (globals.css) is a font size, not a text color — otherwise cn("text-h6 text-muted-foreground") drops text-h6. */ const twMerge = extendTailwindMerge({ extend: { classGroups: { "font-size": [{ text: ["display", "h1", "h2", "h3", "h4", "h5", "h6"] }] } }, }); export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }3. Copy the component into
components/ui/inset-card.tsx.import * as React from "react"; import Link from "next/link"; import { cn } from "@/lib/utils"; /* * Inset Card — a card whose photo does not touch the card's edge: it sits in a frame with a little padding, and pointing at * the card (or focusing it) closes that padding so the photo fills the frame, its corners squaring off (from the Balancee * Service, Post and Team cards). * * Two forms, one file. `join` (default): the frame and the detail box are two boxes `inset` px apart; on hover the padding, * the gap and the facing corners all go to 0, so the two become one card; the detail box takes the gap as extra top padding, so * the card keeps its height (Balancee does this on the Team card). Without `join` it is one surface: the photo is * padded inside it, and fills the top of it on hover, with the text below unchanged. * * It is plain CSS — no animation library, no JavaScript — so it works as it is on a server page. Framer's spring (bounce 0, * 0.4s) is a CSS linear() curve sampled from the spring itself (tools/ui/spring-curve.mjs in the Framebrick workspace; * within 1% of it) over 450ms, which is how Balancee's own port runs it: the curve passes 99% at about 340ms and the last * pixel at 450ms (Framer's notes say "about 300ms" for it to look settled). A browser without linear() (before Chrome 113, * Firefox 112, Safari 17.2) gets cubic-bezier(0.22, 1, 0.36, 1). Padding, gap and radius are not transform or size keys, so * <MotionConfig> would not skip them: with reduced motion the hover state simply changes (motion-reduce:transition-none). * Keyboard focus shows the same state as hover; hover itself only applies where the device can hover. * * The photo is decoration (alt=""): the link is named by the detail text. The look is the library's (bg-muted, rounded-xl, * rounded-md); the 5px is Balancee's, and a number prop. */ const spring = "ease-[cubic-bezier(0.22,1,0.36,1)] supports-[transition-timing-function:linear(0,1)]:ease-[linear(0,0.041,0.134,0.248,0.364,0.472,0.569,0.652,0.722,0.78,0.827,0.864,0.894,0.918,0.937,0.951,0.963,0.971,0.978,0.983,0.987,0.99,0.993,0.995,0.996,0.997,0.998,0.998,0.999,1)]"; const ease = `duration-450 ${spring} motion-reduce:transition-none`; export type InsetCardProps = { /** The photo. Give it an empty alt text; `fill` and a plain img both work. */ media: React.ReactNode; /** The detail: a title and a line, say. It sits under the photo. */ children: React.ReactNode; /** Makes the whole card a link (Next.js Link; a full address opens in a new tab). */ href?: string; /** Proportions of the photo frame, as a CSS aspect-ratio value. */ ratio?: string; /** Padding of the frame at rest, in px; it is also the gap to the detail box when joined. */ inset?: number; /** Two boxes that close into one on hover (default), or one surface with the photo padded inside it. */ join?: boolean; className?: string; }; export function InsetCard({ media, children, href, ratio = "3 / 2", inset = 5, join = true, className }: InsetCardProps) { const classes = cn( "group flex flex-col rounded-xl outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50", join ? "gap-(--inset) transition-[gap] hover:gap-0 focus-visible:gap-0" : "overflow-hidden bg-muted text-foreground", join && ease, className, ); const style = { ["--inset" as string]: `${inset}px` }; const photo = ( <span className={cn( "block overflow-hidden p-(--inset) transition-[padding,border-radius] group-hover:p-0 group-focus-visible:p-0", join ? "rounded-xl bg-muted group-hover:rounded-b-none group-focus-visible:rounded-b-none" : "", ease, )} style={{ aspectRatio: ratio }} > <span className={cn( "block size-full overflow-hidden rounded-md transition-[border-radius] group-hover:rounded-none group-focus-visible:rounded-none [&>*]:size-full [&>*]:object-cover", ease, )} > {media} </span> </span> ); const content = join ? ( <> {photo} <span className={cn( "flex flex-1 flex-col gap-1 rounded-xl bg-muted px-5 py-4 text-foreground transition-[border-radius,padding] group-hover:rounded-t-none group-hover:pt-[calc(1rem+var(--inset))] group-focus-visible:rounded-t-none group-focus-visible:pt-[calc(1rem+var(--inset))]", ease, )} > {children} </span> </> ) : ( <> {photo} <span className="flex flex-col gap-1 px-5 pt-1 pb-4">{children}</span> </> ); if (!href) { return ( <div className={classes} style={style}> {content} </div> ); } if (/^https?:\/\//.test(href)) { return ( <a href={href} target="_blank" rel="noopener noreferrer" className={classes} style={style}> {content} </a> ); } return ( <Link href={href} className={classes} style={style}> {content} </Link> ); }
Usage
import { InsetCard } from "@/components/ui/inset-card";
<InsetCard href="/services/strategy" media={<Image src={photo} alt="" fill sizes="25vw" />}>
<span className="font-medium">Business strategy</span>
<span className="text-sm text-foreground/75">Positioning and a plan your team can follow.</span>
</InsetCard>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| media | ReactNode | — | The photo: an image, with an empty alt text. It is stretched to the frame and clipped. |
| children | ReactNode | — | The detail under the photo: a title and a line, say. |
| href | string | — | Makes the whole card a Next.js link; a full address opens in a new tab. Without it the card is a plain box. |
| ratio | string | "3 / 2" | Proportions of the photo frame, as a CSS aspect-ratio value. |
| inset | number | 5 | Padding of the frame at rest, in px. When joined it is also the gap to the detail box. |
| join | boolean | true | Two boxes that close into one on hover; false is one surface with the photo padded inside it. |
| className | string | — | Classes for the card (its width, say). |
- It is Balancee's Service, Post and Team cards: the photo sits in a frame with 5 px of padding (photo corners 7 px, frame 10), and on hover the padding goes to 0 so the photo grows to the edge (357 to 367 px wide, 5 px up and left) and its corners square off. Joined (Service, Team), the frame and the detail box are two boxes 5 px apart; on hover the gap closes and the frame's bottom corners and the detail box's top corners go from 10 to 0, so the two become one card. Single surface (Post) keeps one card and only the photo moves.
- Plain CSS: no animation library and no JavaScript, so it works as it is on a server page. Framer's spring (bounce 0, 0.4 s) is a CSS linear() curve sampled from the spring itself, within 1% of it, over 450 ms, as Balancee's own port runs it; a browser without linear() gets cubic-bezier(0.22, 1, 0.36, 1).
- Padding, gap and radius are not transform or size keys, so <MotionConfig> would not skip them: with reduced motion the state simply changes (motion-reduce:transition-none). Hover only applies where the device can hover; keyboard focus shows the same state.
- Secondary text on the muted surface needs its own tone: text-muted-foreground on bg-muted is 4.3:1 in the default theme, so the demos use text-foreground/75. The photo is decoration (alt=""): the link is named by the detail text. The look is the library's (bg-muted, rounded-xl, rounded-md); the 5 px is Balancee's, a number.
- It is not Zoom Card (which scales its content inside a fixed frame) or Frame Card (which shrinks the sharp photo inside a blurred copy: the other direction).
AI prompt
Paste into v0, Lovable, Bolt or Claude to rebuild this component in your own project.
Create a React + Tailwind CSS component called InsetCard — plain CSS, no animation library, no JavaScript, so it works in a server component.
- Props: media (the photo), children (the detail), href, ratio ("3 / 2"), inset (px, 5), join (true), className. Set --inset inline. The root is a Next.js Link (an <a> that opens in a new tab for a full address, a div without href) with the group class: flex column, rounded-xl, a focus ring ring-[3px] ring-ring/50.
- join: the root has gap-(--inset) and goes to gap-0 on hover and focus-visible (transition-[gap]). A frame span (overflow-hidden, rounded-xl, bg-muted, p-(--inset), the aspect-ratio from the prop) holds a photo span (size-full, overflow-hidden, rounded-md, children stretched with size-full object-cover). Then a detail span: flex-1, flex column, rounded-xl, bg-muted, px-5 py-4. On group-hover and group-focus-visible the frame's padding goes to 0 and its bottom corners to 0 (rounded-b-none), the photo's corners to 0 and the detail's top corners to 0 (rounded-t-none).
- Without join: the root is one surface (overflow-hidden, bg-muted); the frame has no background or radius of its own, only the padding; the detail has no background and sits under it. The photo's padding and corners change on hover the same way.
- Every transition lasts 450ms on a CSS linear() list sampled from a spring with bounce 0 and duration 0.4s (about 30 stops) behind supports-[transition-timing-function:linear(0,1)]:, with cubic-bezier(0.22,1,0.36,1) as the fallback; motion-reduce:transition-none.












