Skip to content
jjswnth/ui

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.

Installation

  1. 1. Install the dependencies.

    terminal
    npm install gsap clsx tailwind-merge
  2. 2. Add the cn helper, 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. 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

PropTypeDefaultDescription
itemsHelixItem[]Each entry needs an `image`, plus an optional `label` caption.
strandsnumber2Intertwined strands. Two gives the classic double helix.
radiusnumber300Distance of each card from the axis, in pixels.
risenumber72Vertical distance between neighbours on a strand, in pixels.
twistnumber40Degrees each step turns around the axis.
widthnumber168Card width in pixels. Height follows the aspect ratio.
heightstring"620px"Height of the stage.