
Building a Project Preview Component: Layers, Motion, and Adaptation
ProjectPreview is a card that sits quietly until you reach for it. On hover it reveals an extra layer. On click it morphs into a focused dialog. This post walks through how it's built - the layered hover, the morphing dialog, and the dark mode treatment that doesn't undo any of it.
The first decision was about feel. I wanted the card still while you scan the page, and clear the moment you signal interest. A short tilt to suggest depth. A low-bounce spring so nothing wobbles. A hover reveal that arrives quickly and fades gently, so it never feels like a popup. The card shouldn't perform motion at you. It should answer you.
Calm by default. Expressive on intent.
Under the hood, it's standard React and Next with next/image, Tailwind for styling, and Motion Primitives for the dialog and carousel. Vectors - logos, overlays, main artwork - stay as external SVGs so they scale cleanly. Photographs stay raster.

Layering the hover state
Each card is three layers: a color plate, a product image, and two overlays that fade in on hover. They stack on a relative container using absolute positioning, with explicit z-index ordering. The frame is locked to a 1.91:1 ratio so the grid stays quiet across breakpoints. The reveal is pure CSS - opacity tied to group-hover - which keeps JavaScript out of the happy path.
// Core trigger structure (simplified)
<div className="group relative aspect-[1.91/1] w-full overflow-hidden rounded-xl">
<div className="relative h-full w-full rounded-xl">
{/* Color plate */}
<Image
src={`/projects/backgrounds/${project.thumb.color}.png`}
alt={`${project.name} background`}
className="h-full w-full rounded-xl object-cover dark:filter dark:brightness-90"
width={1000}
height={1000}
draggable={false}
/>
{/* Main artwork */}
<Image
src={`/projects/${project.thumb.slug}/main.svg`}
fill
sizes="100vw"
className="pointer-events-none absolute inset-0 object-contain dark:invert"
draggable={false}
/>
</div>
{/* Overlays that fade in on hover */}
<Image
src="/projects/overlay-profile.png"
fill
sizes="100vw"
className="pointer-events-none absolute inset-0 z-20 object-contain opacity-0 transition-opacity duration-700 group-hover:opacity-100 group-hover:duration-200"
draggable={false}
/>
<Image
src="/projects/overlay-logo.svg"
fill
sizes="100vw"
className="pointer-events-none absolute inset-0 z-30 object-contain opacity-0 transition-opacity duration-700 group-hover:opacity-100 group-hover:duration-200 dark:invert"
draggable={false}
/>
</div>
A few details do most of the work. The base plate uses object-cover to fill the frame; the main artwork uses object-contain so it never crops at awkward ratios. Overlays get pointer-events-none so the card stays clickable without z-index gymnastics. The opacity transitions are asymmetric on purpose: a long duration-700 for the default state, a quick group-hover:duration-200 for the response. Soft entrance, snappy return.
Dark mode is not a switch
Dark mode is a stack of small decisions, one per layer.
Background plates get dark:brightness-90 so saturated colors stop glowing once the page goes dark. The main artwork takes dark:invert to keep contrast crisp. The profile overlay - a photograph - gets nothing; inverting skin tones is rarely an improvement. Vector logos and overlays take dark:invert too. If brand fidelity matters more than simplicity, dedicated dark variants are the next step.
From card to focused view
Click or tap the card and it morphs into a dialog that looks like it grew out of the same layer stack. The spring has no bounce, which keeps the opening crisp and avoids the overshoot that makes morphs feel cheap. A video takes center stage if one exists; otherwise a carousel invites a quick scan. Keyboard arrows work out of the box.
<MorphingDialog
open={open}
onOpenChange={setOpen}
transition={{ type: 'spring', bounce: 0, duration: 0.3 }}
>
<MorphingDialogTrigger>{Trigger}</MorphingDialogTrigger>
<MorphingDialogContent className="max-w-5xl rounded-2xl bg-zinc-50 p-1 ring-1 ring-zinc-200/50 dark:bg-zinc-900 dark:ring-zinc-800/50">
{project.video ? (
<iframe /* YouTube embed */ />
) : (
<Carousel>{/* Images */}</Carousel>
)}
</MorphingDialogContent>
</MorphingDialog>
Links inside the dialog have a low-intensity magnetic pull. Low enough to feel tactile, not sticky. Focus and escape come for free from the dialog primitive, which keeps the interaction predictable.
What makes the whole thing feel fast is unglamorous. next/image handles responsive sizing and lazy loading. The hover state stays CSS-only, so the main thread stays quiet. Images carry draggable={false} to avoid ghost drags. Alt text goes on meaningful layers; decorative overlays get aria-hidden. Motion primitives respect prefers-reduced-motion. None of it is clever. It adds up anyway.
Given another day on it, I'd swap the blanket dark:invert for per-logo treatments so brand colors survive the theme switch, make the profile overlay carry context about the project instead of acting as decoration, and try a short hover choreography - staggered fades, a hint of parallax - to lean into the cinematic frame without crossing into noise.
The pattern generalizes. Three layers, explicit z-index, CSS-only hover, per-layer dark mode. Swap in your own assets and the structure holds.

