Section
Sketchboard
A cream board scattered with doodles, where wide image-and-text panels travel a fixed diagonal — in from the bottom right, onto centre stage, then out through the top left.
01 — Discovery
Start with the awkward questions
Before a single pixel, we work out what the thing is actually for and who it has to convince.
02 — Direction
Pick a direction and commit
One route, argued for properly, beats three safe ones presented side by side.
03 — Build
Make it real early
Interfaces get judged in a browser, not a canvas, so that is where we take them.
04 — Handover
Leave something maintainable
Tokens, components, and notes that mean the next person does not start over.
Scroll inside the frame
import { useRef } from "react";
import { Sketchboard, type BoardPanel } from "@/components/ui/sketchboard";
/** Boxed in the docs; on its own page the section takes the viewport. */
const SCREEN = 420;
const panels: BoardPanel[] = [
{
image: "https://picsum.photos/seed/board-1/700/700",
eyebrow: "01 — Discovery",
title: "Start with the awkward questions",
description:
"Before a single pixel, we work out what the thing is actually for and who it has to convince.",
},
{
image: "https://picsum.photos/seed/board-2/700/700",
eyebrow: "02 — Direction",
title: "Pick a direction and commit",
description:
"One route, argued for properly, beats three safe ones presented side by side.",
},
{
image: "https://picsum.photos/seed/board-3/700/700",
eyebrow: "03 — Build",
title: "Make it real early",
description:
"Interfaces get judged in a browser, not a canvas, so that is where we take them.",
},
{
image: "https://picsum.photos/seed/board-4/700/700",
eyebrow: "04 — Handover",
title: "Leave something maintainable",
description:
"Tokens, components, and notes that mean the next person does not start over.",
},
];
export default function SketchboardDemo({ fullscreen }: { fullscreen?: boolean }) {
const scroller = useRef<HTMLDivElement>(null);
if (fullscreen) {
return <Sketchboard panels={panels} 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 }}
>
<Sketchboard panels={panels} 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/sketchboard.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 BoardPanel = { image: string; /** Small line above the title — a number, a category. */ eyebrow?: string; title: string; description: string; }; type SketchboardProps = { panels: BoardPanel[]; /** 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; }; /** Each panel is two square halves — image, then copy — with air between. */ const HALF = 500; const GAP = 72; const PANEL = { width: HALF * 2 + GAP, height: HALF }; /** Share of the screen a panel is allowed to take. */ const FILL_X = 0.8; const FILL_Y = 0.6; /** * How far along the diagonal a panel travels per step, as a share of the * distance that would carry it fully off screen. Under 1 so the next panel * always shows a corner before its turn. */ const REACH = 0.86; /** Slope of the diagonal: a step right for every step down. */ const SLOPE = 0.94; /** 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 Sketchboard({ panels, screen = "100vh", container, className, }: SketchboardProps) { const rootRef = useRef<HTMLDivElement>(null); const trackRef = useRef<HTMLDivElement>(null); const stageRef = useRef<HTMLDivElement>(null); const panelRefs = useRef<(HTMLElement | null)[]>([]); const [stage, setStage] = useState({ width: 0, height: 0 }); useEffect(() => { const node = stageRef.current; if (!node) return; const observer = new ResizeObserver(([entry]) => { const { width, height } = entry!.contentRect; setStage({ width, height }); }); observer.observe(node); return () => observer.disconnect(); }, []); // 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. useEffect(() => { if (!stage.width || !stage.height) return; gsap.registerPlugin(ScrollTrigger); const scroller = container?.current ?? findScroller(trackRef.current); const nodes = panelRefs.current; const context = gsap.context(() => { // The panel grows with the screen rather than sitting inside a fixed // artboard, so a wide display is actually used. const scale = Math.min( (stage.width * FILL_X) / PANEL.width, (stage.height * FILL_Y) / PANEL.height, ); const width = PANEL.width * scale; const height = PANEL.height * scale; const centre = { x: (stage.width - width) / 2, y: (stage.height - height) / 2, }; // Far enough that a panel clears centre stage with room to spare, but // short of the distance that would hide it completely. const travelY = ((stage.height + height) / 2) * REACH; const travel = { x: travelY * SLOPE, y: travelY }; // Panel `i` is centre stage at timeline position `i`, which makes its // whole journey a single straight line at a constant rate. One linear // tween end to end — no chained segments to stutter between. const run = Math.max(1, panels.length - 1); const timeline = gsap.timeline({ scrollTrigger: { trigger: trackRef.current, scroller: scroller ?? undefined, start: "top top", end: "bottom bottom", // A little catch-up smooths the steps a wheel arrives in. scrub: 0.8, }, }); panels.forEach((_, index) => { const node = nodes[index]; if (!node) return; gsap.set(node, { // Anchored top left, so position maths stays independent of scale. transformOrigin: "0 0", scale, x: centre.x + index * travel.x, y: centre.y + index * travel.y, force3D: true, }); timeline.to( node, { x: centre.x + (index - run) * travel.x, y: centre.y + (index - run) * travel.y, duration: run, ease: "none", }, 0, ); }); ScrollTrigger.refresh(); }, rootRef); return () => context.revert(); }, [panels, container, stage.width, stage.height]); return ( <div ref={rootRef} className={cn("relative", className)}> <div ref={trackRef} style={{ height: `calc(${screen} * ${panels.length})` }} > <div ref={stageRef} className="sticky top-0 overflow-hidden bg-[#f4efe3] text-[#2c2a24]" style={{ height: screen }} > <Doodles /> {panels.map((panel, index) => ( <article key={panel.image} ref={(node) => { panelRefs.current[index] = node; }} style={{ width: PANEL.width, height: PANEL.height, gap: GAP }} className="absolute top-0 left-0 flex opacity-0 will-change-transform data-[ready]:opacity-100" data-ready={stage.width ? "" : undefined} > <div className="shrink-0 overflow-hidden rounded-[26px] bg-[#e8e1d1] shadow-[0_40px_80px_-48px_rgb(60_50_30/0.55)]" style={{ width: HALF, height: HALF }} > <img src={panel.image} alt={panel.title} draggable={false} className="size-full object-cover" /> </div> {/* No surface of its own — the board reads straight through. */} <div className="flex shrink-0 flex-col justify-center pr-10" style={{ width: HALF, height: HALF }} > {panel.eyebrow ? ( <p className="font-mono text-[13px] tracking-[0.14em] text-[#9a8f76] uppercase"> {panel.eyebrow} </p> ) : null} <h3 className="mt-4 text-[40px] leading-[1.08] font-semibold tracking-tight text-balance"> {panel.title} </h3> <p className="mt-5 text-[17px] leading-relaxed text-[#5d564a]"> {panel.description} </p> </div> </article> ))} </div> </div> </div> ); } /** Marginalia scattered across the whole screen, not a fixed artboard. */ function Doodles() { const stroke = { stroke: "currentColor", strokeWidth: 3, strokeLinecap: "round" as const, strokeLinejoin: "round" as const, fill: "none", }; const marks = [ { left: "4%", top: "7%", w: 150, h: 110, box: "0 0 150 110", d: ["M6 96C22 40 58 8 92 24c22 10 14 44-8 40-18-3-16-30 8-36 30-8 44 22 46 46", "M124 62l14 18 12-20"] }, { left: "47%", top: "5%", w: 70, h: 70, box: "0 0 70 70", d: ["M35 4c3 18 13 28 31 31-18 3-28 13-31 31-3-18-13-28-31-31 18-3 28-13 31-31Z"] }, { left: "88%", top: "9%", w: 110, h: 110, box: "0 0 110 110", d: ["M55 55c0-9 8-14 16-11 11 4 13 20 4 30-12 13-34 11-45-4C17 52 22 25 43 13c25-14 56-4 68 22"] }, { left: "3%", top: "45%", w: 120, h: 60, box: "0 0 120 60", d: ["M4 22c14-20 26 20 40 0s26 20 40 0 26 20 32 8", "M4 48c14-20 26 20 40 0s26 20 40 0"] }, { left: "91%", top: "46%", w: 100, h: 100, box: "0 0 100 100", d: ["M50 8v22M50 70v22M8 50h22M70 50h22M20 20l16 16M64 64l16 16M80 20L64 36M36 64 20 80"] }, { left: "6%", top: "78%", w: 140, h: 120, box: "0 0 140 120", d: ["M70 12c34 0 58 22 58 46s-24 46-58 46S12 82 12 58 34 16 66 14", "M70 26c26 0 44 16 44 32s-18 32-44 32-44-16-44-32 16-30 40-31"] }, { left: "38%", top: "88%", w: 220, h: 40, box: "0 0 220 40", d: ["M6 22c40-14 84-14 124-2s58 12 84 2"] }, { left: "86%", top: "80%", w: 110, h: 90, box: "0 0 110 90", d: ["M8 8c34 4 62 26 76 62", "M62 66l24 8-2-26"] }, ]; return ( <div aria-hidden className="pointer-events-none absolute inset-0 opacity-25"> {marks.map((mark, index) => ( <svg key={index} width={mark.w} height={mark.h} viewBox={mark.box} className="absolute" style={{ left: mark.left, top: mark.top }} > {mark.d.map((path) => ( <path key={path} d={path} {...stroke} /> ))} </svg> ))} </div> ); }
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| panels | BoardPanel[] | — | Each entry needs an `image`, `title` and `description`, plus an optional `eyebrow`. |
| 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. |