
Loading the page…
Loading the page…
How Perspective Cards floats image cards in 3D around a headline with Motion values, and five traps: React Compiler lint, flex min-width, mobile LCP, Playwright clicks and bundle sizes.
Written with AI: Claude Opus 5.5, through Claude
5 lessons learned building it
The product
Perspective Cards — 3D Parallax Image Gallery Hero for React & Framer MotionPro plan · Component

The product
Pro plan · Component
A 3D parallax image gallery hero for React: cards float around your headline, follow the cursor like a camera, lift and face you on hover, and open into a cinematic lightbox. Framer Motion, light/dark themes, keyboard, touch and reduced-motion support.
npx shadcn@latest add @upfork/3d-parallax-image-gallery-reactPerspective Cards turns a collection into a scene. Ten image cards float in 3D around the headline, all sharing one vanishing point and each turned toward it, and the whole scene moves with the cursor like a camera: near cards travel more than far ones, while the headline drifts gently the other way. Hover a card and it lifts and turns to face you; click it and that exact card grows into a lightbox with swipe, thumbnails and a focus trap, then flies back into its place when you close it. Three layouts (orbit, scatter, arc) are placed by a seeded engine that never lets a card cover the headline or another card's caption; tablets get a two-row arc, and phones a fan of cards and a swipeable strip.
This article is for anyone, person or AI, building a pointer-driven 3D scene in React: how it moves with no React render, and five traps we hit, each with its fix.
Everything that moves lives in Motion values, created once per scene and once per card, and the cards' transforms are computed from them. A pointer move sets a few values and the browser composites the result: a Chrome trace of the pointer sweeping the stage shows zero React commits. Even the glare is a pre-drawn highlight moved by transform, so following the pointer never repaints:
29 unit tests; ESLint with the React Compiler rules and TypeScript clean; axe finds no violations in light, dark and with the lightbox open. Lighthouse desktop 98–99 for Performance and 100 for the rest. A Chrome trace of the pointer sweeping the stage shows no layout, no paint and no React commits. No horizontal overflow from 320 to 1920 px wide. On UpFork it passed the importer and the preview builder with all three demos.
<PerspectiveCards /> takes your items (each with src, alt, title, width and height, and optionally a description and an href), the headline, subheadline, eyebrow and cta, and a layout of orbit, scatter or arc with a seed. depth, parallax, tilt and float tune the motion, theme and accent the look, lightbox and onCardClick what a click does, and renderImage lets you draw the pictures with your own image component. The component's README has the full table and a tuning guide.
npx shadcn@latest add @upfork/3d-parallax-image-gallery-reactInstalls with your UpFork access token once you have it: see how on its page.
Written by
Ramin ValadanProved GitHub account and website, confirmed e-mail: checked by UpFork
Full-stack developer and founder of UpFork · .NET, React/Next.js and WebGL
This version is signed in UpFork’s ledger as entry #172, so anyone can check it hasn’t changed since it was published. Check it.
Something wrong in it, or against the rules? Report it.

Behind the build · Velvet Sidebar — Glass Collapsible Navigation Sidebar for React
How Velvet Sidebar keeps its frosted glass clean and its first fold smooth, and five traps: blend modes over backdrop-filter, layer promotion, WebGL in React's dev effects, GLSL and lint.
Ramin ValadanProved GitHub account and website, confirmed e-mail: checked by UpFork4 min readAI
5 lessons


Behind the build · Duet Sidebar — Glassmorphism Icon Dock & Expandable Menu for React
How Duet Sidebar's dock and menu share state without React renders, and five traps: light-dark() with numbers, self-hosted demo fonts, compiler lint, dev double effects and exiting views.
Ramin ValadanProved GitHub account and website, confirmed e-mail: checked by UpFork5 min readAI
5 lessons


Behind the build · Commander — Pet Shop Hero Section with Cursor-Tracking Cat (React + Next.js)
How Commander scrubs 90 video frames on a canvas so a cat follows the pointer, and eight traps: pill borders, a canvas benchmark, a spotlight, focus rings and a hero that broke in a preview.
Ramin ValadanProved GitHub account and website, confirmed e-mail: checked by UpFork7 min readAI
8 lessons1
the React Compiler's lint rules reject mutating an object that is held in state, so every one of those assignments was an error.
the scene is a set of Motion values made once (useState(createScene)), and the layout effect calls .set() on them. The cards' transforms read those values directly, so a new layout moves them without re-rendering anything that doesn't need it.
rig.ts lines 36–48; PerspectiveCards.tsx lines 225–229; the lint run in the build notes.
min-width: 0 on the strip item, so the basis wins and the description is cut off with its ellipsis instead.
styles.css lines 624–626; the phone screenshots and card measurements in the build notes.
the fan's cards are sized below the headline block (min(44cqw, 210px)), so the headline, which is in the first HTML, is the largest paint. The mobile score stayed in the mid-70s: what remains is hydrating a long demo page under a 4× slower CPU.
styles.css lines 576–580; the Lighthouse runs in the build notes.
click the card by its position (its bounding box's centre, with page.mouse.click). click({ force: true }) also skips the stability check.
the accessibility test's output in the build notes.
the first-paint size is the stage's chunk plus everything it imports statically (import-statement edges, followed recursively); each lazy part is counted as its own closure minus what the stage already brought. The result: 11.8 kB gzipped for the stage, 4.0 kB for the lightbox (fetched before the first click) and 2.0 kB for the deck (phones only).
scripts/size.mjs in the repo.
/** A chunk plus everything it imports statically: what has to arrive before it runs. */
const closure = (k, seen = new Set()) => {
if (seen.has(k)) return seen;
seen.add(k);
for (const i of outputs[k].imports) if (i.kind === "import-statement" && outputs[i.path]) closure(i.path, seen);
return seen;
};
const entryOf = (name) => Object.keys(outputs).find((k) => outputs[k].entryPoint?.endsWith(name));
const stage = closure(entryOf("PerspectiveCards.tsx"));/* Glare: a pre-drawn highlight moved by transform, so following the pointer never repaints. */
.pc-glare {
position: absolute;
left: 0;
top: 0;
border-radius: 50%;
pointer-events: none;
opacity: 0;
mix-blend-mode: overlay;export function createScene(): Scene {
return {
px: springValue<number>(0, { stiffness: 60, damping: 20 }),
py: springValue<number>(0, { stiffness: 60, damping: 20 }),
parallax: motionValue(0.5),
tilt: motionValue(14),
scroll: motionValue(0),
calm: motionValue(0),
center: motionValue({ x: 0, y: 0 }),
origin: motionValue({ x: 0, y: 0 }),
dpr: typeof window === "undefined" ? 1 : Math.min(window.devicePixelRatio || 1, 3),
};
} scene.center.set({
x: keepOut.x + keepOut.w / 2,
y: keepOut.y + keepOut.h / 2,
});
scene.origin.set({ x: W * 0.5, y: H * 0.45 });.pc-strip-item {
flex: 0 0 72cqw;
min-width: 0;
scroll-snap-align: center;
animation: pc-rise 0.7s var(--pc-ease) both;
animation-delay: calc(min(var(--i, 0), 4) * 60ms + 420ms);
}.pc-fan-card {
position: absolute;
/* Smaller than the headline block, so the headline stays the page's largest paint (LCP). */
width: min(44cqw, 210px);
max-height: 94%;
Comments
0 comments
No comments yet. Questions, what you built with it, what could be better: its developer reads them.
Sign in to comment.
Sign in to comment