Cake

Lemon sponge, lemon curd and a sharp glaze.
No dependencies
Code format
React + Tailwind
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>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/tab-cake
Source on GitHub

About this component

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.

Dependencies
None
Difficulty
Advanced
Triggers
click, state change, 3d, spring
Techniques
raf, svg, state machine, spring
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

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.

More tabs

See all
Cookie Cutter
Four desks by the window, booked by the hour.
Elevator
Tench under the pads. Fish it slowly in the evening.
Fishing
A:\PHOTOSBEACH.JPG412KSNOW.JPG506K
Floppy