Makes 24 from one sheet. Bake 9 minutes at 180°C.
Makes 18. Ice them once they have cooled.
Makes 30. Dust with sugar while still warm.
Makes 16. Thread a ribbon through before baking.
import { useEffect, useId, useRef, useState } from 'react';
const FLAVOURS = [
{ label: 'Lemon', body: 'Lemon sponge, lemon curd and a sharp glaze.' },
{ label: 'Cocoa', body: 'Dark chocolate sponge with a salted ganache.' },
{ label: 'Berry', body: 'Vanilla sponge, raspberry jam and fresh berries.' },
{ label: 'Coffee', body: 'Espresso sponge with a coffee buttercream.' },
{ label: 'Hazelnut', body: 'Brown butter sponge and toasted hazelnut praline.' },
];
const N = FLAVOURS.length;
const CX = 115;
const CY = 52;
const RX = 74;
const RY = 26;
const PULL = 16; // how far the slice comes out toward you
// The layers down the side, from the top: icing, sponge, jam, sponge, base.
const LAYERS = [
{ k: 'icing', t: 0, b: 7 },
{ k: 'sponge', t: 7, b: 17 },
{ k: 'jam', t: 17, b: 20 },
{ k: 'sponge', t: 20, b: 31 },
{ k: 'base', t: 31, b: 34 },
];
const rad = (deg: number) => (deg * Math.PI) / 180;
const f = (n: number) => n.toFixed(2);
const pt = (deg: number, dy = 0, r = 1) => `${f(CX + RX * r * Math.cos(rad(deg)))} ${f(CY + RY * r * Math.sin(rad(deg)) + dy)}`;
// A strip of the side between two angles (front half only) and two depths.
const band = (a: number, b: number, t: number, bot: number) => `M${pt(a, t)}A${RX} ${RY} 0 0 1 ${pt(b, t)}L${pt(b, bot)}A${RX} ${RY} 0 0 0 ${pt(a, bot)}Z`;
// A cut face: from the centre of the cake out to the rim at one angle.
const face = (a: number, t: number, bot: number) => `M${CX} ${f(CY + t)}L${pt(a, t)}L${pt(a, bot)}L${CX} ${f(CY + bot)}Z`;
const LO = 90 - 180 / N;
const HI = 90 + 180 / N;
const SHAPES = {
whole: { top: `M${pt(0)}A${RX} ${RY} 0 1 1 ${pt(180)}A${RX} ${RY} 0 1 1 ${pt(0)}Z`, side: LAYERS.map((l) => band(0, 180, l.t, l.b)) },
back: { top: `M${CX} ${CY}L${pt(LO)}A${RX} ${RY} 0 1 0 ${pt(HI)}Z`, side: LAYERS.flatMap((l) => [band(0, LO, l.t, l.b), band(HI, 180, l.t, l.b)]) },
slice: { top: `M${CX} ${CY}L${pt(LO)}A${RX} ${RY} 0 0 1 ${pt(HI)}Z`, side: LAYERS.map((l) => band(LO, HI, l.t, l.b)) },
faces: LAYERS.flatMap((l) => [face(LO, l.t, l.b), face(HI, l.t, l.b)]),
};
// What sits on each slice, drawn around 0,0.
const TOPPINGS = [
<g key="lemon" className="tabs-cake__lemon">
<circle r="7" />
<circle className="tabs-cake__flesh" r="5.6" />
<path d="M0-5.6V5.6M-4.8-2.8L4.8 2.8M-4.8 2.8L4.8-2.8" />
</g>,
<g key="cocoa" className="tabs-cake__cocoa">
<rect x="-8" y="-4" width="11" height="3.6" rx="1.8" transform="rotate(-18)" />
<rect x="-3" y="1" width="11" height="3.6" rx="1.8" transform="rotate(12)" />
</g>,
<g key="berry" className="tabs-cake__berry">
<circle cx="-3.5" cy="1" r="3.2" />
<circle cx="3.2" cy="1.6" r="3.2" />
<circle cx="0" cy="-3" r="3.2" />
</g>,
<g key="coffee" className="tabs-cake__bean">
<ellipse cx="-3.5" cy="0" rx="3.4" ry="2.4" transform="rotate(-20 -3.5 0)" />
<ellipse cx="3.8" cy="0.5" rx="3.4" ry="2.4" transform="rotate(25 3.8 0.5)" />
<path d="M-6 0.8Q-3.5-1-1 0M1.4 0.4Q3.8-1 6.2 1.2" />
</g>,
<g key="nut" className="tabs-cake__nut">
<circle cx="-3" cy="1" r="3.6" />
<circle cx="4" cy="0" r="3.6" />
<path d="M-6.6 0Q-3-3.6 0.6 0M0.4-1Q4-4.6 7.6-1" />
</g>,
];
const BEADS = Array.from({ length: 30 }, (_, j) => 6 + 12 * j);
const css = `
.tabs-cake { --icing: light-dark(#fbfcfd, #e5e6e9); --sponge: light-dark(#dbdde1, #7d8087); --jam: light-dark(#50535b, #2e3035); width: 230px; font-family: Georgia, 'Times New Roman', serif; }
.tabs-cake__stage { display: block; overflow: visible; }
.tabs-cake__plate { fill: light-dark(#ebedf0, #232529); stroke: light-dark(#cbced3, #393c41); stroke-width: 1; }
.tabs-cake__foot { fill: light-dark(#dbdde1, #2e3035); }
.tabs-cake__top { fill: var(--icing); stroke: light-dark(light-dark(#dbdde1, #2e3035), transparent); stroke-width: 0.6; }
.tabs-cake__layer[data-k='icing'] { fill: var(--icing); }
.tabs-cake__layer[data-k='sponge'] { fill: var(--sponge); }
.tabs-cake__layer[data-k='jam'] { fill: var(--jam); }
.tabs-cake__layer[data-k='base'] { fill: light-dark(#dbdde1, #2e3035); }
.tabs-cake__stop { stop-color: light-dark(#373a40, #121417); }
.tabs-cake__face { filter: brightness(1.06); }
.tabs-cake__face[data-side='l'] { filter: brightness(0.85); }
.tabs-cake__cut { stroke: color-mix(in oklab, light-dark(#6d7179, #7d8087) 35%, transparent); stroke-width: 0.7; }
.tabs-cake__bead { fill: var(--icing); stroke: light-dark(light-dark(#dbdde1, #2e3035), color-mix(in oklab, light-dark(#6d7179, #7d8087) 60%, transparent)); stroke-width: 0.5; }
.tabs-cake__lemon circle { fill: light-dark(#5f626a, #7d8087); }
.tabs-cake__lemon .tabs-cake__flesh { fill: light-dark(#ebedf0, #a7abb2); }
.tabs-cake__lemon path { stroke: light-dark(#5f626a, #7d8087); stroke-width: 0.6; }
.tabs-cake__cocoa rect { fill: light-dark(#42454c, #232529); stroke: light-dark(#5f626a, #393c41); stroke-width: 0.5; }
.tabs-cake__berry circle { fill: light-dark(#50535b, #2e3035); stroke: light-dark(#373a40, #1a1c1f); stroke-width: 0.5; }
.tabs-cake__bean ellipse { fill: light-dark(#50535b, #2e3035); }
.tabs-cake__bean path, .tabs-cake__nut path { fill: none; stroke: light-dark(#373a40, #1a1c1f); stroke-width: 0.7; }
.tabs-cake__nut circle { fill: light-dark(#5f626a, #7d8087); }
.tabs-cake__tabs { display: flex; justify-content: space-between; margin-top: 8px; }
.tabs-cake__tab { padding: 4px 2px; border: 0; background: none; color: light-dark(#5f626a, #a7abb2); font: inherit; font-size: 12px; font-style: italic; cursor: pointer; transition: color 0.2s; }
.tabs-cake__tab:hover { color: light-dark(#42454c, #e5e6e9); }
.tabs-cake__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-cake__tab:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 1px; border-radius: 3px; }
.tabs-cake__panel { min-height: 3em; margin-top: 4px; color: light-dark(#50535b, #c6c8cd); font-size: 12.5px; line-height: 1.5; }
`;
const ease = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
const back = (t: number) => 1 + 2.4 * Math.pow(t - 1, 3) + 1.4 * Math.pow(t - 1, 2);
export function Preview() {
const id = useId();
const [sel, setSel] = useState(0);
const [view, setView] = useState({ rot: 0, d: 1 });
const st = useRef({ rot: 0, d: 1, raf: 0 });
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
// Push the slice back in, turn the cake the short way round, pull the new slice out.
useEffect(() => {
const s = st.current;
cancelAnimationFrame(s.raf);
const target = sel + N * Math.round((s.rot - sel) / N);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
Object.assign(s, { rot: sel, d: 1 });
setView({ rot: sel, d: 1 });
return;
}
if (s.rot === target && s.d === 1) return;
const d0 = s.d;
const r0 = s.rot;
const inMs = Math.abs(r0 - target) > 0.001 ? 280 * d0 : 0;
const turnMs = Math.abs(r0 - target) > 0.001 ? 300 + 220 * Math.abs(target - r0) : 0;
const outMs = 560;
const t0 = performance.now();
const step = (now: number) => {
const t = now - t0;
if (t < inMs) s.d = d0 * (1 - ease(t / inMs));
else if (t < inMs + turnMs) {
s.d = 0;
s.rot = r0 + (target - r0) * ease((t - inMs) / turnMs);
} else {
s.rot = target;
const k = Math.min(1, (t - inMs - turnMs) / outMs);
const startD = inMs || turnMs ? 0 : d0;
s.d = startD + (1 - startD) * back(k);
}
setView({ rot: s.rot, d: s.d });
if (t < inMs + turnMs + outMs) s.raf = requestAnimationFrame(step);
else {
s.rot = sel;
s.d = 1;
setView({ rot: sel, d: 1 });
}
};
s.raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(s.raf);
}, [sel]);
const onKeyDown = (e: React.KeyboardEvent) => {
const next = { ArrowRight: (sel + 1) % N, ArrowLeft: (sel + N - 1) % N, Home: 0, End: N - 1 }[e.key];
if (next === undefined) return;
e.preventDefault();
setSel(next);
tabs.current[next]?.focus();
};
const { rot, d } = view;
const pull = PULL * d;
const out = d > 0.02;
const front = ((Math.round(rot) % N) + N) % N;
// Screen angle of a point fixed to the cake, from its angle round the cake.
const screen = (phi: number) => 90 + phi - 180 / N - (360 / N) * rot;
const items = [
...FLAVOURS.map((_, k) => {
const a = screen((360 / N) * k + 180 / N);
return { kind: 't' as const, id: `t${k}`, k, a, y: Math.sin(rad(a)) };
}),
...BEADS.map((phi) => {
const a = screen(phi);
return { kind: 'b' as const, id: `b${phi}`, k: Math.floor(phi / (360 / N)), a, y: Math.sin(rad(a)) };
}),
].sort((p, q) => p.y - q.y);
const shape = out ? SHAPES.back : SHAPES.whole;
return (
<>
<style href="kit-tabs-cake" precedence="kit">
{css}
</style>
<div className="tabs-cake">
<svg className="tabs-cake__stage" width="230" height="134" viewBox="0 0 230 134" aria-hidden="true">
<defs>
<linearGradient id={`${id}-shade`} x1="0" x2="1">
<stop className="tabs-cake__stop" offset="0" stopOpacity="0.3" />
<stop className="tabs-cake__stop" offset="0.35" stopOpacity="0" />
<stop className="tabs-cake__stop" offset="0.7" stopOpacity="0.05" />
<stop className="tabs-cake__stop" offset="1" stopOpacity="0.34" />
</linearGradient>
</defs>
<path className="tabs-cake__foot" d="M100 118H130L136 132H94Z" />
<ellipse className="tabs-cake__plate" cx={CX} cy={CY + 40} rx="96" ry="30" />
<path className="tabs-cake__top" d={shape.top} />
{shape.side.map((p, i) => (
<path key={i} className="tabs-cake__layer" data-k={LAYERS[out ? Math.floor(i / 2) : i].k} d={p} />
))}
<path fill={`url(#${id}-shade)`} d={`M${pt(0)}A${RX} ${RY} 0 0 1 ${pt(180)}L${pt(180, 34)}A${RX} ${RY} 0 0 0 ${pt(0, 34)}Z`} />
{out &&
SHAPES.faces.map((p, i) => (
<path key={i} className="tabs-cake__layer tabs-cake__face" data-side={i % 2 ? 'l' : 'r'} data-k={LAYERS[Math.floor(i / 2)].k} d={p} />
))}
{Array.from({ length: N }, (_, k) => {
const a = screen((360 / N) * k);
return <line key={k} className="tabs-cake__cut" x1={CX} y1={CY} x2={CX + RX * Math.cos(rad(a))} y2={CY + RY * Math.sin(rad(a))} />;
})}
{out && (
<g transform={`translate(0 ${f(pull)})`}>
<path className="tabs-cake__top" d={SHAPES.slice.top} />
{SHAPES.slice.side.map((p, i) => (
<path key={i} className="tabs-cake__layer" data-k={LAYERS[i].k} d={p} />
))}
<path fill={`url(#${id}-shade)`} d={`M${pt(LO)}A${RX} ${RY} 0 0 1 ${pt(HI)}L${pt(HI, 34)}A${RX} ${RY} 0 0 0 ${pt(LO, 34)}Z`} />
</g>
)}
{items.map((it) => {
const lift = out && it.k === front ? pull : 0;
if (it.kind === 'b') return <circle key={it.id} className="tabs-cake__bead" cx={CX + RX * 0.93 * Math.cos(rad(it.a))} cy={CY + RY * 0.93 * Math.sin(rad(it.a)) + lift} r={2.6 + 0.5 * it.y} />;
const s = 0.82 + 0.18 * it.y;
return (
<g key={it.id} transform={`translate(${f(CX + RX * 0.56 * Math.cos(rad(it.a)))} ${f(CY + RY * 0.56 * Math.sin(rad(it.a)) + lift)}) scale(${f(s)} ${f(s * 0.75)})`}>
{TOPPINGS[it.k]}
</g>
);
})}
</svg>
<div className="tabs-cake__tabs" role="tablist" aria-label="Flavours" onKeyDown={onKeyDown}>
{FLAVOURS.map((fl, i) => (
<button
key={fl.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-cake__tab"
type="button"
role="tab"
id={`${id}-tab-${i}`}
aria-selected={sel === i}
aria-controls={`${id}-panel-${i}`}
tabIndex={sel === i ? 0 : -1}
onClick={() => setSel(i)}
>
{fl.label}
</button>
))}
</div>
{FLAVOURS.map((fl, i) => (
<div key={fl.label} className="tabs-cake__panel" role="tabpanel" id={`${id}-panel-${i}`} aria-labelledby={`${id}-tab-${i}`} hidden={sel !== i}>
{fl.body}
</div>
))}
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initTabsCake(el) on the mounted element instead.
<div class="tabs-cake">
<svg class="tabs-cake__stage" width="230" height="134" viewBox="0 0 230 134" aria-hidden="true">
<defs>
<linearGradient id="tabs-cake-shade" x1="0" x2="1">
<stop class="tabs-cake__stop" offset="0" stop-opacity="0.3"></stop>
<stop class="tabs-cake__stop" offset="0.35" stop-opacity="0"></stop>
<stop class="tabs-cake__stop" offset="0.7" stop-opacity="0.05"></stop>
<stop class="tabs-cake__stop" offset="1" stop-opacity="0.34"></stop>
</linearGradient>
</defs>
<path class="tabs-cake__foot" d="M100 118H130L136 132H94Z"></path>
<ellipse class="tabs-cake__plate" cx="115" cy="92" rx="96" ry="30"></ellipse>
<path class="tabs-cake__top" d="M115 52L158.50 73.03A74 26 0 1 0 71.50 73.03Z"></path>
<path class="tabs-cake__layer" data-k="icing" d="M189.00 52.00A74 26 0 0 1 158.50 73.03L158.50 80.03A74 26 0 0 0 189.00 59.00Z"></path>
<path class="tabs-cake__layer" data-k="icing" d="M71.50 73.03A74 26 0 0 1 41.00 52.00L41.00 59.00A74 26 0 0 0 71.50 80.03Z"></path>
<path class="tabs-cake__layer" data-k="sponge" d="M189.00 59.00A74 26 0 0 1 158.50 80.03L158.50 90.03A74 26 0 0 0 189.00 69.00Z"></path>
<path class="tabs-cake__layer" data-k="sponge" d="M71.50 80.03A74 26 0 0 1 41.00 59.00L41.00 69.00A74 26 0 0 0 71.50 90.03Z"></path>
<path class="tabs-cake__layer" data-k="jam" d="M189.00 69.00A74 26 0 0 1 158.50 90.03L158.50 93.03A74 26 0 0 0 189.00 72.00Z"></path>
<path class="tabs-cake__layer" data-k="jam" d="M71.50 90.03A74 26 0 0 1 41.00 69.00L41.00 72.00A74 26 0 0 0 71.50 93.03Z"></path>
<path class="tabs-cake__layer" data-k="sponge" d="M189.00 72.00A74 26 0 0 1 158.50 93.03L158.50 104.03A74 26 0 0 0 189.00 83.00Z"></path>
<path class="tabs-cake__layer" data-k="sponge" d="M71.50 93.03A74 26 0 0 1 41.00 72.00L41.00 83.00A74 26 0 0 0 71.50 104.03Z"></path>
<path class="tabs-cake__layer" data-k="base" d="M189.00 83.00A74 26 0 0 1 158.50 104.03L158.50 107.03A74 26 0 0 0 189.00 86.00Z"></path>
<path class="tabs-cake__layer" data-k="base" d="M71.50 104.03A74 26 0 0 1 41.00 83.00L41.00 86.00A74 26 0 0 0 71.50 107.03Z"></path>
<path fill="url(#tabs-cake-shade)" d="M189.00 52.00A74 26 0 0 1 41.00 52.00L41.00 86.00A74 26 0 0 0 189.00 86.00Z"></path>
<path class="tabs-cake__layer tabs-cake__face" data-side="r" data-k="icing" d="M115 52.00L158.50 73.03L158.50 80.03L115 59.00Z"></path>
<path class="tabs-cake__layer tabs-cake__face" data-side="l" data-k="icing" d="M115 52.00L71.50 73.03L71.50 80.03L115 59.00Z"></path>
<path class="tabs-cake__layer tabs-cake__face" data-side="r" data-k="sponge" d="M115 59.00L158.50 80.03L158.50 90.03L115 69.00Z"></path>
<path class="tabs-cake__layer tabs-cake__face" data-side="l" data-k="sponge" d="M115 59.00L71.50 80.03L71.50 90.03L115 69.00Z"></path>
<path class="tabs-cake__layer tabs-cake__face" data-side="r" data-k="jam" d="M115 69.00L158.50 90.03L158.50 93.03L115 72.00Z"></path>
<path class="tabs-cake__layer tabs-cake__face" data-side="l" data-k="jam" d="M115 69.00L71.50 90.03L71.50 93.03L115 72.00Z"></path>
<path class="tabs-cake__layer tabs-cake__face" data-side="r" data-k="sponge" d="M115 72.00L158.50 93.03L158.50 104.03L115 83.00Z"></path>
<path class="tabs-cake__layer tabs-cake__face" data-side="l" data-k="sponge" d="M115 72.00L71.50 93.03L71.50 104.03L115 83.00Z"></path>
<path class="tabs-cake__layer tabs-cake__face" data-side="r" data-k="base" d="M115 83.00L158.50 104.03L158.50 107.03L115 86.00Z"></path>
<path class="tabs-cake__layer tabs-cake__face" data-side="l" data-k="base" d="M115 83.00L71.50 104.03L71.50 107.03L115 86.00Z"></path>
<line class="tabs-cake__cut" x1="115" y1="52" x2="158.496108669643" y2="73.03444185374863"></line>
<line class="tabs-cake__cut" x1="115" y1="52" x2="71.50389133035699" y2="73.03444185374863"></line>
<line class="tabs-cake__cut" x1="115" y1="52" x2="44.621817794158645" y2="43.96555814625136"></line>
<line class="tabs-cake__cut" x1="115" y1="52" x2="114.99999999999999" y2="26"></line>
<line class="tabs-cake__cut" x1="115" y1="52" x2="185.37818220584137" y2="43.96555814625136"></line>
<g transform="translate(0 16.00)">
<path class="tabs-cake__top" d="M115 52L158.50 73.03A74 26 0 0 1 71.50 73.03Z"></path>
<path class="tabs-cake__layer" data-k="icing" d="M158.50 73.03A74 26 0 0 1 71.50 73.03L71.50 80.03A74 26 0 0 0 158.50 80.03Z"></path>
<path class="tabs-cake__layer" data-k="sponge" d="M158.50 80.03A74 26 0 0 1 71.50 80.03L71.50 90.03A74 26 0 0 0 158.50 90.03Z"></path>
<path class="tabs-cake__layer" data-k="jam" d="M158.50 90.03A74 26 0 0 1 71.50 90.03L71.50 93.03A74 26 0 0 0 158.50 93.03Z"></path>
<path class="tabs-cake__layer" data-k="sponge" d="M158.50 93.03A74 26 0 0 1 71.50 93.03L71.50 104.03A74 26 0 0 0 158.50 104.03Z"></path>
<path class="tabs-cake__layer" data-k="base" d="M158.50 104.03A74 26 0 0 1 71.50 104.03L71.50 107.03A74 26 0 0 0 158.50 107.03Z"></path>
<path fill="url(#tabs-cake-shade)" d="M158.50 73.03A74 26 0 0 1 71.50 73.03L71.50 107.03A74 26 0 0 0 158.50 107.03Z"></path>
</g>
<circle class="tabs-cake__bead" cx="107.80635115792009" cy="27.95246056999515" r="2.1027390523158633"></circle>
<circle class="tabs-cake__bead" cx="122.19364884207988" cy="27.95246056999515" r="2.1027390523158633"></circle>
<circle class="tabs-cake__bead" cx="136.26654955288387" cy="29.003453435983186" r="2.1244717418524233"></circle>
<circle class="tabs-cake__bead" cx="93.7334504471161" cy="29.00345343598319" r="2.1244717418524233"></circle>
<circle class="tabs-cake__bead" cx="149.41000000000003" cy="31.059505736492277" r="2.166987298107781"></circle>
<circle class="tabs-cake__bead" cx="80.58999999999997" cy="31.059505736492277" r="2.166987298107781"></circle>
<g transform="translate(139.36 40.22) scale(0.67 0.51)">
<g class="tabs-cake__bean">
<ellipse cx="-3.5" cy="0" rx="3.4" ry="2.4" transform="rotate(-20 -3.5 0)"></ellipse>
<ellipse cx="3.8" cy="0.5" rx="3.4" ry="2.4" transform="rotate(25 3.8 0.5)"></ellipse>
<path d="M-6 0.8Q-3.5-1-1 0M1.4 0.4Q3.8-1 6.2 1.2"></path>
</g>
</g>
<g transform="translate(90.64 40.22) scale(0.67 0.51)">
<g class="tabs-cake__berry">
<circle cx="-3.5" cy="1" r="3.2"></circle>
<circle cx="3.2" cy="1.6" r="3.2"></circle>
<circle cx="0" cy="-3" r="3.2"></circle>
</g>
</g>
<circle class="tabs-cake__bead" cx="161.0495683296166" cy="34.0307581199566" r="2.2284275872613026"></circle>
<circle class="tabs-cake__bead" cx="68.95043167038335" cy="34.030758119956616" r="2.228427587261303"></circle>
<circle class="tabs-cake__bead" cx="170.6765495528839" cy="37.787352599567996" r="2.3061073738537634"></circle>
<circle class="tabs-cake__bead" cx="59.32345044711611" cy="37.787352599568" r="2.3061073738537634"></circle>
<circle class="tabs-cake__bead" cx="177.8701983949638" cy="42.16510797042715" r="2.3966316784621"></circle>
<circle class="tabs-cake__bead" cx="52.129801605036185" cy="42.16510797042716" r="2.3966316784621"></circle>
<circle class="tabs-cake__bead" cx="182.31611788250052" cy="46.97269531602657" r="2.4960441545911203"></circle>
<circle class="tabs-cake__bead" cx="47.68388211749948" cy="46.97269531602659" r="2.4960441545911207"></circle>
<circle class="tabs-cake__bead" cx="183.82" cy="51.99999999999999" r="2.6"></circle>
<circle class="tabs-cake__bead" cx="46.17999999999999" cy="52" r="2.6"></circle>
<circle class="tabs-cake__bead" cx="182.31611788250052" cy="57.0273046839734" r="2.7039558454088795"></circle>
<circle class="tabs-cake__bead" cx="47.68388211749948" cy="57.02730468397342" r="2.70395584540888"></circle>
<g transform="translate(154.41 56.50) scale(0.88 0.66)">
<g class="tabs-cake__nut">
<circle cx="-3" cy="1" r="3.6"></circle>
<circle cx="4" cy="0" r="3.6"></circle>
<path d="M-6.6 0Q-3-3.6 0.6 0M0.4-1Q4-4.6 7.6-1"></path>
</g>
</g>
<g transform="translate(75.59 56.50) scale(0.88 0.66)">
<g class="tabs-cake__cocoa">
<rect x="-8" y="-4" width="11" height="3.6" rx="1.8" transform="rotate(-18)"></rect>
<rect x="-3" y="1" width="11" height="3.6" rx="1.8" transform="rotate(12)"></rect>
</g>
</g>
<circle class="tabs-cake__bead" cx="177.8701983949638" cy="61.834892029572835" r="2.8033683215379"></circle>
<circle class="tabs-cake__bead" cx="52.12980160503621" cy="61.834892029572856" r="2.8033683215379"></circle>
<circle class="tabs-cake__bead" cx="59.323450447116116" cy="66.212647400432" r="2.8938926261462368"></circle>
<circle class="tabs-cake__bead" cx="170.67654955288387" cy="66.21264740043202" r="2.8938926261462368"></circle>
<circle class="tabs-cake__bead" cx="161.04956832961668" cy="69.96924188004338" r="2.971572412738697"></circle>
<circle class="tabs-cake__bead" cx="68.95043167038338" cy="69.9692418800434" r="2.971572412738697"></circle>
<circle class="tabs-cake__bead" cx="149.41000000000003" cy="88.94049426350773" r="3.0330127018922193"></circle>
<circle class="tabs-cake__bead" cx="80.59" cy="88.94049426350773" r="3.0330127018922193"></circle>
<circle class="tabs-cake__bead" cx="136.26654955288387" cy="90.99654656401681" r="3.075528258147577"></circle>
<circle class="tabs-cake__bead" cx="93.73345044711613" cy="90.99654656401681" r="3.075528258147577"></circle>
<circle class="tabs-cake__bead" cx="122.19364884207991" cy="92.04753943000485" r="3.097260947684137"></circle>
<circle class="tabs-cake__bead" cx="107.80635115792009" cy="92.04753943000485" r="3.097260947684137"></circle>
<g transform="translate(115.00 82.56) scale(1.00 0.75)">
<g class="tabs-cake__lemon">
<circle r="7"></circle>
<circle class="tabs-cake__flesh" r="5.6"></circle>
<path d="M0-5.6V5.6M-4.8-2.8L4.8 2.8M-4.8 2.8L4.8-2.8"></path>
</g>
</g>
</svg>
<div class="tabs-cake__tabs" role="tablist" aria-label="Flavours">
<button class="tabs-cake__tab" type="button" role="tab" id="tabs-cake-tab-0" aria-selected="true" aria-controls="tabs-cake-panel-0" tabindex="0">Lemon</button>
<button class="tabs-cake__tab" type="button" role="tab" id="tabs-cake-tab-1" aria-selected="false" aria-controls="tabs-cake-panel-1" tabindex="-1">Cocoa</button>
<button class="tabs-cake__tab" type="button" role="tab" id="tabs-cake-tab-2" aria-selected="false" aria-controls="tabs-cake-panel-2" tabindex="-1">Berry</button>
<button class="tabs-cake__tab" type="button" role="tab" id="tabs-cake-tab-3" aria-selected="false" aria-controls="tabs-cake-panel-3" tabindex="-1">Coffee</button>
<button class="tabs-cake__tab" type="button" role="tab" id="tabs-cake-tab-4" aria-selected="false" aria-controls="tabs-cake-panel-4" tabindex="-1">Hazelnut</button>
</div>
<div class="tabs-cake__panel" role="tabpanel" id="tabs-cake-panel-0" aria-labelledby="tabs-cake-tab-0">Lemon sponge, lemon curd and a sharp glaze.</div>
<div class="tabs-cake__panel" role="tabpanel" id="tabs-cake-panel-1" aria-labelledby="tabs-cake-tab-1" hidden="">Dark chocolate sponge with a salted ganache.</div>
<div class="tabs-cake__panel" role="tabpanel" id="tabs-cake-panel-2" aria-labelledby="tabs-cake-tab-2" hidden="">Vanilla sponge, raspberry jam and fresh berries.</div>
<div class="tabs-cake__panel" role="tabpanel" id="tabs-cake-panel-3" aria-labelledby="tabs-cake-tab-3" hidden="">Espresso sponge with a coffee buttercream.</div>
<div class="tabs-cake__panel" role="tabpanel" id="tabs-cake-panel-4" aria-labelledby="tabs-cake-tab-4" hidden="">Brown butter sponge and toasted hazelnut praline.</div>
</div>.tabs-cake { --icing: light-dark(#fbfcfd, #e5e6e9); --sponge: light-dark(#dbdde1, #7d8087); --jam: light-dark(#50535b, #2e3035); width: 230px; font-family: Georgia, 'Times New Roman', serif; }
.tabs-cake__stage { display: block; overflow: visible; }
.tabs-cake__plate { fill: light-dark(#ebedf0, #232529); stroke: light-dark(#cbced3, #393c41); stroke-width: 1; }
.tabs-cake__foot { fill: light-dark(#dbdde1, #2e3035); }
.tabs-cake__top { fill: var(--icing); stroke: light-dark(light-dark(#dbdde1, #2e3035), transparent); stroke-width: 0.6; }
.tabs-cake__layer[data-k='icing'] { fill: var(--icing); }
.tabs-cake__layer[data-k='sponge'] { fill: var(--sponge); }
.tabs-cake__layer[data-k='jam'] { fill: var(--jam); }
.tabs-cake__layer[data-k='base'] { fill: light-dark(#dbdde1, #2e3035); }
.tabs-cake__stop { stop-color: light-dark(#373a40, #121417); }
.tabs-cake__face { filter: brightness(1.06); }
.tabs-cake__face[data-side='l'] { filter: brightness(0.85); }
.tabs-cake__cut { stroke: color-mix(in oklab, light-dark(#6d7179, #7d8087) 35%, transparent); stroke-width: 0.7; }
.tabs-cake__bead { fill: var(--icing); stroke: light-dark(light-dark(#dbdde1, #2e3035), color-mix(in oklab, light-dark(#6d7179, #7d8087) 60%, transparent)); stroke-width: 0.5; }
.tabs-cake__lemon circle { fill: light-dark(#5f626a, #7d8087); }
.tabs-cake__lemon .tabs-cake__flesh { fill: light-dark(#ebedf0, #a7abb2); }
.tabs-cake__lemon path { stroke: light-dark(#5f626a, #7d8087); stroke-width: 0.6; }
.tabs-cake__cocoa rect { fill: light-dark(#42454c, #232529); stroke: light-dark(#5f626a, #393c41); stroke-width: 0.5; }
.tabs-cake__berry circle { fill: light-dark(#50535b, #2e3035); stroke: light-dark(#373a40, #1a1c1f); stroke-width: 0.5; }
.tabs-cake__bean ellipse { fill: light-dark(#50535b, #2e3035); }
.tabs-cake__bean path, .tabs-cake__nut path { fill: none; stroke: light-dark(#373a40, #1a1c1f); stroke-width: 0.7; }
.tabs-cake__nut circle { fill: light-dark(#5f626a, #7d8087); }
.tabs-cake__tabs { display: flex; justify-content: space-between; margin-top: 8px; }
.tabs-cake__tab { padding: 4px 2px; border: 0; background: none; color: light-dark(#5f626a, #a7abb2); font: inherit; font-size: 12px; font-style: italic; cursor: pointer; transition: color 0.2s; }
.tabs-cake__tab:hover { color: light-dark(#42454c, #e5e6e9); }
.tabs-cake__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-cake__tab:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 1px; border-radius: 3px; }
.tabs-cake__panel { min-height: 3em; margin-top: 4px; color: light-dark(#50535b, #c6c8cd); font-size: 12.5px; line-height: 1.5; }function initTabsCake(root) {
var list = root.querySelector('[role="tablist"]');
var tabs = Array.prototype.slice.call(list.querySelectorAll('[role="tab"]'));
var panels = Array.prototype.slice.call(root.querySelectorAll('[role="tabpanel"]'));
var n = tabs.length;
var sel = tabs.findIndex(function (tab) { return tab.getAttribute('aria-selected') === 'true'; });
var svg = root.querySelector('.tabs-cake__stage');
var CX = 115;
var CY = 52;
var RX = 74;
var RY = 26;
var PULL = 16; // how far the slice comes out toward you
// The layers down the side, from the top: icing, sponge, jam, sponge, base.
var LAYERS = [{ k: 'icing', t: 0, b: 7 }, { k: 'sponge', t: 7, b: 17 }, { k: 'jam', t: 17, b: 20 }, { k: 'sponge', t: 20, b: 31 }, { k: 'base', t: 31, b: 34 }];
var LO = 90 - 180 / n;
var HI = 90 + 180 / n;
function rad(deg) { return (deg * Math.PI) / 180; }
function f(v) { return v.toFixed(2); }
function pt(deg, dy) { return f(CX + RX * Math.cos(rad(deg))) + ' ' + f(CY + RY * Math.sin(rad(deg)) + (dy || 0)); }
function arc(sweep, to) { return 'A' + RX + ' ' + RY + ' 0 0 ' + sweep + ' ' + to; }
// A strip of the side between two angles and two depths.
function band(a, b, t, bot) { return 'M' + pt(a, t) + arc(1, pt(b, t)) + 'L' + pt(b, bot) + arc(0, pt(a, bot)) + 'Z'; }
var WHOLE_TOP = 'M' + pt(0) + 'A' + RX + ' ' + RY + ' 0 1 1 ' + pt(180) + 'A' + RX + ' ' + RY + ' 0 1 1 ' + pt(0) + 'Z';
var BACK_TOP = 'M' + CX + ' ' + CY + 'L' + pt(LO) + 'A' + RX + ' ' + RY + ' 0 1 0 ' + pt(HI) + 'Z';
var top = svg.querySelector(':scope > .tabs-cake__top');
var sides = svg.querySelectorAll(':scope > .tabs-cake__layer:not(.tabs-cake__face)');
var faces = svg.querySelectorAll('.tabs-cake__face');
var cuts = svg.querySelectorAll('.tabs-cake__cut');
var slice = svg.querySelector('g .tabs-cake__top').parentNode;
// Toppings and piped beads are drawn back to front, so they are re-stacked as the cake turns.
var items = ['lemon', 'cocoa', 'berry', 'bean', 'nut'].map(function (name, k) {
return { el: svg.querySelector('.tabs-cake__' + name).parentNode, k: k, phi: (360 / n) * k + 180 / n, bead: false };
});
svg.querySelectorAll('.tabs-cake__bead').forEach(function (el, j) {
var phi = 6 + 12 * j;
items.push({ el: el, k: Math.floor(phi / (360 / n)), phi: phi, bead: true });
});
var s = { rot: sel, d: 1 };
var raf = 0;
function ease(t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }
function back(t) { return 1 + 2.4 * Math.pow(t - 1, 3) + 1.4 * Math.pow(t - 1, 2); }
function draw() {
var out = s.d > 0.02;
var pull = PULL * s.d;
var front = ((Math.round(s.rot) % n) + n) % n;
// Whole while it turns, so no seam shows; a gap with its cut faces when a slice is out.
top.setAttribute('d', out ? BACK_TOP : WHOLE_TOP);
sides.forEach(function (side, i) {
var l = LAYERS[out ? Math.floor(i / 2) : i] || LAYERS[0];
side.setAttribute('data-k', l.k);
if (out) side.setAttribute('d', i % 2 ? band(HI, 180, l.t, l.b) : band(0, LO, l.t, l.b));
else side.setAttribute('d', i < LAYERS.length ? band(0, 180, l.t, l.b) : '');
});
faces.forEach(function (face) { face.style.display = out ? '' : 'none'; });
slice.style.display = out ? '' : 'none';
slice.setAttribute('transform', 'translate(0 ' + f(pull) + ')');
function screen(phi) { return 90 + phi - 180 / n - (360 / n) * s.rot; }
cuts.forEach(function (cut, k) {
var a = rad(screen((360 / n) * k));
cut.setAttribute('x2', CX + RX * Math.cos(a));
cut.setAttribute('y2', CY + RY * Math.sin(a));
});
items.forEach(function (it) {
var a = rad(screen(it.phi));
var lift = out && it.k === front ? pull : 0;
it.y = Math.sin(a);
if (it.bead) {
it.el.setAttribute('cx', CX + RX * 0.93 * Math.cos(a));
it.el.setAttribute('cy', CY + RY * 0.93 * Math.sin(a) + lift);
it.el.setAttribute('r', 2.6 + 0.5 * it.y);
} else {
var z = 0.82 + 0.18 * it.y;
it.el.setAttribute('transform', 'translate(' + f(CX + RX * 0.56 * Math.cos(a)) + ' ' + f(CY + RY * 0.56 * Math.sin(a) + lift) + ') scale(' + f(z) + ' ' + f(z * 0.75) + ')');
}
});
items.slice().sort(function (p, q) { return p.y - q.y; }).forEach(function (it) { svg.appendChild(it.el); });
}
// Push the slice back in, turn the cake the short way round, pull the new slice out.
function update() {
cancelAnimationFrame(raf);
var target = sel + n * Math.round((s.rot - sel) / n);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
s = { rot: sel, d: 1 };
draw();
return;
}
var d0 = s.d;
var r0 = s.rot;
var turns = Math.abs(r0 - target) > 0.001;
var inMs = turns ? 280 * d0 : 0;
var turnMs = turns ? 300 + 220 * Math.abs(target - r0) : 0;
var outMs = 560;
var t0 = performance.now();
function step(now) {
var t = now - t0;
if (t < inMs) s.d = d0 * (1 - ease(t / inMs));
else if (t < inMs + turnMs) {
s.d = 0;
s.rot = r0 + (target - r0) * ease((t - inMs) / turnMs);
} else {
s.rot = target;
var k = Math.min(1, (t - inMs - turnMs) / outMs);
var startD = inMs || turnMs ? 0 : d0;
s.d = startD + (1 - startD) * back(k);
}
draw();
if (t < inMs + turnMs + outMs) raf = requestAnimationFrame(step);
else {
s = { rot: sel, d: 1 };
draw();
}
}
raf = requestAnimationFrame(step);
}
function pick(i) {
if (i === sel) return;
var from = sel;
sel = i;
tabs.forEach(function (tab, k) {
tab.setAttribute('aria-selected', String(k === sel));
tab.tabIndex = k === sel ? 0 : -1;
});
panels.forEach(function (panel, k) { panel.hidden = k !== sel; });
update(from);
}
function onClick(e) {
var i = tabs.indexOf(e.currentTarget);
if (i >= 0) pick(i);
}
function onKey(e) {
var next = { ArrowRight: (sel + 1) % n, ArrowLeft: (sel + n - 1) % n, Home: 0, End: n - 1 }[e.key];
if (next === undefined) return;
e.preventDefault();
pick(next);
tabs[next].focus();
}
tabs.forEach(function (tab) { tab.addEventListener('click', onClick); });
list.addEventListener('keydown', onKey);
return function () {
tabs.forEach(function (tab) { tab.removeEventListener('click', onClick); });
list.removeEventListener('keydown', onKey);
cancelAnimationFrame(raf);
};
}
document.querySelectorAll('.tabs-cake').forEach(initTabsCake);Goal: Create a "Cake" tab.
Visual behavior: A layer cake on a turntable: the slice slides back in, the cake turns to bring the new flavour to the front, and that slice slides out showing its layers.
Technique:
- A real tablist of buttons with roving tabindex and a labelled panel for each tab; everything drawn follows the selection.
- The cake is true geometry in SVG: ellipse arcs for the top and each layer down the side. Whole while it turns, so no seam shows; with a slice out it is a wedge-shaped gap showing the two cut faces, layer by layer.
- One timeline runs three steps: push the slice in, turn the short way round, pull the new slice out with a slight overshoot. Picking again mid-way starts from wherever it is.
- The toppings and piped beads are fixed to the cake, so they travel round the ellipse with the turn, scale with depth and are re-stacked back to front every frame.
Accessibility: Real ARIA tabs: arrow keys, Home and End move between tabs, only the selected tab is in the Tab order, and each panel is labelled by its tab. The drawing is aria-hidden. Under prefers-reduced-motion the cake is at once turned with the new slice out.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Cake" tab.
Visual behavior: A layer cake on a turntable: the slice slides back in, the cake turns to bring the new flavour to the front, and that slice slides out showing its layers.
Technique:
- A real tablist of buttons with roving tabindex and a labelled panel for each tab; everything drawn follows the selection.
- The cake is true geometry in SVG: ellipse arcs for the top and each layer down the side. Whole while it turns, so no seam shows; with a slice out it is a wedge-shaped gap showing the two cut faces, layer by layer.
- One timeline runs three steps: push the slice in, turn the short way round, pull the new slice out with a slight overshoot. Picking again mid-way starts from wherever it is.
- The toppings and piped beads are fixed to the cake, so they travel round the ellipse with the turn, scale with depth and are re-stacked back to front every frame.
Accessibility: Real ARIA tabs: arrow keys, Home and End move between tabs, only the selected tab is in the Tab order, and each panel is labelled by its tab. The drawing is aria-hidden. Under prefers-reduced-motion the cake is at once turned with the new slice out.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
A layer cake on a turntable: the slice slides back in, the cake turns to bring the new flavour to the front, and that slice slides out showing its layers.
Real ARIA tabs: arrow keys, Home and End move between tabs, only the selected tab is in the Tab order, and each panel is labelled by its tab. The drawing is aria-hidden. Under prefers-reduced-motion the cake is at once turned with the new slice out.