"use client"; import { useId, useRef, useState, type KeyboardEvent, type ReactNode } from "react"; import { motion, useAnimationFrame, useInView, useReducedMotion } from "motion/react"; type Ribbon = { label: string; title: string; /** What the panel says. */ body: ReactNode; /** Ribbon colour. */ color?: string; }; type RibbonTabsProps = { /** One satin ribbon per tab, 2 to 5. Keep labels short, they run down the ribbon. */ tabs: Ribbon[]; /** Index of the tab open at the start. */ start?: number; /** Name read out by screen readers. */ label?: string; className?: string; }; const SATIN = ["#a3202e", "#1f5f8b", "#c98a12", "#2d7a55", "#6a2f86"]; const REST = [168, 136, 186, 150, 176]; const FULL = 286; const PITCH = 46; const WIDTH = 36; const STEPS = 40; const shape = (cx: number, length: number, amp: number, t: number, seed: number) => { const wave = (y: number) => y * 0.05 - t * 2.3 + seed; const left: string[] = []; const right: string[] = []; const line: string[] = []; const edges: { l: number; r: number; y: number; light: number }[] = []; for (let k = 0; k <= STEPS; k++) { const y = (length * k) / STEPS; const shift = amp * Math.pow(y / length, 1.2) * Math.sin(wave(y)); const half = (WIDTH / 2) * (0.87 + 0.13 * Math.cos(wave(y))); edges.push({ l: cx + shift - half, r: cx + shift + half, y, light: Math.cos(wave(y)) }); left.push(`${(cx + shift - half).toFixed(1)} ${y.toFixed(1)}`); right.unshift(`${(cx + shift + half).toFixed(1)} ${y.toFixed(1)}`); line.push(`${k ? "L" : "M"}${(cx + shift).toFixed(1)} ${y.toFixed(1)}`); } const tip = edges[STEPS]; const base = `M${left.join("L")}L${((tip.l + tip.r) / 2).toFixed(1)} ${(length - 17).toFixed(1)}L${right.join("L")}Z`; // Four soft layers: two of shade, two of sheen. const folds = ["", "", "", ""]; for (let k = 0; k < STEPS; k++) { const a = edges[k]; const b = edges[k + 1]; const quad = `M${a.l.toFixed(1)} ${a.y.toFixed(1)}L${a.r.toFixed(1)} ${a.y.toFixed(1)}L${b.r.toFixed(1)} ${b.y.toFixed(1)}L${b.l.toFixed(1)} ${b.y.toFixed(1)}Z`; if (b.y > length - 17) break; if (a.light < -0.1) folds[0] += quad; if (a.light < -0.65) folds[1] += quad; if (a.light > 0.45) folds[2] += quad; if (a.light > 0.85) folds[3] += quad; } return { base, folds, line: line.join("") }; }; /* RibbonTabs: satin ribbons hang from a brass rail and move in a slow draught. Press one: it falls to full length with a flutter and the sheet beside it rolls down. */ export function RibbonTabs({ tabs, start = 0, label = "Sections", className = "" }: RibbonTabsProps) { const reduce = useReducedMotion(); const uid = useId().replace(/[^a-z0-9]/gi, ""); const stage = useRef(null); const seen = useInView(stage); const parts = useRef<(SVGPathElement | null)[][]>([]); const buttons = useRef<(HTMLButtonElement | null)[]>([]); const flow = useRef(tabs.map((_, i) => ({ amp: 2, length: i === start ? FULL : REST[i % REST.length] }))); const near = useRef(-1); const pick = useRef(start); const [active, setActive] = useState(start); const count = tabs.length; const wide = count * PITCH + 8; const middle = (i: number) => 4 + PITCH * (i + 0.5); useAnimationFrame((t, delta) => { if (!seen) return; const ease = (rate: number) => 1 - Math.exp(-rate * Math.min(delta, 50) * 0.001); flow.current.forEach((f, i) => { const goalAmp = reduce ? 0 : near.current === i ? 9 : 2.4; const goalLength = pick.current === i ? FULL : REST[i % REST.length]; f.amp = reduce ? 0 : f.amp + (goalAmp - f.amp) * ease(2.4); f.length = reduce ? goalLength : f.length + (goalLength - f.length) * ease(7); const p = shape(middle(i), f.length, f.amp, reduce ? 0 : t * 0.001, i * 1.7); const set = parts.current[i]; set?.[0]?.setAttribute("d", p.base); p.folds.forEach((d, k) => set?.[k + 1]?.setAttribute("d", d)); set?.[5]?.setAttribute("d", p.line); }); }); const choose = (i: number) => { pick.current = i; const f = flow.current; f[i].amp = 15; if (f[i - 1]) f[i - 1].amp = Math.max(f[i - 1].amp, 7); if (f[i + 1]) f[i + 1].amp = Math.max(f[i + 1].amp, 7); setActive(i); }; const key = (e: KeyboardEvent, i: number) => { const next = e.key === "ArrowRight" ? (i + 1) % count : e.key === "ArrowLeft" ? (i - 1 + count) % count : e.key === "Home" ? 0 : e.key === "End" ? count - 1 : -1; if (next < 0) return; e.preventDefault(); choose(next); buttons.current[next]?.focus(); }; const paint = (i: number) => tabs[i].color ?? SATIN[i % SATIN.length]; const shown = tabs[active]; return (
{tabs.map((t, i) => { const p = shape(middle(i), i === start ? FULL : REST[i % REST.length], 2, 0, i * 1.7); const keep = (k: number) => (el: SVGPathElement | null) => { (parts.current[i] ??= [])[k] = el; }; return ( {p.folds.map((d, k) => ( ))} {t.label} ); })}
{tabs.map((t, i) => { const on = i === active; return ( ); })}

{shown.title}

{shown.body}
); }