Navigation
RibbonTabs
Satin ribbons of different lengths hang from a brass rail, moving in a slow draught, each with its name running down the cloth. Press one: it falls to full length with a flutter and the sheet rolls down.
Download
- React
- Tailwind CSS v4
- motion
components/ui/ribbon-tabs.tsx
"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<SVGSVGElement>(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 (
<div className={`@container w-full max-w-[640px] ${className}`}>
<div className="relative flex aspect-[3/2] overflow-hidden rounded-[0.4em] bg-[#e9e0cd] bg-[radial-gradient(circle_at_70%_20%,#f7f0e0,transparent_70%)] text-[2.9cqw] leading-snug text-[#2b2620] shadow-[inset_0_0_1.6em_rgba(90,60,20,0.25)]">
<div aria-hidden className="absolute inset-x-[1%] top-[0.12em] z-20 h-[0.55em] rounded-full bg-[linear-gradient(#f3d98f,#a37d2e_60%,#65481a)] shadow-[0_0.18em_0.25em_rgba(0,0,0,0.35)]" />
<div className="relative h-full shrink-0 self-start" style={{ aspectRatio: `${wide} / 300` }}>
<svg ref={stage} aria-hidden viewBox={`0 0 ${wide} 300`} className="absolute inset-0 size-full">
{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 (
<g key={t.label}>
<path ref={keep(0)} d={p.base} fill={paint(i)} />
{p.folds.map((d, k) => (
<path key={k} ref={keep(k + 1)} d={d} fill={k < 2 ? "#000" : "#fff"} opacity={k < 2 ? 0.13 : 0.1} />
))}
<path ref={keep(5)} id={`${uid}${i}`} d={p.line} fill="none" />
<text fontSize="15" fontWeight="700" letterSpacing="1.4" fill="#fff6e2" dy="5" className="font-serif">
<textPath href={`#${uid}${i}`} startOffset="26">
{t.label}
</textPath>
</text>
</g>
);
})}
</svg>
<div role="tablist" aria-label={label} className="absolute inset-0">
{tabs.map((t, i) => {
const on = i === active;
return (
<button
key={t.label}
ref={(el) => {
buttons.current[i] = el;
}}
type="button"
role="tab"
id={`${uid}t${i}`}
aria-selected={on}
aria-controls={`${uid}p`}
tabIndex={on ? 0 : -1}
onClick={() => choose(i)}
onKeyDown={(e) => key(e, i)}
onPointerEnter={() => {
near.current = i;
}}
onPointerLeave={() => {
near.current = -1;
}}
style={{
left: `${((middle(i) - PITCH / 2) * 100) / wide}%`,
width: `${(PITCH * 100) / wide}%`,
height: `${((on ? FULL : REST[i % REST.length]) * 100) / 300}%`,
}}
className="absolute top-0 cursor-pointer rounded-b-[0.4em] outline-offset-[-0.15em] outline-[#2b2620] focus-visible:outline-2"
>
<span className="sr-only">{t.label}</span>
</button>
);
})}
</div>
</div>
<div className="relative min-w-0 flex-1 pt-[1.3em] pr-[0.9em] pb-[0.9em] pl-[0.4em]">
<motion.div
key={active}
id={`${uid}p`}
role="tabpanel"
aria-labelledby={`${uid}t${active}`}
initial={reduce ? false : { clipPath: "inset(0 0 100% 0)" }}
animate={{ clipPath: "inset(0 0 0% 0)" }}
transition={{ duration: 0.55, ease: [0.2, 0.8, 0.3, 1] }}
style={{ borderTopColor: paint(active) }}
className="h-full overflow-hidden border-t-[0.4em] bg-[#fdfaf2] p-[0.9em] shadow-[0_0.15em_0.5em_rgba(70,45,10,0.3)]"
>
<p className="font-serif text-[1.6em] font-bold">{shown.title}</p>
<div className="mt-[0.4em] text-[1.05em] opacity-80">{shown.body}</div>
</motion.div>
</div>
</div>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/ribbon-tabs.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/ribbon-tabs-demo.tsx
import { RibbonTabs } from "@/components/ui/ribbon-tabs"; export default function RibbonTabsExample() { return ( <div className="grid h-full place-items-center overflow-hidden bg-[#3a2c22] p-2"> <RibbonTabs tabs={[ { label: "Story", title: "Our story", body: "Three weavers, one small loom and a shop in Kanchipuram." }, { label: "Craft", title: "The craft", body: "Every length takes nine days, by hand, in pure silk." }, { label: "Shop", title: "The shop", body: "Open daily from ten. Orders ship within the week." }, { label: "Visit", title: "Visit us", body: "Come by the loom and try a few passes yourself." }, ]} /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| tabs | { label, title, body, color? }[] | — | One ribbon per tab, 2 to 5. Labels must differ. Keep them short. |
| start | number | 0 | Index of the tab open at the start. |
| label | string | "Sections" | Name read out by screen readers. |
| className | string | "" | Size. Everything inside scales with the width. |