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.
WhatWeDo01 02 03
A short line about the work, sitting in the middle of the screen until the cards arrive and bury it.
Interface engineering
Design systems and component libraries that survive contact with a real product team.
- —Design tokens
- —Component APIs
- —Accessibility
Motion design
Scroll-driven sections, page transitions, and the small interactions that make a build feel finished.
- —GSAP & ScrollTrigger
- —Page transitions
- —Micro-interactions
Performance
Shipping less JavaScript, rendering ahead of time, and keeping the main thread free.
- —Islands architecture
- —Core Web Vitals
- —Bundle budgets
Scroll inside the frame
import { useRef } from "react";
import { CardAscent, type AscentCard } from "@/components/ui/card-ascent";
/** Boxed in the docs; on its own page the section takes the viewport. */
const SCREEN = 420;
const cards: AscentCard[] = [
{
title: "Interface engineering",
description:
"Design systems and component libraries that survive contact with a real product team.",
points: ["Design tokens", "Component APIs", "Accessibility"],
},
{
title: "Motion design",
description:
"Scroll-driven sections, page transitions, and the small interactions that make a build feel finished.",
points: ["GSAP & ScrollTrigger", "Page transitions", "Micro-interactions"],
},
{
title: "Performance",
description:
"Shipping less JavaScript, rendering ahead of time, and keeping the main thread free.",
points: ["Islands architecture", "Core Web Vitals", "Bundle budgets"],
},
];
export default function CardAscentDemo({ fullscreen }: { fullscreen?: boolean }) {
const scroller = useRef<HTMLDivElement>(null);
if (fullscreen) {
return <CardAscent cards={cards} screen="100dvh" />;
}
return (
<div className="w-full space-y-3">
<div
ref={scroller}
className="scrollbar-thin w-full min-w-0 overflow-y-auto overscroll-contain rounded-card border border-line"
style={{ height: SCREEN }}
>
<CardAscent cards={cards} screen={`${SCREEN}px`} container={scroller} />
</div>
<p className="text-center text-xs text-faint">Scroll inside the frame</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/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
| Prop | Type | Default | Description |
|---|---|---|---|
| cards | AscentCard[] | — | 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. |
| intro | string | — | Sits in the middle of the screen until the cards cover it. |
| screen | string | "100vh" | Height of one pinned screen. Match the scroll container when it is not the page. |
| container | RefObject<HTMLElement | null> | — | The scrollable ancestor, when the section lives inside one instead of the page. |