Perspective Flow

Scroll-driven 3D image grid with perspective rotations, blur, brightness, and inner parallax — supports both GSAP ScrollTrigger and Framer Motion engines.

Installation

File Structure

perspective-flow.tsx

Usage

import { PerspectiveFlow } from "@/components/unlumen-ui/perspective-flow";

const images = [
  { src: "/photos/1.jpg", alt: "Photo 1" },
  { src: "/photos/2.jpg", alt: "Photo 2" },
  { src: "/photos/3.jpg", alt: "Photo 3" },
  { src: "/photos/4.jpg", alt: "Photo 4" },
];

export default function Gallery() {
  return <PerspectiveFlow images={images} />;
}

With a custom scroll container

If your page uses an inner scroll area (overflow-y-auto), pass a scrollContainerRef so the animation listens to the correct scroller.

"use client";

import { useRef } from "react";
import { PerspectiveFlow } from "@/components/unlumen-ui/perspective-flow";

export default function ScrollAreaExample() {
  const scrollRef = useRef<HTMLDivElement>(null);

  return (
    <div ref={scrollRef} className="h-[80vh] overflow-y-auto">
      <PerspectiveFlow images={images} scrollContainerRef={scrollRef} />
    </div>
  );
}

GSAP engine

Switch to GSAP for a stronger cinematic look:

<PerspectiveFlow images={images} engine="gsap" />

Tuning density and spacing

<PerspectiveFlow
  images={images}
  perspective={900}
  minItemWidth={200}
  gap={24}
  verticalGap={96}
/>

gap and verticalGap accept both numbers (px) and CSS lengths ("1.5rem", "clamp(...)", etc.).

API Reference

images
PerspectiveFlowImage[]

Array of image objects to display. Each item has `src` (string) and optional `alt` (string).

engine?
"gsap" | "motion"
"motion"

Animation engine. `"motion"` uses Framer Motion useScroll/useTransform. `"gsap"` uses GSAP ScrollTrigger timelines.

perspective?
number
1000

CSS perspective depth in pixels applied to each card container.

maxWidth?
string
"900px"

Max width of the grid container.

gap?
number | string
"1.5rem"

Horizontal gap between items. Accepts `number` (px) or CSS string.

verticalGap?
number | string

Vertical gap between rows. Defaults to `gap` when omitted. Accepts `number` (px) or CSS string.

minItemWidth?
number
240

Minimum item width used by responsive auto-fit columns.

scrollContainerRef?
RefObject<HTMLElement | null>

Optional ref to a custom scroll container. Required when not using viewport scroll.

className?
string

Additional classes applied to the root element.

How it works

Each card is rendered as a figure with its own CSS perspective context. Both engines run a 3-phase scroll animation:

  1. Entering (bottom) — tilted back (rotateX: 70°), shifted sideways, skewed, blurred (7px), dark (brightness: 0%), high contrast, and the inner image is vertically stretched (scaleY: 1.8) for a parallax effect.
  2. Center (revealed) — all transforms reset to identity. No blur, full brightness, normal scale.
  3. Exiting (top) — tilts forward (rotateX: -50°), slight counter-rotation, blur returns, goes dark again, inner image re-stretches.

Left/right cards are mirrored, and GSAP computes the left/right side relative to the active scroller center (not always the window), which keeps behavior consistent in preview containers.

Perspective-adaptive behavior

The component now auto-adapts motion and spacing based on perspective:

  • Lower perspective: reduced transform amplitudes + increased effective spacing to avoid overlap.
  • Higher perspective: stronger depth and larger motion range.

This makes low values like 200-400 still usable without collapsing cards into each other.

Notes

  • The grid is intentionally capped to at most 2 columns for this component's visual language.
  • The component is "use client" because both engines require browser APIs (scroll position, DOM measurements).
  • Images are rendered as CSS background-image with bg-cover bg-center — they fill the card and crop to center.
  • will-change: filter and will-change: transform are set for GPU compositing.
  • GSAP cleanup is scoped with gsap.context(...).revert() to avoid touching unrelated ScrollTriggers.

Credits

Built by leo.

Inspired by and adapted from Skiper UI.

Keep in mind

Most components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.