Zoom Card
Media frame whose content zooms slowly on hover, with an optional caption.
From the Nitip template — see it in contextimport { ZoomCard } from "@/components/ui/zoom-card";
// Demo photos from Unsplash (free under the Unsplash License), loaded from its image CDN.
const photo = (id: string) => `https://images.unsplash.com/${id}?auto=format&fit=crop&w=900&q=80`;
const cards = [
{ title: "Aurora — brand site", image: photo("photo-1483366774565-c783b9f70e2c"), alt: "White curved facade of a modern building" },
{ title: "Kestrel — product launch", image: photo("photo-1724582586529-62622e50c0b3"), alt: "Calm living room with a low grey sofa" },
];
export default function ZoomCardDemo() {
return (
<div className="grid w-full max-w-xl gap-4 sm:grid-cols-2">
{cards.map((card) => (
<ZoomCard key={card.title} href="#" caption={card.title} className="aspect-[4/3]">
<img src={card.image} alt={card.alt} className="object-cover" />
</ZoomCard>
))}
</div>
);
}Installation
$ npx shadcn@latest add https://framebrick.co/r/zoom-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/zoom-card.tsx."use client"; import * as React from "react"; import Link from "next/link"; import { cn } from "@/lib/utils"; /* * Zoom Card — a media frame whose content zooms slowly on hover (from the Nitip project and post cards). * Put an image, a video or anything else inside; the frame clips it. An optional caption sits over the bottom of the * frame on a gradient, and the whole card can be a link. * The zoom uses Framer's own tween — cubic-bezier(0.12, 0.23, 0.5, 1) over 400 ms — not a spring. * * Pairs with <LabelCursor />: pass `cursor="View project"` to show a label while the pointer is over the card. */ export type ZoomCardProps = { children: React.ReactNode; href?: string; /** Shown over the bottom of the frame. */ caption?: React.ReactNode; /** Zoom at the end of the hover, e.g. 1.05 = 105%. */ zoom?: number; /** Label for <LabelCursor /> (sets data-cursor). */ cursor?: string; className?: string; }; export function ZoomCard({ children, href, caption, zoom = 1.05, cursor, className }: ZoomCardProps) { const frameProps = { "data-cursor": cursor, style: { ["--zoom" as string]: zoom }, className: cn( "group relative block overflow-hidden rounded-lg bg-muted outline-none", href && "focus-visible:ring-[3px] focus-visible:ring-ring/50", className, ), }; const content = ( <> <div className="size-full transition-transform duration-400 ease-[cubic-bezier(0.12,0.23,0.5,1)] group-hover:scale-(--zoom) group-focus-visible:scale-(--zoom) motion-reduce:transition-none *:size-full"> {children} </div> {caption && ( <div className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent p-4 text-sm font-medium text-white"> {caption} </div> )} </> ); return href ? <Link href={href} {...frameProps}>{content}</Link> : <div {...frameProps}>{content}</div>; }
Usage
import { ZoomCard } from "@/components/ui/zoom-card";
<ZoomCard href="/work/aurora" caption="Aurora — brand site" cursor="View project" className="aspect-[4/3]">
<Image src={cover} alt="" fill className="object-cover" />
</ZoomCard>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | — | The media: an image, a video, anything. It is stretched to the frame and clipped. |
| href | string | — | Makes the whole card a Next.js link. |
| caption | ReactNode | — | Shown over the bottom of the frame on a gradient. |
| zoom | number | 1.05 | Scale at the end of the hover. |
| cursor | string | — | Label for <LabelCursor /> (sets data-cursor). |
- The zoom uses Framer's own tween — cubic-bezier(0.12, 0.23, 0.5, 1) over 400 ms — not a spring.
- Keyboard focus zooms too; reduced motion turns the transition off.
AI prompt
Paste into v0, Lovable, Bolt or Claude to rebuild this component in your own project.
Create a React + Tailwind CSS component called ZoomCard (no animation library needed). - A rounded-lg frame with overflow-hidden and bg-muted that stretches its children to fill it. - On hover or keyboard focus of the frame, the inner layer scales to 1.05 with transition-transform, duration 400ms and Framer's tween cubic-bezier(0.12, 0.23, 0.5, 1); motion-reduce turns the transition off. - Optional caption absolutely positioned across the bottom on a gradient from black/70 to transparent, p-4, text-sm font-medium text-white, pointer-events-none. - If an href is given, the frame is a Next.js Link with a visible focus ring; a "cursor" prop sets data-cursor for a custom cursor component.











