Motion
Helix Gallery
Cards wound onto intertwined strands of a vertical helix, turning in real 3D as you drag, with the far side dimmed and blurred behind the near one.
Drag to turn the helix, or scroll over it
import { HelixGallery, type HelixItem } from "@/components/ui/helix-gallery";
const items: HelixItem[] = Array.from({ length: 10 }, (_, index) => ({
image: `https://picsum.photos/seed/helix-${index}/400/300`,
label: `Frame ${String(index + 1).padStart(2, "0")}`,
}));
export default function HelixGalleryDemo({ fullscreen }: { fullscreen?: boolean }) {
return (
<div className="w-full space-y-3">
<HelixGallery
items={items}
height={fullscreen ? "78dvh" : "460px"}
className="rounded-card border border-line bg-[#0c0c10]"
/>
<p className="text-center text-xs text-faint">
Drag to turn the helix, or scroll over it
</p>
</div>
);
}Installation
1. Install the dependencies.
terminal npm install gsap clsx tailwind-merge2. Add the
cnhelper, if you don't already have it.lib/utils.ts import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }3. Copy the component into your project.
components/ui/helix-gallery.tsx "use client"; import gsap from "gsap"; import { useCallback, useEffect, useMemo, useRef, type PointerEvent, type WheelEvent, } from "react"; import { cn } from "@/lib/utils"; export type HelixItem = { image: string; label?: string; }; type HelixGalleryProps = { items: HelixItem[]; /** Intertwined strands. Two gives the classic double helix. */ strands?: number; /** Distance of each card from the axis, in pixels. */ radius?: number; /** Vertical distance between neighbours on a strand, in pixels. */ rise?: number; /** Degrees each step turns around the axis. */ twist?: number; /** Card width in pixels. Height follows the aspect ratio. */ width?: number; /** Height of the stage. */ height?: string; className?: string; }; const ASPECT = 4 / 3; /** Depth cue applied to the far side of the axis. */ const DIM = 0.42; const HAZE = 3.5; /** Steps over which a card fades out before it wraps to the other end. */ const FADE = 1.4; /** Enough cards that the wrap always happens out of sight. */ const MIN_CARDS = 16; const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); export function HelixGallery({ items, strands = 2, radius = 300, rise = 72, twist = 40, width = 168, height = "620px", className, }: HelixGalleryProps) { // The same items repeated until each strand has enough to loop cleanly. const cards = useMemo(() => { if (!items.length) return []; const out = [...items]; while (out.length < MIN_CARDS) out.push(...items); // Trim to a whole number of turns so both strands stay balanced. return out.slice(0, out.length - (out.length % strands)); }, [items, strands]); const perStrand = cards.length / strands; const cardRefs = useRef<(HTMLElement | null)[]>([]); const state = useRef({ offset: 0 }); const drag = useRef<{ x: number; y: number; from: number; velocity: number; at: number; } | null>(null); const layout = useCallback(() => { if (!perStrand) return; const { offset } = state.current; cardRefs.current.forEach((node, index) => { if (!node) return; const strand = index % strands; const step = Math.floor(index / strands); // Wrap into the nearest turn, so a card leaving the top reappears at the // bottom while it is still faded out. let u = (((step - offset) % perStrand) + perStrand) % perStrand; if (u > perStrand / 2) u -= perStrand; const angle = u * twist + (strand * 360) / strands; const radians = (angle * Math.PI) / 180; const z = radius * Math.cos(radians); // 0 on the far side of the axis, 1 nearest the viewer. const depth = (z / radius + 1) / 2; gsap.set(node, { x: radius * Math.sin(radians), y: u * rise, z, rotateY: angle, opacity: clamp((perStrand / 2 - Math.abs(u)) / FADE, 0, 1), filter: `brightness(${(DIM + (1 - DIM) * depth).toFixed(3)}) blur(${( (1 - depth) * HAZE ).toFixed(2)}px)`, zIndex: Math.round(1000 + z), }); }); }, [perStrand, radius, rise, strands, twist]); useEffect(() => { gsap.set(cardRefs.current.filter(Boolean), { xPercent: -50, yPercent: -50, force3D: true, }); layout(); }, [layout]); const glide = useCallback( (velocity: number) => { gsap.killTweensOf(state.current); if (Math.abs(velocity) < 0.0004) return; gsap.to(state.current, { offset: state.current.offset + velocity * 260, duration: 1.4, ease: "power3.out", onUpdate: layout, }); }, [layout], ); const onPointerDown = useCallback((event: PointerEvent<HTMLDivElement>) => { gsap.killTweensOf(state.current); event.currentTarget.setPointerCapture(event.pointerId); drag.current = { x: event.clientX, y: event.clientY, from: state.current.offset, velocity: 0, at: performance.now(), }; }, []); const onPointerMove = useCallback( (event: PointerEvent<HTMLDivElement>) => { const held = drag.current; if (!held) return; // Either axis drives it, so a mouse can sweep in any direction while // touch keeps its vertical gesture for scrolling the page. const travel = event.clientY - held.y + (event.clientX - held.x) * 0.6; const now = performance.now(); held.velocity = -travel / rise / Math.max(1, now - held.at); held.at = now; held.y = event.clientY; held.x = event.clientX; held.from -= travel / rise; state.current.offset = held.from; layout(); }, [layout, rise], ); const onPointerUp = useCallback( (event: PointerEvent<HTMLDivElement>) => { const held = drag.current; event.currentTarget.releasePointerCapture(event.pointerId); drag.current = null; if (held) glide(held.velocity); }, [glide], ); const onWheel = useCallback( (event: WheelEvent<HTMLDivElement>) => { gsap.killTweensOf(state.current); state.current.offset += event.deltaY / (rise * 4); layout(); }, [layout, rise], ); return ( <div role="group" aria-label="Helix gallery" tabIndex={0} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp} onWheel={onWheel} onKeyDown={(event) => { if (event.key === "ArrowUp") glide(-0.004); if (event.key === "ArrowDown") glide(0.004); }} style={{ height }} className={cn( "relative touch-pan-y cursor-grab overflow-hidden select-none active:cursor-grabbing", "focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-accent", className, )} > {/* Perspective lives above the clip, and preserve-3d below it, so the depth survives the overflow that crops the strands. */} <div className="absolute inset-0" style={{ perspective: 1400 }}> <div className="absolute top-1/2 left-1/2" style={{ transformStyle: "preserve-3d" }} > {cards.map((card, index) => ( <figure key={index} ref={(node) => { cardRefs.current[index] = node; }} style={{ width, height: width * ASPECT }} className="absolute top-0 left-0 overflow-hidden rounded-xl bg-elevated shadow-[0_24px_50px_-28px_rgb(0_0_0/0.75)]" > <img src={card.image} alt={card.label ?? ""} draggable={false} className="size-full object-cover" /> {card.label ? ( <figcaption className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent p-3 text-[11px] font-medium text-white"> {card.label} </figcaption> ) : null} </figure> ))} </div> </div> </div> ); }
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| items | HelixItem[] | — | Each entry needs an `image`, plus an optional `label` caption. |
| strands | number | 2 | Intertwined strands. Two gives the classic double helix. |
| radius | number | 300 | Distance of each card from the axis, in pixels. |
| rise | number | 72 | Vertical distance between neighbours on a strand, in pixels. |
| twist | number | 40 | Degrees each step turns around the axis. |
| width | number | 168 | Card width in pixels. Height follows the aspect ratio. |
| height | string | "620px" | Height of the stage. |