Profile photo
Jannis Metrikat

Product Engineer

Project Preview cover
jmetrikat.com

Building a Project Preview Component: Layers, Motion, and Adaptation

The ProjectPreview component is designed to feel tactile and intentional: a static card that transforms into a rich, focused experience on hover and tap. This post breaks down the design decisions behind the layered hover effect, the morphing dialog, and the adaptation of elements to dark mode.


I started by optimizing for feel over features. The card should be calm when you’re just scanning the page; it only opens up when you show intent. That meant a clean static state, a gentle tilt to suggest dimensionality, and a hover reveal that arrives quickly but not abruptly. For the tilt, I used a modest rotation with a spring that eases in and out without wobble. The goal isn’t to impress with motion—it’s to whisper that the element is alive.

Calm by default, expressive on intent.

  • Tilt that suggests, not shouts: a limited rotation with a low-bounce spring.
  • Fast hover, soft fade: quick on interaction, gentle otherwise.
  • Selective inversion: preserve legibility and brand intent in dark mode.

Under the hood it’s standard React and Next with next/image, Tailwind for styling, and a few Motion Primitives for the dialog and carousel. Where assets are vector, I use external SVGs (logos, overlays, main artwork) so they scale cleanly; photographic content stays raster.


Layering the Hover State

Each card is a small stage composed of three layers: a color plate, a product image, and two on-hover overlays. The layers stack on a relative container using absolute positioning and z-index so their order is explicit and easy to reason about. I lock the frame to a cinematic 1.91:1 ratio; it keeps the grid visually quiet across breakpoints. The reveal is pure CSS - opacity transitions tied to group-hover - so there’s no JavaScript on 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`}
      alt=""
      aria-hidden="true"
      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"
    alt=""
    aria-hidden="true"
    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"
    alt=""
    aria-hidden="true"
    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>

There are a few tactical details that make it feel right. The base plate is object-cover to fill the frame, while the main artwork is object-contain so it never crops. Overlays are pointer-events-none, which lets the card stay clickable without juggling z-index hacks. And the transition timing is asymmetric: a slower default opacity (duration-700) with a faster hover (group-hover:duration-200). That gives a soft entrance and a snappy response when you interact.


Adaptation to Dark Mode

Dark mode isn’t a single switch; it’s a set of decisions. The background plates dim slightly using dark:brightness-90 so saturated colors don’t glow. For the main artwork, I apply dark:invert to maintain crisp contrast, though you could create dedicated dark variants if brand accuracy is needed. The profile overlay contains photography that looks best untouched - skin tones rarely benefit from inversion. Similarly, the logo overlay uses an external SVG with dark:invert for consistent contrast, though inlining would enable more granular CSS theming.

  • Background plates: Dimmed with dark:brightness-90 to prevent glowing
  • Main artwork: Uses dark:invert for contrast, unless brand accuracy needed
  • Profile overlay (photo): No inversion to preserve natural colors
  • Vector logos/overlays (SVG): dark:invert for consistent contrast

From Card to Focused View

Once you click or tap, the intent shifts from browsing to viewing. The card morphs into a dialog that feels like it grows from the same layer stack. A spring with no bounce keeps the opening crisp and avoids overshoot on a modal. If there’s a video, it takes center stage; otherwise a carousel invites a quick scan. Keyboard arrow keys work out of the box, which makes the component feel more like an app than a page.

<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 subtle magnetic pull. It’s easy to overdo this kind of affordance; I keep the intensity low so it reads as tactile, not sticky. Focus and escape are handled by the dialog, which keeps interactions predictable when you’re moving quickly.

What makes it feel fast isn’t one trick - it’s mutiple small ones. next/image handles responsive sizing and lazy loading. Static hover states stay CSS-only, which keeps the main thread quiet. Images are draggable={false} to avoid ghost drags when you move quickly. Alt text is kept on meaningful layers, and decorative overlays are marked aria-hidden. Motion primitives respect reduced-motion, so the component still communicates without animation.

Given more time, I’d map brand assets to per-logo dark treatments rather than a blanket invert. I’d also make overlays more narrative: the profile layer could carry subtle motion or context about the project, not just decoration. And I’d experiment with a brief choreography on hover - staggered fades and micro parallax - to lean into the card’s cinematic frame without adding noise.


Conclusion

The ProjectPreview is a small, layered composition: fast to render, calm by default, and expressive on intent. By carefully separating layers, using CSS for hover, and treating dark mode as a first-class design problem, the component stays both beautiful and robust.