Skip to content
jjswnth/ui

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.

Start with the awkward questions

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.

Pick a direction and commit

02 — Direction

Pick a direction and commit

One route, argued for properly, beats three safe ones presented side by side.

Make it real early

03 — Build

Make it real early

Interfaces get judged in a browser, not a canvas, so that is where we take them.

Leave something maintainable

04 — Handover

Leave something maintainable

Tokens, components, and notes that mean the next person does not start over.

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/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

PropTypeDefaultDescription
panelsBoardPanel[]Each entry needs an `image`, `title` and `description`, plus an optional `eyebrow`.
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.