Pottery
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>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initTabsPottery(el) on the mounted element instead.
<div class="tabs-pottery">
<svg class="tabs-pottery__wheel" width="230" height="146" viewBox="0 0 230 146" aria-hidden="true">
<defs>
<linearGradient id="tabs-pottery-clay" x1="0" x2="1">
<stop class="tabs-pottery__s1" offset="0"></stop>
<stop class="tabs-pottery__s2" offset="0.36"></stop>
<stop class="tabs-pottery__s3" offset="1"></stop>
</linearGradient>
</defs>
<ellipse class="tabs-pottery__pan" cx="115" cy="124" rx="92" ry="20"></ellipse>
<path class="tabs-pottery__head" d="M55 118A60 13 0 0 0 175 118V124A60 13 0 0 1 55 124Z"></path>
<ellipse class="tabs-pottery__headtop" cx="115" cy="118" rx="60" ry="13"></ellipse>
<circle class="tabs-pottery__spoke" cx="164.7372936761558" cy="122.47831093654948" r="1.2" opacity="0.4836510053851903"></circle>
<circle class="tabs-pottery__spoke" cx="135.3001187057735" cy="128.65646149107343" r="1.2" opacity="0.8059892951864398"></circle>
<circle class="tabs-pottery__spoke" cx="93.97140951533288" cy="128.59220143103317" r="1.2" opacity="0.802636596401731"></circle>
<circle class="tabs-pottery__spoke" cx="64.96096343322043" cy="122.3231734280814" r="1.2" opacity="0.47555687450859474"></circle>
<circle class="tabs-pottery__spoke" cx="65.2627063238442" cy="113.52168906345052" r="1.2" opacity="0.25"></circle>
<circle class="tabs-pottery__spoke" cx="94.69988129422649" cy="107.34353850892657" r="1.2" opacity="0.25"></circle>
<circle class="tabs-pottery__spoke" cx="136.02859048466712" cy="107.40779856896683" r="1.2" opacity="0.25"></circle>
<circle class="tabs-pottery__spoke" cx="165.03903656677957" cy="113.67682657191861" r="1.2" opacity="0.25"></circle>
<path d="M130.0 118C130.8 116.0 133.4 110.2 135.0 106.3C136.6 102.3 138.5 98.4 139.5 94.5C140.5 90.6 141.1 86.7 141.0 82.8C140.9 78.8 140.2 74.9 139.0 71.0C137.8 67.1 135.6 63.2 134.0 59.3C132.4 55.3 130.1 51.4 129.5 47.5C128.9 43.6 129.4 39.7 130.5 35.8C131.6 31.8 135.1 26.0 136.0 24.0L94.0 24.0C94.9 26.0 98.4 31.8 99.5 35.8C100.6 39.7 101.1 43.6 100.5 47.5C99.9 51.4 97.6 55.3 96.0 59.3C94.4 63.2 92.2 67.1 91.0 71.0C89.8 74.9 89.1 78.8 89.0 82.8C88.9 86.7 89.5 90.6 90.5 94.5C91.5 98.4 93.4 102.3 95.0 106.3C96.6 110.2 99.2 116.0 100.0 118.0A15.0 3.3 0 0 0 130.0 118Z" fill="url(#tabs-pottery-clay)"></path>
<path class="tabs-pottery__ring" d="M95.2 106.7A19.8 4.4 0 0 0 134.8 106.7"></path>
<path class="tabs-pottery__ring" d="M90.5 94.5A24.5 5.4 0 0 0 139.5 94.5"></path>
<path class="tabs-pottery__ring" d="M89.1 82.3A25.9 5.7 0 0 0 140.9 82.3"></path>
<path class="tabs-pottery__ring" d="M91.4 70.1A23.6 5.2 0 0 0 138.6 70.1"></path>
<path class="tabs-pottery__ring" d="M96.5 57.8A18.5 4.1 0 0 0 133.5 57.8"></path>
<path class="tabs-pottery__ring" d="M100.3 45.6A14.7 3.2 0 0 0 129.7 45.6"></path>
<path class="tabs-pottery__ring" d="M99.3 35.3A15.7 3.5 0 0 0 130.7 35.3"></path>
<path class="tabs-pottery__mark" d="M120.8 121.0L122.8 110.3L124.5 99.5L125.1 88.0L124.3 75.9L122.4 63.1L120.6 50.4L121.0 38.9L123.2 28.3" opacity="0.9210609940028851"></path>
<path class="tabs-pottery__mark" d="" opacity="0"></path>
<path class="tabs-pottery__mark" d="" opacity="0"></path>
<path class="tabs-pottery__mark" d="" opacity="0"></path>
<path class="tabs-pottery__mark" d="M103.7 120.2L99.9 109.1L96.5 98.0L95.4 86.5L96.9 74.5L100.6 62.0L104.0 49.6L103.3 38.0L99.1 27.0" opacity="0.6549823520202719"></path>
<ellipse class="tabs-pottery__lip" cx="115" cy="24" rx="21" ry="4.62"></ellipse>
<ellipse class="tabs-pottery__hole" cx="115" cy="24.6" rx="18.4" ry="3.42"></ellipse>
</svg>
<div class="tabs-pottery__tabs" role="tablist" aria-label="Forms">
<button class="tabs-pottery__tab" type="button" role="tab" id="tabs-pottery-tab-0" aria-selected="false" aria-controls="tabs-pottery-panel-0" tabindex="-1">Bowl</button>
<button class="tabs-pottery__tab" type="button" role="tab" id="tabs-pottery-tab-1" aria-selected="false" aria-controls="tabs-pottery-panel-1" tabindex="-1">Cup</button>
<button class="tabs-pottery__tab" type="button" role="tab" id="tabs-pottery-tab-2" aria-selected="true" aria-controls="tabs-pottery-panel-2" tabindex="0">Vase</button>
<button class="tabs-pottery__tab" type="button" role="tab" id="tabs-pottery-tab-3" aria-selected="false" aria-controls="tabs-pottery-panel-3" tabindex="-1">Bottle</button>
</div>
<div class="tabs-pottery__panel" role="tabpanel" id="tabs-pottery-panel-0" aria-labelledby="tabs-pottery-tab-0" hidden="">A wide salad bowl, 26 cm across, from 1.5 kg of clay.</div>
<div class="tabs-pottery__panel" role="tabpanel" id="tabs-pottery-panel-1" aria-labelledby="tabs-pottery-tab-1" hidden="">A straight-sided cup that holds a big coffee.</div>
<div class="tabs-pottery__panel" role="tabpanel" id="tabs-pottery-panel-2" aria-labelledby="tabs-pottery-tab-2">A bellied vase with a flared neck, 30 cm tall.</div>
<div class="tabs-pottery__panel" role="tabpanel" id="tabs-pottery-panel-3" aria-labelledby="tabs-pottery-tab-3" hidden="">A narrow-necked bottle, thrown in one piece.</div>
</div>.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; }function initTabsPottery(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'; });
// Each form is a height and nine half-widths from the foot up to the lip.
var FORMS = [
{ h: 40, r: [24, 34, 40, 44, 47, 49, 50.5, 51.5, 52] },
{ h: 56, r: [21, 23, 24.5, 25.5, 26, 26.5, 27, 27.8, 29] },
{ h: 94, r: [15, 20, 24.5, 26, 24, 19, 14.5, 15.5, 21] },
{ h: 92, r: [26, 34, 36, 35, 30, 18, 9.5, 8.5, 10] },
];
var CX = 115;
var FOOT = 118; // where the pot meets the wheel head
var TILT = 0.22; // how round the ellipses look from this height
var body = root.querySelector('.tabs-pottery__wheel path[fill]');
var rings = root.querySelectorAll('.tabs-pottery__ring');
var marks = root.querySelectorAll('.tabs-pottery__mark');
var spokes = root.querySelectorAll('.tabs-pottery__spoke');
var lip = root.querySelector('.tabs-pottery__lip');
var hole = root.querySelector('.tabs-pottery__hole');
var goal = FORMS[sel];
var s = { h: goal.h, r: goal.r.slice(), vh: 0, vr: goal.r.map(function () { return 0; }), turn: 0.4, w: 0 };
var raf = 0;
function f(v) { return v.toFixed(1); }
// A smooth curve through the points (Catmull-Rom as cubic Beziers).
function curve(p) {
var d = '';
for (var j = 0; j < p.length - 1; j++) {
var a = p[Math.max(0, j - 1)];
var b = p[j];
var c = p[j + 1];
var 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;
}
// Everything that depends on the shape of the clay and the turn of the wheel.
function draw() {
var K = s.r.length;
var pts = s.r.map(function (r, j) { return { r: r, y: FOOT - (s.h * j) / (K - 1) }; });
var top = pts[K - 1];
var r0 = pts[0].r;
var right = pts.map(function (q) { return { x: CX + q.r, y: q.y }; });
var left = pts.map(function (q) { return { x: CX - q.r, y: q.y }; }).reverse();
body.setAttribute('d', 'M' + f(CX + r0) + ' ' + FOOT + curve(right) + 'L' + f(CX - top.r) + ' ' + f(top.y) + curve(left) + 'A' + f(r0) + ' ' + f(r0 * TILT) + ' 0 0 0 ' + f(CX + r0) + ' ' + FOOT + 'Z');
[0.12, 0.25, 0.38, 0.51, 0.64, 0.77, 0.88].forEach(function (t, k) {
var j = t * (K - 1);
var lo = Math.floor(j);
var r = s.r[lo] + (s.r[Math.min(K - 1, lo + 1)] - s.r[lo]) * (j - lo);
var y = FOOT - s.h * t;
rings[k].setAttribute('d', '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.
marks.forEach(function (mark, k) {
var a = s.turn + (k * 2 * Math.PI) / 5;
var c = Math.cos(a);
if (c < 0.08) {
mark.setAttribute('d', '');
mark.setAttribute('opacity', '0');
return;
}
mark.setAttribute('d', pts.map(function (q, j) { return (j ? 'L' : 'M') + f(CX + q.r * Math.sin(a)) + ' ' + f(q.y + q.r * TILT * c); }).join(''));
mark.setAttribute('opacity', c);
});
spokes.forEach(function (spoke, k) {
var a = s.turn + (k * Math.PI) / 4;
spoke.setAttribute('cx', CX + 54 * Math.cos(a));
spoke.setAttribute('cy', FOOT + 11.5 * Math.sin(a));
spoke.setAttribute('opacity', 0.25 + 0.6 * Math.max(0, Math.sin(a)));
});
lip.setAttribute('cy', top.y);
lip.setAttribute('rx', top.r);
lip.setAttribute('ry', top.r * TILT);
hole.setAttribute('cy', top.y + 0.6);
hole.setAttribute('rx', Math.max(0, top.r - 2.6));
hole.setAttribute('ry', Math.max(0, top.r * TILT - 1.2));
}
function settle() {
s.h = goal.h;
s.r = goal.r.slice();
s.vh = 0;
s.vr = goal.r.map(function () { return 0; });
s.w = 0;
draw();
}
// 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.
function update() {
cancelAnimationFrame(raf);
goal = FORMS[sel];
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
settle();
return;
}
var last = performance.now();
var t0 = last;
function step(now) {
var dt = Math.min(0.032, (now - last) / 1000);
last = now;
var shaping = now - t0 > 180; // the wheel gets going before the clay moves
var 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(function (r, j) {
var 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]);
});
}
var 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;
draw();
if (!busy && s.w < 0.05) {
settle();
return;
}
raf = requestAnimationFrame(step);
}
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-pottery').forEach(initTabsPottery);Goal: Create a "Pottery" tab.
Visual behavior: 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.
Technique:
- A real tablist of buttons with roving tabindex and a labelled panel for each tab; everything drawn follows the selection.
- Each form is a height and nine half-widths. Each value chases its target on its own spring, softer toward the lip, so the top wobbles last. A smooth curve through the points draws the silhouette.
- The spin only exists because of marks fixed to the clay: each sits at an angle round the pot, its x the radius times the sine, fading toward the edges and gone round the back.
- The wheel gets going before the clay moves, and once the clay has settled it runs down to a stop and the loop ends, so a resting pot costs nothing.
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 new form appears at once and the wheel stays still.
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 "Pottery" tab.
Visual behavior: 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.
Technique:
- A real tablist of buttons with roving tabindex and a labelled panel for each tab; everything drawn follows the selection.
- Each form is a height and nine half-widths. Each value chases its target on its own spring, softer toward the lip, so the top wobbles last. A smooth curve through the points draws the silhouette.
- The spin only exists because of marks fixed to the clay: each sits at an angle round the pot, its x the radius times the sine, fading toward the edges and gone round the back.
- The wheel gets going before the clay moves, and once the clay has settled it runs down to a stop and the loop ends, so a resting pot costs nothing.
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 new form appears at once and the wheel stays still.
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
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.