Website Sections
ChangelogWall
Every release is a poster pasted over the last one on a brick wall. The newest has one corner come loose; pull it, or press the button, and it rips away along a rough edge to show the release under it, leaving a scrap behind.
Download
- React
- Tailwind CSS v4
- motion
components/ui/changelog-wall.tsx
"use client";
import { useEffect, useId, useRef, useState, type PointerEvent } from "react";
import { useReducedMotion } from "motion/react";
type ReleaseNote = { kind: "new" | "fix" | "up"; text: string };
type Release = { version: string; date: string; title: string; notes: ReleaseNote[] };
type ChangelogWallProps = {
/** Newest first. Each one is a poster on the wall. */
releases?: Release[];
/** Called with the release now on top. */
onChange?: (release: Release, index: number) => void;
/** Set the width here. Everything scales with it. */
className?: string;
};
const RELEASES: Release[] = [
{
version: "4.2",
date: "2 Nov 2026",
title: "Offline mode",
notes: [
{ kind: "new", text: "Works with no signal" },
{ kind: "up", text: "Search twice as fast" },
{ kind: "fix", text: "Double invoices are gone" },
],
},
{
version: "4.1",
date: "9 Sep 2026",
title: "Team seats",
notes: [
{ kind: "new", text: "Invite your whole team" },
{ kind: "new", text: "Roles for each member" },
{ kind: "fix", text: "CSV dates read right" },
],
},
{
version: "4.0",
date: "1 Jul 2026",
title: "All new look",
notes: [
{ kind: "new", text: "Dark theme" },
{ kind: "up", text: "Pages load in half the time" },
{ kind: "up", text: "Tamil and Hindi menus" },
],
},
{
version: "3.9",
date: "12 Apr 2026",
title: "Reports",
notes: [
{ kind: "new", text: "Monthly report by mail" },
{ kind: "fix", text: "Totals round the right way" },
],
},
{
version: "3.8",
date: "3 Feb 2026",
title: "First print",
notes: [
{ kind: "new", text: "Billing, stock and customers" },
{ kind: "new", text: "UPI payment links" },
],
},
];
const PAPERS = [
{ bg: "#f2b632", ink: "#1b1a18", hot: "#c8321f" },
{ bg: "#d8402a", ink: "#fbf1dc", hot: "#1b1a18" },
{ bg: "#14756e", ink: "#fbf1dc", hot: "#f2b632" },
{ bg: "#f0e4c8", ink: "#1b1a18", hot: "#d8402a" },
{ bg: "#27306b", ink: "#fbf1dc", hot: "#f09aa8" },
];
const TAGS = { new: "NEW", fix: "FIX", up: "UP" };
const TILT = [-1.4, 1.1, -0.7, 1.6, -1.1];
// In cqw, measured from the bottom right corner: u leftwards, v upwards.
const PW = 84;
const PH = 54;
const REST = 9; // the corner that has come loose by itself
const END = PW + PH - 7; // what stays on the wall after a tear
const STOP = 2.6;
const rip = (s: number, c: number) => (Math.sin(s * 1.7) * 1.1 + Math.sin(s * 4.3 + 1) * 0.7) * Math.min(1, c / 9);
const at = ([u, v]: number[], pad = 0) => `${(PW - u + pad).toFixed(2)}cqw ${(PH - v + pad).toFixed(2)}cqw`;
/** The two shapes for a tear c long: what stays pasted, and the torn paper folded back over it. */
function tear(c: number) {
const a = c <= PW ? [c, 0] : [PW, c - PW];
const b = c <= PH ? [0, c] : [c - PH, PH];
const line = [a];
// Sampled on a fixed grid so the rough edge stays put as the tear moves.
for (let s = Math.floor((a[0] - a[1]) / STOP) * STOP; s > b[0] - b[1]; s -= STOP) {
const j = rip(s, c);
line.push([(c + s) / 2 + j, (c - s) / 2 + j]);
}
line.push(b);
const keep = [...(c < PW ? [[PW, 0]] : []), [PW, PH], ...(c < PH ? [[0, PH]] : [])];
const gone = [...(c > PH ? [[0, PH]] : []), [0, 0], ...(c > PW ? [[PW, 0]] : [])];
const back = [...line, ...gone].map(([u, v]) => [c - v, c - u]);
return {
keep: `polygon(${[...line.slice().reverse(), ...keep].map((p) => at(p)).join(",")})`,
flap: `polygon(${back.map((p) => at(p, 100)).join(",")})`,
fade: Math.max(0, Math.min(1, (END - c) / (END * 0.3))),
};
}
function Poster({ release, paper }: { release: Release; paper: (typeof PAPERS)[number] }) {
return (
<div className="absolute inset-0 flex gap-[3cqw] px-[4cqw] py-[3.6cqw]" style={{ background: paper.bg, color: paper.ink }}>
<div className="flex w-[30cqw] shrink-0 flex-col justify-between">
<span className="font-mono text-[2.6cqw] font-bold tracking-[0.2em] uppercase">Release</span>
<span className="block leading-[0.86]" style={{ fontFamily: "Impact, Haettenschweiler, 'Arial Narrow', sans-serif", fontSize: "17.5cqw", color: paper.hot, letterSpacing: "-0.01em" }}>
{release.version}
</span>
<span className="font-mono text-[2.9cqw] font-semibold">{release.date}</span>
</div>
<div className="min-w-0 flex-1 border-l-[0.5cqw] pl-[3cqw]" style={{ borderColor: paper.ink }}>
<p className="m-0 text-[5.2cqw] leading-[1.08] font-extrabold uppercase" style={{ fontFamily: "'Arial Narrow', 'Roboto Condensed', Arial, sans-serif" }}>
{release.title}
</p>
<ul className="m-0 mt-[1.5cqw] list-none p-0">
{release.notes.map((note) => (
<li key={note.text} className="flex items-baseline gap-[1.2cqw] text-[3.1cqw] leading-[1.18] font-semibold" style={{ marginTop: "1.3cqw" }}>
<span className="shrink-0 rounded-[0.5cqw] px-[0.8cqw] py-[0.1cqw] font-mono text-[2.3cqw] font-bold" style={{ background: paper.ink, color: paper.bg }}>
{TAGS[note.kind]}
</span>
<span>{note.text}</span>
</li>
))}
</ul>
</div>
{/* Wheat paste: slack paper dries in soft ridges. */}
<div
className="pointer-events-none absolute inset-0"
style={{ background: "linear-gradient(97deg, transparent 12%, #ffffff1f 14%, transparent 17%, transparent 52%, #00000017 54%, transparent 57%, transparent 80%, #ffffff17 82%, transparent 85%), linear-gradient(176deg, transparent 38%, #0000000f 40%, transparent 43%)" }}
/>
</div>
);
}
/* ChangelogWall: every release is a poster pasted over the last one on a brick wall. The newest has one corner come loose; pull it, or press Older, and it rips away along a rough edge to show the release under it, leaving a scrap behind. */
export function ChangelogWall({ releases = RELEASES, onChange, className = "w-full max-w-2xl" }: ChangelogWallProps) {
const reduce = useReducedMotion();
const id = useId().replace(/[^a-zA-Z0-9]/g, "");
const wall = useRef<HTMLDivElement>(null);
const raf = useRef(0);
const busy = useRef(false);
const drag = useRef<{ x: number; y: number; moved: boolean; c: number } | null>(null);
const [top, setTop] = useState(0);
const last = releases.length - 1;
useEffect(() => () => cancelAnimationFrame(raf.current), []);
const paint = (i: number, c: number) => {
const root = wall.current;
if (!root) return;
const t = tear(c);
const keep = root.querySelector<HTMLElement>(`[data-poster="${i}"]`);
const flap = root.querySelector<HTMLElement>(`[data-flap="${i}"]`);
if (keep) keep.style.clipPath = t.keep;
if (flap) {
flap.style.clipPath = t.flap;
flap.style.opacity = String(t.fade);
}
};
const run = (i: number, from: number, to: number, ms: number, done?: () => void) => {
cancelAnimationFrame(raf.current);
const begun = performance.now();
const tick = (now: number) => {
const p = ms ? Math.min(1, (now - begun) / ms) : 1;
paint(i, from + (to - from) * (p * p * (3 - 2 * p)));
if (p < 1) raf.current = requestAnimationFrame(tick);
else done?.();
};
raf.current = requestAnimationFrame(tick);
};
const show = (i: number) => {
busy.current = false;
setTop(i);
const r = releases[i];
if (r) onChange?.(r, i);
};
const older = (from = REST) => {
if (busy.current || top >= last) return;
busy.current = true;
run(top, from, END, reduce ? 0 : 620, () => show(top + 1));
};
const newer = () => {
if (busy.current || top <= 0) return;
busy.current = true;
const i = top - 1;
run(i, END, REST, reduce ? 0 : 480, () => show(i));
};
const down = (e: PointerEvent<HTMLElement>) => {
if (busy.current) return;
e.currentTarget.setPointerCapture(e.pointerId);
drag.current = { x: e.clientX, y: e.clientY, moved: false, c: REST };
};
const move = (e: PointerEvent<HTMLElement>) => {
const d = drag.current;
const root = wall.current;
if (!d || !root) return;
const pull = d.x - e.clientX + (d.y - e.clientY);
if (Math.abs(pull) > 5) d.moved = true;
d.c = Math.max(REST, Math.min(END, REST + (pull / root.offsetWidth) * 100 * 0.85));
paint(top, d.c);
};
const up = () => {
const d = drag.current;
drag.current = null;
if (!d) return;
if (!d.moved || d.c > REST + 15) {
older(d.c);
} else {
busy.current = true;
run(top, d.c, REST, 240, () => {
busy.current = false;
});
}
};
const brick = `url(#${id}b)`;
const current = releases[top];
return (
<div className={`@container ${className}`}>
<div ref={wall} className="relative overflow-hidden rounded-[1.4cqw] select-none" style={{ paddingTop: "74cqw", background: "#6a3528" }}>
<svg className="absolute inset-0 size-full" aria-hidden>
<defs>
<pattern id={`${id}b`} width="96" height="48" patternUnits="userSpaceOnUse">
<rect width="96" height="48" fill="#3f2019" />
<rect x="1" y="1" width="46" height="22" fill="#8b4733" />
<rect x="49" y="1" width="46" height="22" fill="#7c3d2c" />
<rect x="-23" y="25" width="46" height="22" fill="#7a3b2b" />
<rect x="25" y="25" width="46" height="22" fill="#94503a" />
<rect x="73" y="25" width="46" height="22" fill="#7a3b2b" />
</pattern>
</defs>
<rect width="100%" height="100%" fill={brick} />
</svg>
<div className="absolute inset-0" style={{ background: "radial-gradient(90% 70% at 50% 20%, transparent 40%, #0007), linear-gradient(180deg, #ffffff10, transparent 40%)" }} aria-hidden />
<div className="absolute top-[3.5cqw] left-[8cqw]" style={{ width: `${PW}cqw`, height: `${PH}cqw` }} aria-hidden>
{releases.map((release, i) => {
if (i > top + 2) return null;
const torn = i < top;
const live = i === top && i < last;
const shape = torn ? tear(END) : live ? tear(REST) : null;
return (
<div key={release.version} className="absolute inset-0" style={{ zIndex: releases.length - i, transform: `rotate(${TILT[i % TILT.length]}deg)`, pointerEvents: i === top ? undefined : "none" }}>
<div data-poster={i} className="absolute inset-0" style={{ clipPath: shape?.keep, boxShadow: "0 0.4cqw 1.2cqw #0006" }}>
<Poster release={release} paper={PAPERS[i % PAPERS.length]} />
</div>
{/* Back of the paper, folded over the tear. */}
<div className="pointer-events-none absolute inset-0" style={{ filter: "drop-shadow(-0.4cqw -0.4cqw 0.7cqw #0007)" }}>
<div
data-flap={i}
className="absolute top-[-100cqw] left-[-100cqw] h-[300cqw] w-[300cqw]"
style={{ clipPath: shape?.flap ?? "none", opacity: shape?.fade ?? 0, background: "repeating-linear-gradient(135deg, #e9e1cf 0 1.4cqw, #dcd3bd 1.4cqw 2.2cqw)" }}
/>
</div>
{i === top && i < last && (
<span
onPointerDown={down}
onPointerMove={move}
onPointerUp={up}
onPointerCancel={up}
className="absolute right-0 bottom-0 size-[20cqw] cursor-grab touch-none active:cursor-grabbing"
/>
)}
</div>
);
})}
</div>
<div className="absolute inset-x-[8cqw] bottom-[2.8cqw] flex items-center justify-between gap-[2cqw]" style={{ zIndex: releases.length + 2 }}>
<p className="m-0 rounded-[0.6cqw] px-[2.2cqw] py-[1cqw] font-mono text-[2.9cqw] font-bold" style={{ background: "#efe8d6", color: "#26211b", boxShadow: "0 0.4cqw 0.8cqw #0006" }} role="status">
v{current?.version} · {top + 1} of {releases.length}
</p>
<div className="flex gap-[1.6cqw]">
<button type="button" onClick={newer} disabled={top === 0} className="min-h-6 cursor-pointer rounded-[0.6cqw] px-[2.6cqw] py-[1cqw] font-mono text-[2.9cqw] font-bold disabled:cursor-default disabled:opacity-45" style={{ background: "#efe8d6", color: "#26211b", boxShadow: "0 0.4cqw 0.8cqw #0006" }}>
Newer
</button>
<button type="button" onClick={() => older()} disabled={top === last} className="min-h-6 cursor-pointer rounded-[0.6cqw] px-[2.6cqw] py-[1cqw] font-mono text-[2.9cqw] font-bold disabled:cursor-default disabled:opacity-45" style={{ background: "#f2b632", color: "#1b1a18", boxShadow: "0 0.4cqw 0.8cqw #0006" }}>
Tear for older
</button>
</div>
</div>
</div>
<ol className="sr-only">
{releases.map((r) => (
<li key={r.version}>
Version {r.version}, {r.date}. {r.title}. {r.notes.map((n) => `${TAGS[n.kind]}: ${n.text}`).join(". ")}.
</li>
))}
</ol>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/changelog-wall.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/changelog-wall-demo.tsx
import { ChangelogWall } from "@/components/ui/changelog-wall"; export default function ChangelogWallExample() { return ( <div className="@container-size grid h-full place-items-center overflow-hidden bg-[#1c1412] p-2"> <ChangelogWall className="w-[min(132cqh,96cqw,720px)]" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| releases | { version: string; date: string; title: string; notes: { kind: "new" | "fix" | "up"; text: string }[] }[] | 5 releases | Newest first. Keep to 3 short notes each. |
| onChange | (release: Release, index: number) => void | - | Called with the release now on top. |
| className | string | "w-full max-w-2xl" | Set the width here. Everything scales with it. |