Pottery

A bellied vase with a flared neck, 30 cm tall.
No dependencies
Code format
React + Tailwind
import { useEffect, useId, useRef, useState } from 'react';

// Each form is a height and nine half-widths from the foot up to the lip.
const FORMS = [
  { label: 'Bowl', h: 40, r: [24, 34, 40, 44, 47, 49, 50.5, 51.5, 52], body: 'A wide salad bowl, 26 cm across, from 1.5 kg of clay.' },
  { label: 'Cup', h: 56, r: [21, 23, 24.5, 25.5, 26, 26.5, 27, 27.8, 29], body: 'A straight-sided cup that holds a big coffee.' },
  { label: 'Vase', h: 94, r: [15, 20, 24.5, 26, 24, 19, 14.5, 15.5, 21], body: 'A bellied vase with a flared neck, 30 cm tall.' },
  { label: 'Bottle', h: 92, r: [26, 34, 36, 35, 30, 18, 9.5, 8.5, 10], body: 'A narrow-necked bottle, thrown in one piece.' },
];
const CX = 115;
const FOOT = 118; // where the pot meets the wheel head
const TILT = 0.22; // how round the ellipses look from this height
const f = (n: number) => n.toFixed(1);

type Shape = { h: number; r: number[] };
// Everything that depends on the shape of the clay and the turn of the wheel.
function geometry(s: Shape, turn: number) {
  const K = s.r.length;
  const pts = s.r.map((r, j) => ({ r, y: FOOT - (s.h * j) / (K - 1) }));
  const side = (sign: number) => {
    const p = pts.map((q) => ({ x: CX + sign * q.r, y: q.y }));
    let d = '';
    for (let j = 0; j < p.length - 1; j++) {
      const a = p[Math.max(0, j - 1)];
      const b = p[j];
      const c = p[j + 1];
      const e = p[Math.min(p.length - 1, j + 2)];
      d += `C${f(b.x + (c.x - a.x) / 6)} ${f(b.y + (c.y - a.y) / 6)} ${f(c.x - (e.x - b.x) / 6)} ${f(c.y - (e.y - b.y) / 6)} ${f(c.x)} ${f(c.y)}`;
    }
    return d;
  };
  const top = pts[K - 1];
  const r0 = pts[0].r;
  // The left side, drawn from the lip down, is the right side mirrored and reversed.
  const leftDown = (() => {
    const p = pts.map((q) => ({ x: CX - q.r, y: q.y })).reverse();
    let d = '';
    for (let j = 0; j < p.length - 1; j++) {
      const a = p[Math.max(0, j - 1)];
      const b = p[j];
      const c = p[j + 1];
      const e = p[Math.min(p.length - 1, j + 2)];
      d += `C${f(b.x + (c.x - a.x) / 6)} ${f(b.y + (c.y - a.y) / 6)} ${f(c.x - (e.x - b.x) / 6)} ${f(c.y - (e.y - b.y) / 6)} ${f(c.x)} ${f(c.y)}`;
    }
    return d;
  })();
  const body = `M${f(CX + r0)} ${FOOT}${side(1)}L${f(CX - top.r)} ${f(top.y)}${leftDown}A${f(r0)} ${f(r0 * TILT)} 0 0 0 ${f(CX + r0)} ${FOOT}Z`;
  const rings = [0.12, 0.25, 0.38, 0.51, 0.64, 0.77, 0.88].map((t) => {
    const j = t * (K - 1);
    const lo = Math.floor(j);
    const r = s.r[lo] + (s.r[Math.min(K - 1, lo + 1)] - s.r[lo]) * (j - lo);
    const y = FOOT - s.h * t;
    return `M${f(CX - r)} ${f(y)}A${f(r)} ${f(r * TILT)} 0 0 0 ${f(CX + r)} ${f(y)}`;
  });
  // Throwing marks fixed to the clay: they travel round as the wheel turns.
  const marks = Array.from({ length: 5 }, (_, k) => {
    const a = turn + (k * 2 * Math.PI) / 5;
    const c = Math.cos(a);
    if (c < 0.08) return { d: '', o: 0 };
    return { d: pts.map((q, j) => `${j ? 'L' : 'M'}${f(CX + q.r * Math.sin(a))} ${f(q.y + q.r * TILT * c)}`).join(''), o: c };
  });
  const spokes = Array.from({ length: 8 }, (_, k) => {
    const a = turn + (k * Math.PI) / 4;
    return { x: CX + 54 * Math.cos(a), y: FOOT + 11.5 * Math.sin(a), o: Math.max(0, Math.sin(a)) };
  });
  return { body, rx: top.r, ty: top.y, rings, marks, spokes };
}

const css = `
  .tabs-pottery { --shade: light-dark(#373a40, #121417); --clay: light-dark(#dbdde1, #7d8087); width: 230px; font-family: Georgia, 'Times New Roman', serif; }
  .tabs-pottery__wheel { display: block; overflow: visible; }
  .tabs-pottery__pan { fill: light-dark(#f3f4f7, #1a1c1f); stroke: light-dark(#dbdde1, #2e3035); stroke-width: 3; }
  .tabs-pottery__head { fill: light-dark(#cbced3, #393c41); }
  .tabs-pottery__headtop { fill: light-dark(#6d7179, #7d8087); }
  .tabs-pottery__spoke { fill: light-dark(#50535b, #c6c8cd); }
  .tabs-pottery__s1 { stop-color: color-mix(in oklab, var(--clay) 62%, var(--shade)); }
  .tabs-pottery__s2 { stop-color: color-mix(in oklab, var(--clay) 78%, light-dark(#373a40, #f1f2f3)); }
  .tabs-pottery__s3 { stop-color: color-mix(in oklab, var(--clay) 55%, var(--shade)); }
  .tabs-pottery__ring { fill: none; stroke: color-mix(in oklab, light-dark(#373a40, #f1f2f3) 13%, transparent); stroke-width: 0.9; }
  .tabs-pottery__mark { fill: none; stroke: color-mix(in oklab, var(--shade) 16%, transparent); stroke-width: 1; stroke-linecap: round; }
  .tabs-pottery__lip { fill: color-mix(in oklab, var(--clay) 85%, light-dark(#373a40, #f1f2f3)); }
  .tabs-pottery__hole { fill: color-mix(in oklab, var(--clay) 35%, var(--shade)); }
  .tabs-pottery__tabs { display: flex; justify-content: space-around; margin-top: 6px; }
  .tabs-pottery__tab { padding: 4px 4px; border: 0; background: none; color: light-dark(#5f626a, #a7abb2); font: inherit; font-size: 13px; font-style: italic; cursor: pointer; transition: color 0.2s; }
  .tabs-pottery__tab:hover { color: light-dark(#42454c, #e5e6e9); }
  .tabs-pottery__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
  .tabs-pottery__tab:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 1px; border-radius: 3px; }
  .tabs-pottery__panel { min-height: 3em; margin-top: 4px; color: light-dark(#50535b, #c6c8cd); font-size: 12.5px; line-height: 1.5; }
`;

export function Preview() {
  const id = useId();
  const [sel, setSel] = useState(2);
  const [g, setG] = useState(() => geometry(FORMS[2], 0.4));
  const tabs = useRef<(HTMLButtonElement | null)[]>([]);
  const st = useRef({ h: FORMS[2].h, r: [...FORMS[2].r], vh: 0, vr: FORMS[2].r.map(() => 0), turn: 0.4, w: 0, raf: 0, first: true });

  // The clay chases the new form on springs (the lip softest, so it wobbles last) while the
  // wheel spins up; once the clay has settled the wheel runs down to a stop.
  useEffect(() => {
    const s = st.current;
    if (s.first) {
      s.first = false;
      return;
    }
    cancelAnimationFrame(s.raf);
    const goal = FORMS[sel];
    if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
      Object.assign(s, { h: goal.h, r: [...goal.r], vh: 0, vr: goal.r.map(() => 0), w: 0 });
      setG(geometry(s, s.turn));
      return;
    }
    let last = performance.now();
    const t0 = last;
    const step = (now: number) => {
      const dt = Math.min(0.032, (now - last) / 1000);
      last = now;
      const shaping = now - t0 > 180; // the wheel gets going before the clay moves
      let moving = 0;
      if (shaping) {
        s.vh += (46 * (goal.h - s.h) - 7.5 * s.vh) * dt;
        s.h += s.vh * dt;
        moving = Math.abs(s.vh) + Math.abs(goal.h - s.h);
        s.r.forEach((r, j) => {
          const k = 80 - (40 * j) / (s.r.length - 1);
          s.vr[j] += (k * (goal.r[j] - r) - 7 * s.vr[j]) * dt;
          s.r[j] += s.vr[j] * dt;
          moving += Math.abs(s.vr[j]) + Math.abs(goal.r[j] - s.r[j]);
        });
      }
      const busy = !shaping || moving > 0.4 || now - t0 < 700;
      s.w += ((busy ? 11 : 0) - s.w) * Math.min(1, dt * (busy ? 5 : 1.6));
      s.turn += s.w * dt;
      setG(geometry(s, s.turn));
      if (!busy && s.w < 0.05) {
        Object.assign(s, { h: goal.h, r: [...goal.r], vh: 0, vr: goal.r.map(() => 0), w: 0 });
        setG(geometry(s, s.turn));
        return;
      }
      s.raf = requestAnimationFrame(step);
    };
    s.raf = requestAnimationFrame(step);
    return () => cancelAnimationFrame(s.raf);
  }, [sel]);

  const n = FORMS.length;
  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();
  };
  return (
    <>
      <style href="kit-tabs-pottery" precedence="kit">
        {css}
      </style>
      <div className="tabs-pottery">
        <svg className="tabs-pottery__wheel" width="230" height="146" viewBox="0 0 230 146" aria-hidden="true">
          <defs>
            <linearGradient id={`${id}-clay`} x1="0" x2="1">
              <stop className="tabs-pottery__s1" offset="0" />
              <stop className="tabs-pottery__s2" offset="0.36" />
              <stop className="tabs-pottery__s3" offset="1" />
            </linearGradient>
          </defs>
          <ellipse className="tabs-pottery__pan" cx={CX} cy={FOOT + 6} rx="92" ry="20" />
          <path className="tabs-pottery__head" d={`M${CX - 60} ${FOOT}A60 13 0 0 0 ${CX + 60} ${FOOT}V${FOOT + 6}A60 13 0 0 1 ${CX - 60} ${FOOT + 6}Z`} />
          <ellipse className="tabs-pottery__headtop" cx={CX} cy={FOOT} rx="60" ry="13" />
          {g.spokes.map((p, k) => (
            <circle key={k} className="tabs-pottery__spoke" cx={p.x} cy={p.y} r="1.2" opacity={0.25 + 0.6 * p.o} />
          ))}
          <path d={g.body} fill={`url(#${id}-clay)`} />
          {g.rings.map((d, k) => (
            <path key={k} className="tabs-pottery__ring" d={d} />
          ))}
          {g.marks.map((m, k) => (
            <path key={k} className="tabs-pottery__mark" d={m.d} opacity={m.o} />
          ))}
          <ellipse className="tabs-pottery__lip" cx={CX} cy={g.ty} rx={g.rx} ry={g.rx * TILT} />
          <ellipse className="tabs-pottery__hole" cx={CX} cy={g.ty + 0.6} rx={Math.max(0, g.rx - 2.6)} ry={Math.max(0, g.rx * TILT - 1.2)} />
        </svg>
        <div className="tabs-pottery__tabs" role="tablist" aria-label="Forms" onKeyDown={onKeyDown}>
          {FORMS.map((fm, i) => (
            <button
              key={fm.label}
              ref={(el) => {
                tabs.current[i] = el;
              }}
              className="tabs-pottery__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)}
            >
              {fm.label}
            </button>
          ))}
        </div>
        {FORMS.map((fm, i) => (
          <div key={fm.label} className="tabs-pottery__panel" role="tabpanel" id={`${id}-panel-${i}`} aria-labelledby={`${id}-tab-${i}`} hidden={sel !== i}>
            {fm.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-pottery
Source on GitHub

About this component

A potter's wheel: pick a form and the wheel spins up while the clay rises, swells and narrows into it with real give, then the wheel slows to a stop.

Dependencies
None
Difficulty
Advanced
Triggers
click, state change, morph, spring
Techniques
raf, spring, svg
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 new form appears at once and the wheel stays still.

More tabs

See all
Full on the fifteenth, rising over the east wall at dusk.
Shoji
pH 7.9 and cloudy, with algae and a little silt.
Test Tubes
Drawing 1.2 kW from the grid, on the off-peak rate until seven.
Arc
Grasslands. Eight levels, a hidden pipe in the fourth.
Blocks