Skip to content
jjswnth/ui

Section

Card Ascent

A pinned section where numbered cards rise from below the fold one at a time and stack, tilted, over three words of oversized background type.

Scroll inside the frame

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/card-ascent.tsx
    "use client";
    
    import gsap from "gsap";
    import { ScrollTrigger } from "gsap/ScrollTrigger";
    import { useEffect, useRef, useState, type RefObject } from "react";
    import { cn } from "@/lib/utils";
    
    export type AscentCard = {
      title: string;
      description: string;
      /** Listed under the description. */
      points?: string[];
      /** Degrees of tilt. Falls back to a rotating set of angles. */
      tilt?: number;
    };
    
    type CardAscentProps = {
      cards: AscentCard[];
      /** The three background words: top left, middle right, bottom centre. */
      words?: [string, string, string];
      /** Sits in the middle of the screen until the cards cover it. */
      intro?: string;
      /** Height of one pinned screen. The page default is a viewport tall. */
      screen?: string;
      /** The scrollable ancestor, when this lives inside one instead of the page. */
      container?: RefObject<HTMLElement | null>;
      className?: string;
    };
    
    /* Everything below is in the 1440 x 1024 design grid and scaled to fit. */
    const GRID = { width: 1440, height: 1024 };
    const CARD = 500;
    const CARD_AT = { left: (GRID.width - CARD) / 2, top: (GRID.height - CARD) / 2 };
    
    /** Far enough below the grid that a card is fully clipped before it rises. */
    const START_Y = GRID.height - CARD_AT.top + 120;
    
    /** Cycled so no two neighbours share an angle, all within ±10 degrees. */
    const TILTS = [-8, 6, -4, 9, -6, 3];
    
    /**
     * Fast while off screen, then easing hard into stillness as it nears the
     * centre — the whole point of the effect, so it is not configurable.
     */
    const EASE = "expo.out";
    
    /** Nearest scrolling ancestor, or null when the page itself is the scroller. */
    function findScroller(node: HTMLElement | null) {
      let parent = node?.parentElement ?? null;
    
      while (parent && parent !== document.body) {
        const overflowY = getComputedStyle(parent).overflowY;
        if (overflowY === "auto" || overflowY === "scroll") return parent;
        parent = parent.parentElement;
      }
    
      return null;
    }
    
    export function CardAscent({
      cards,
      words = ["What", "We", "Do"],
      intro = "A short line about the work, sitting in the middle of the screen until the cards arrive and bury it.",
      screen = "100vh",
      container,
      className,
    }: CardAscentProps) {
      const rootRef = useRef<HTMLDivElement>(null);
      const trackRef = useRef<HTMLDivElement>(null);
      const stageRef = useRef<HTMLDivElement>(null);
      const cardRefs = useRef<(HTMLElement | null)[]>([]);
    
      const [scale, setScale] = useState<number | null>(null);
    
      // Passive, not layout: an ancestor's ref is not attached yet while a child's
      // layout effect runs, so `container.current` would still be null here and
      // ScrollTrigger would silently bind to the page instead.
      useEffect(() => {
        gsap.registerPlugin(ScrollTrigger);
    
        const scroller = container?.current ?? findScroller(trackRef.current);
        const nodes = cardRefs.current;
    
        const context = gsap.context(() => {
          cards.forEach((card, index) => {
            gsap.set(nodes[index], {
              y: START_Y,
              rotate: card.tilt ?? TILTS[index % TILTS.length]!,
            });
          });
    
          const timeline = gsap.timeline({
            defaults: { duration: 1, ease: EASE },
            scrollTrigger: {
              trigger: trackRef.current,
              scroller: scroller ?? undefined,
              start: "top top",
              end: "bottom bottom",
              scrub: true,
            },
          });
    
          // One card per unit of the timeline, each landing before the next starts.
          cards.forEach((_, index) => {
            if (nodes[index]) timeline.to(nodes[index]!, { y: 0 }, index);
          });
        }, rootRef);
    
        return () => context.revert();
      }, [cards, container]);
    
      useEffect(() => {
        const node = stageRef.current;
        if (!node) return;
    
        const observer = new ResizeObserver(([entry]) => {
          const { width, height } = entry!.contentRect;
          setScale(Math.min(width / GRID.width, height / GRID.height));
          ScrollTrigger.refresh();
        });
    
        observer.observe(node);
        return () => observer.disconnect();
      }, []);
    
      return (
        <div ref={rootRef} className={cn("relative", className)}>
          <div
            ref={trackRef}
            style={{ height: `calc(${screen} * ${cards.length + 1})` }}
          >
            <div
              ref={stageRef}
              className="sticky top-0 overflow-hidden bg-[#0e0e11] text-white"
              style={{ height: screen }}
            >
              {/* Absolute, so a 1440px artboard never forces the layout of the
                  container it is being scaled down to fit inside. Clipping here is
                  what keeps a card hidden below the grid before it rises. */}
              <div
                className="absolute top-1/2 left-1/2 overflow-hidden"
                style={{
                  width: GRID.width,
                  height: GRID.height,
                  transform: `translate(-50%, -50%) scale(${scale ?? 1})`,
                  visibility: scale === null ? "hidden" : "visible",
                }}
              >
                <BackgroundWords words={words} />
    
                <p
                  className="absolute text-center text-[19px] leading-relaxed text-white/45"
                  style={{
                    left: (GRID.width - 560) / 2,
                    top: GRID.height / 2 - 40,
                    width: 560,
                  }}
                >
                  {intro}
                </p>
    
                {cards.map((card, index) => (
                  <article
                    key={card.title}
                    ref={(node) => {
                      cardRefs.current[index] = node;
                    }}
                    style={{
                      left: CARD_AT.left,
                      top: CARD_AT.top,
                      width: CARD,
                      height: CARD,
                      zIndex: 10 + index,
                    }}
                    className="absolute flex flex-col rounded-[28px] bg-white p-10 text-[#111114] shadow-[0_40px_90px_-40px_rgb(0_0_0/0.85)]"
                  >
                    <span className="font-mono text-[15px] tabular-nums text-[#9a9aa2]">
                      {String(index + 1).padStart(2, "0")}
                    </span>
    
                    <h3 className="mt-6 text-[38px] leading-[1.05] font-semibold tracking-tight text-balance">
                      {card.title}
                    </h3>
    
                    <p className="mt-4 text-[17px] leading-relaxed text-[#55555e]">
                      {card.description}
                    </p>
    
                    {card.points ? (
                      <ul className="mt-auto space-y-2 border-t border-[#e6e6ea] pt-5 text-[15px] text-[#55555e]">
                        {card.points.map((point) => (
                          <li key={point} className="flex gap-3">
                            <span className="text-[#b6b6bf]">—</span>
                            {point}
                          </li>
                        ))}
                      </ul>
                    ) : null}
                  </article>
                ))}
              </div>
            </div>
          </div>
        </div>
      );
    }
    
    function BackgroundWords({ words }: { words: [string, string, string] }) {
      const base =
        "absolute font-[family-name:var(--font-display,var(--font-sans))] text-[210px] leading-[0.82] tracking-[-0.02em] text-white/10 uppercase";
    
      return (
        <>
          <span className={base} style={{ left: 56, top: 28 }}>
            {words[0]}
          </span>
          <span
            className={cn(base, "text-right")}
            style={{ right: 56, top: GRID.height / 2 - 105 }}
          >
            {words[1]}
          </span>
          <span
            className={cn(base, "w-full text-center")}
            style={{ left: 0, bottom: 28 }}
          >
            {words[2]}
          </span>
        </>
      );
    }

Props

PropTypeDefaultDescription
cardsAscentCard[]Each entry needs a `title` and `description`, plus optional `points` and a `tilt` override.
words[string, string, string]["What", "We", "Do"]Background words: top left, middle right, bottom centre.
introstringSits in the middle of the screen until the cards cover it.
screenstring"100vh"Height of one pinned screen. Match the scroll container when it is not the page.
containerRefObject<HTMLElement | null>The scrollable ancestor, when the section lives inside one instead of the page.