Skip to content
jjswnth/ui

Section

Arc Carousel

A centred hero over an endlessly draggable arc of cards that shrink as they reach the middle and swell toward the edges.

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/arc-carousel.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 ArcSlide = {
      image: string;
      /** Small index shown under the card, e.g. "01". */
      number?: string;
      label?: string;
    };
    
    type ArcCarouselProps = {
      slides: ArcSlide[];
      eyebrow?: string;
      title?: string;
      description?: string;
      cta?: { label: string; href: string };
      /** Colour of the eyebrow and the call to action. */
      accent?: string;
      className?: string;
    };
    
    /** Card footprint. The middle of the arc renders at exactly this size. */
    const CARD = { width: 197, height: 289 };
    
    /** The gap left between neighbours — the same one everywhere on the arc. */
    const GAP = 26;
    
    /** Cards hold their natural size this far out, then start to grow. */
    const FLAT = 2;
    
    /** Cards reach full turn and full size this many steps from the centre. */
    const EDGE = 3.6;
    
    const GROW = 0.5;
    const TURN = 34;
    
    /** One card plus its gap — the unit a drag or a wheel step is measured in. */
    const STRIDE = CARD.width + GAP;
    
    /**
     * Enough cards on the ring that one is always well outside the visible arc
     * when it wraps to the other side, so the jump is never seen.
     */
    const MIN_RING = 10;
    
    const clamp = (value: number, min: number, max: number) =>
      Math.min(max, Math.max(min, value));
    
    /** Flat through the middle, swelling only toward the cropped edges. */
    function scaleAt(away: number) {
      return 1 + GROW * clamp((away - FLAT) / (EDGE - FLAT), 0, 1) ** 0.9;
    }
    
    function turnAt(away: number) {
      return TURN * Math.min(1, away / EDGE) ** 1.6;
    }
    
    /**
     * The room a card actually takes up on screen. A turned card is foreshortened,
     * so its drawn width is narrower than its layout width — without this the
     * outer gaps read wider than the ones in the middle.
     */
    function widthAt(away: number) {
      return CARD.width * scaleAt(away) * Math.cos((turnAt(away) * Math.PI) / 180);
    }
    
    /*
     * Cards change width as they grow, so evenly spaced centres would leave uneven
     * gaps. Each step out is instead the two neighbours' half widths plus one fixed
     * gap, which is exactly the spacing that keeps every gap on the arc identical.
     */
    const MAX_STEPS = 10;
    const OFFSETS = (() => {
      const table = new Float64Array(MAX_STEPS + 1);
    
      for (let step = 0; step < MAX_STEPS; step += 1) {
        table[step + 1] = table[step]! + (widthAt(step) + widthAt(step + 1)) / 2 + GAP;
      }
    
      return table;
    })();
    
    function offsetAt(d: number) {
      const away = Math.min(Math.abs(d), MAX_STEPS);
      const low = Math.floor(away);
      const high = Math.min(MAX_STEPS, low + 1);
    
      const value = OFFSETS[low]! + (OFFSETS[high]! - OFFSETS[low]!) * (away - low);
    
      return Math.sign(d) * value;
    }
    
    export function ArcCarousel({
      slides,
      eyebrow = "Behind the Designs",
      title = "Curious What Else I've Created?",
      description = "Explore more brand identities, packaging, and digital design work in my immersive portfolio.",
      cta = { label: "See more Projects", href: "#" },
      accent = "#f26722",
      className,
    }: ArcCarouselProps) {
      // The same slides repeated until there are enough to fill the ring.
      const ring = useMemo(() => {
        if (!slides.length) return [];
        const out = [...slides];
        while (out.length < MIN_RING) out.push(...slides);
        return out;
      }, [slides]);
    
      const cardRefs = useRef<(HTMLElement | null)[]>([]);
      const captionRefs = useRef<(HTMLElement | null)[]>([]);
    
      // Measured in cards. Unbounded — the ring wraps it during layout.
      const state = useRef({ position: 0 });
      const drag = useRef<{ pointerX: number; from: number; lastX: number; lastAt: number; velocity: number } | null>(null);
      const settle = useRef<number>(0);
    
      const layout = useCallback(() => {
        const count = ring.length;
        if (!count) return;
    
        const position = state.current.position;
    
        cardRefs.current.forEach((node, index) => {
          if (!node) return;
    
          // Wrap into the nearest representation, so a card that falls off one
          // side reappears on the other while it is still invisible.
          let d = (((index - position) % count) + count) % count;
          if (d > count / 2) d -= count;
    
          const away = Math.abs(d);
    
          gsap.set(node, {
            x: offsetAt(d),
            rotateY: -Math.sign(d) * turnAt(away),
            scale: scaleAt(away),
            opacity: clamp(1 - (away - EDGE - 0.4) / 0.8, 0, 1),
            zIndex: Math.round(100 - away * 10),
          });
    
          const caption = captionRefs.current[index];
          if (caption) {
            gsap.set(caption, { opacity: clamp(1 - (away - 1.3) / 0.9, 0, 1) });
          }
        });
      }, [ring.length]);
    
      useEffect(() => {
        gsap.set(cardRefs.current.filter(Boolean), {
          xPercent: -50,
          yPercent: -50,
        });
        layout();
      }, [layout]);
    
      const glideTo = useCallback(
        (target: number, duration = 0.9) => {
          gsap.killTweensOf(state.current);
          gsap.to(state.current, {
            position: target,
            duration,
            ease: "power3.out",
            onUpdate: layout,
          });
        },
        [layout],
      );
    
      const onPointerDown = useCallback((event: PointerEvent<HTMLDivElement>) => {
        gsap.killTweensOf(state.current);
        event.currentTarget.setPointerCapture(event.pointerId);
        drag.current = {
          pointerX: event.clientX,
          from: state.current.position,
          lastX: event.clientX,
          lastAt: performance.now(),
          velocity: 0,
        };
      }, []);
    
      const onPointerMove = useCallback(
        (event: PointerEvent<HTMLDivElement>) => {
          const held = drag.current;
          if (!held) return;
    
          const now = performance.now();
          const elapsed = Math.max(1, now - held.lastAt);
          held.velocity = (event.clientX - held.lastX) / elapsed;
          held.lastX = event.clientX;
          held.lastAt = now;
    
          state.current.position = held.from - (event.clientX - held.pointerX) / STRIDE;
          layout();
        },
        [layout],
      );
    
      const onPointerUp = useCallback(
        (event: PointerEvent<HTMLDivElement>) => {
          const held = drag.current;
          event.currentTarget.releasePointerCapture(event.pointerId);
          drag.current = null;
          if (!held) return;
    
          // Carry the throw, then settle on whichever card is nearest the centre.
          const thrown = state.current.position - held.velocity * 3.2;
          glideTo(Math.round(thrown));
        },
        [glideTo],
      );
    
      const onWheel = useCallback(
        (event: WheelEvent<HTMLDivElement>) => {
          // Trackpads report horizontal intent on deltaX; ignore vertical so the
          // page can still be scrolled through the carousel.
          if (Math.abs(event.deltaX) < 1) return;
    
          gsap.killTweensOf(state.current);
          state.current.position += event.deltaX / STRIDE;
          layout();
    
          window.clearTimeout(settle.current);
          settle.current = window.setTimeout(
            () => glideTo(Math.round(state.current.position), 0.6),
            140,
          );
        },
        [glideTo, layout],
      );
    
      return (
        <section className={cn("overflow-hidden bg-canvas", className)}>
          <div className="mx-auto max-w-3xl px-6 pt-20 text-center sm:pt-28">
            <p className="text-sm font-semibold tracking-tight" style={{ color: accent }}>
              {eyebrow}
            </p>
    
            <h2 className="mt-4 text-4xl leading-[1.08] font-bold tracking-tight text-balance sm:text-5xl">
              {title}
            </h2>
    
            <p className="mx-auto mt-5 max-w-md text-sm text-muted text-pretty">
              {description}
            </p>
    
            {cta ? (
              <a
                href={cta.href}
                className="group mt-7 inline-flex items-center gap-3 text-sm font-medium"
              >
                {cta.label}
                <span
                  className="grid size-8 place-items-center rounded-full text-white transition-transform duration-300 group-hover:translate-x-1"
                  style={{ backgroundColor: accent }}
                >
                  <svg
                    className="size-3.5"
                    viewBox="0 0 24 24"
                    fill="none"
                    stroke="currentColor"
                    strokeWidth={2.4}
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    aria-hidden="true"
                  >
                    <path d="M5 12h13" />
                    <path d="m12 5 7 7-7 7" />
                  </svg>
                </span>
              </a>
            ) : null}
          </div>
    
          <div
            role="group"
            aria-label="Project carousel"
            tabIndex={0}
            onPointerDown={onPointerDown}
            onPointerMove={onPointerMove}
            onPointerUp={onPointerUp}
            onPointerCancel={onPointerUp}
            onWheel={onWheel}
            onKeyDown={(event) => {
              if (event.key === "ArrowLeft") glideTo(Math.round(state.current.position) - 1, 0.6);
              if (event.key === "ArrowRight") glideTo(Math.round(state.current.position) + 1, 0.6);
            }}
            className="relative mt-14 h-[540px] cursor-grab touch-pan-y select-none active:cursor-grabbing focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-accent"
            style={{ perspective: 1200 }}
          >
            {ring.map((slide, index) => (
              <figure
                key={index}
                ref={(node) => {
                  cardRefs.current[index] = node;
                }}
                // A fixed height, with the caption taken out of flow, so every card
                // is centred on its image regardless of whether it is labelled.
                style={{ width: CARD.width, height: CARD.height, top: "44%", left: "50%" }}
                className="absolute opacity-0"
              >
                <div className="size-full overflow-hidden rounded-2xl bg-elevated shadow-[0_30px_60px_-32px_rgb(0_0_0/0.5)]">
                  <img
                    src={slide.image}
                    alt={slide.label ?? ""}
                    draggable={false}
                    className="size-full object-cover"
                  />
                </div>
    
                {slide.label ? (
                  <figcaption
                    ref={(node) => {
                      captionRefs.current[index] = node;
                    }}
                    className="absolute inset-x-0 top-full mt-5 text-center"
                  >
                    <span className="font-mono text-[11px] text-faint">→{slide.number}</span>
                    <p className="mt-1 text-[13px] font-medium">{slide.label}</p>
                  </figcaption>
                ) : null}
              </figure>
            ))}
          </div>
        </section>
      );
    }

Props

PropTypeDefaultDescription
slidesArcSlide[]Each entry needs an `image`, plus an optional `number` and `label` for its caption.
eyebrowstring"Behind the Designs"Accent line above the title.
titlestringThe hero headline.
descriptionstringSupporting line under the headline.
cta{ label: string; href: string }Call to action under the copy. Omit to hide it.
accentstring"#f26722"Colour of the eyebrow and the call to action.