Cookie Cutter
import { useId, useRef, useState, type CSSProperties } from 'react';
const STAR = (() => {
let d = '';
for (let k = 0; k < 10; k++) {
const a = -Math.PI / 2 + (k * Math.PI) / 5;
const r = k % 2 ? 7.5 : 17;
d += `${k ? 'L' : 'M'}${(20 + r * Math.cos(a)).toFixed(1)} ${(21 + r * Math.sin(a)).toFixed(1)}`;
}
return `${d}Z`;
})();
const CUTTERS = [
{ label: 'Star', shape: STAR, body: 'Makes 24 from one sheet. Bake 9 minutes at 180°C.' },
{ label: 'Heart', shape: 'M20 35C8 26 3 19 3 13C3 7.5 7.5 4 12 4C15.5 4 18.5 6 20 9C21.5 6 24.5 4 28 4C32.5 4 37 7.5 37 13C37 19 32 26 20 35Z', body: 'Makes 18. Ice them once they have cooled.' },
{ label: 'Moon', shape: 'M24 3A17 17 0 1 0 37 28A13 13 0 0 1 24 3Z', body: 'Makes 30. Dust with sugar while still warm.' },
{ label: 'Tree', shape: 'M20 3L30 15H25L33 26H23.5V36H16.5V26H7L15 15H10Z', body: 'Makes 16. Thread a ribbon through before baking.' },
];
const SLOT = [33, 88, 143, 198];
const DOUGH = { x: 115, y: 50 }; // where the cutter presses, in the desk's coordinates
const css = `
.tabs-cookie-cutter { --flour: light-dark(#fbfcfd, #f1f2f3); --shade: light-dark(#373a40, #121417); --dough: light-dark(#f3f4f7, #c6c8cd); --steel: light-dark(#6d7179, #a7abb2); width: 230px; font-family: ui-rounded, 'SF Pro Rounded', system-ui, sans-serif; }
.tabs-cookie-cutter__desk { position: relative; height: 164px; }
/* Rolled-out dough with a dusting of flour. */
.tabs-cookie-cutter__dough { position: absolute; left: 14px; top: 4px; width: 202px; height: 92px; border-radius: 46% 54% 50% 48% / 52% 46% 56% 48%; background: radial-gradient(circle, color-mix(in oklab, var(--flour) 70%, transparent) 0.7px, transparent 1.1px) 0 0 / 7px 6px, radial-gradient(circle, color-mix(in oklab, var(--flour) 50%, transparent) 0.6px, transparent 1px) 3px 2px / 11px 9px, radial-gradient(ellipse at 40% 35%, color-mix(in oklab, var(--dough) 85%, light-dark(#373a40, #f1f2f3)), var(--dough) 70%); box-shadow: inset 0 -3px 6px color-mix(in oklab, var(--shade) 22%, transparent), 0 6px 12px -6px light-dark(rgba(0, 0, 0, 0.25), rgba(0, 0, 0, 0.6)); }
.tabs-cookie-cutter__cut { position: absolute; left: ${DOUGH.x - 22}px; top: ${DOUGH.y - 22}px; width: 44px; height: 44px; overflow: visible; }
.tabs-cookie-cutter__cut path { fill: color-mix(in oklab, var(--dough) 90%, light-dark(#373a40, #f1f2f3)); stroke: color-mix(in oklab, var(--dough) 55%, var(--shade)); stroke-width: 1.8; stroke-linejoin: round; }
.tabs-cookie-cutter__cut[data-old] { opacity: 0; }
.tabs-cookie-cutter[data-run='a'] .tabs-cookie-cutter__cut[data-old] { animation: tabs-cookie-cutter-smooth-a 1.8s both; }
.tabs-cookie-cutter[data-run='b'] .tabs-cookie-cutter__cut[data-old] { animation: tabs-cookie-cutter-smooth-b 1.8s both; }
.tabs-cookie-cutter[data-run='a'] .tabs-cookie-cutter__cut[data-new] { animation: tabs-cookie-cutter-press-a 1.8s both; }
.tabs-cookie-cutter[data-run='b'] .tabs-cookie-cutter__cut[data-new] { animation: tabs-cookie-cutter-press-b 1.8s both; }
/* The rolling pin passes over first and smooths the last cut away. */
.tabs-cookie-cutter__pin { position: absolute; left: 0; top: -8px; width: 16px; height: 112px; opacity: 0; pointer-events: none; }
.tabs-cookie-cutter__pin::before { content: ''; position: absolute; left: 6px; top: 0; bottom: 0; width: 4px; border-radius: 2px; background: light-dark(#cbced3, #393c41); }
.tabs-cookie-cutter__pin::after { content: ''; position: absolute; left: 0; top: 14px; bottom: 14px; width: 16px; border-radius: 4px; background: repeating-linear-gradient(90deg, transparent 0 5px, color-mix(in oklab, var(--shade) 12%, transparent) 5px 6px), linear-gradient(90deg, light-dark(#cbced3, #393c41), light-dark(#5f626a, #a7abb2) 40%, light-dark(#dbdde1, #2e3035)); background-size: 9px 100%, 100% 100%; }
.tabs-cookie-cutter[data-run='a'] .tabs-cookie-cutter__pin::after { animation: tabs-cookie-cutter-grain-a 0.7s linear; }
.tabs-cookie-cutter[data-run='b'] .tabs-cookie-cutter__pin::after { animation: tabs-cookie-cutter-grain-b 0.7s linear; }
.tabs-cookie-cutter[data-run='a'] .tabs-cookie-cutter__pin { animation: tabs-cookie-cutter-roll-a 1.8s both; }
.tabs-cookie-cutter[data-run='b'] .tabs-cookie-cutter__pin { animation: tabs-cookie-cutter-roll-b 1.8s both; }
/* The cutter in your hand: lifted from its place, pressed in, put back. */
.tabs-cookie-cutter__hand { position: absolute; left: ${DOUGH.x - 20}px; top: ${DOUGH.y - 20}px; width: 40px; height: 40px; opacity: 0; pointer-events: none; }
.tabs-cookie-cutter[data-run='a'] .tabs-cookie-cutter__hand { animation: tabs-cookie-cutter-hand-a 1.8s both; }
.tabs-cookie-cutter[data-run='b'] .tabs-cookie-cutter__hand { animation: tabs-cookie-cutter-hand-b 1.8s both; }
.tabs-cookie-cutter__puff { position: absolute; left: ${DOUGH.x - 30}px; top: ${DOUGH.y - 30}px; width: 60px; height: 60px; border-radius: 50%; background: radial-gradient(circle, color-mix(in oklab, light-dark(#373a40, #f1f2f3) 60%, transparent) 0.8px, transparent 1.3px) 0 0 / 9px 8px; mask: radial-gradient(closest-side, transparent 55%, #000 70%, transparent); opacity: 0; pointer-events: none; }
.tabs-cookie-cutter[data-run='a'] .tabs-cookie-cutter__puff { animation: tabs-cookie-cutter-puff-a 1.8s both; }
.tabs-cookie-cutter[data-run='b'] .tabs-cookie-cutter__puff { animation: tabs-cookie-cutter-puff-b 1.8s both; }
.tabs-cookie-cutter__steel { fill: none; stroke: var(--steel); stroke-width: 3; stroke-linejoin: round; }
.tabs-cookie-cutter__shine { fill: none; stroke: color-mix(in oklab, var(--steel) 45%, light-dark(#373a40, #f1f2f3)); stroke-width: 0.9; stroke-linejoin: round; }
.tabs-cookie-cutter__tabs { position: absolute; left: 0; right: 0; top: 104px; height: 60px; }
.tabs-cookie-cutter__tab { position: absolute; top: 0; width: 52px; height: 60px; margin-left: -26px; padding: 0; border: 0; border-radius: 8px; background: none; color: light-dark(#5f626a, #a7abb2); font: inherit; font-size: 11.5px; font-weight: 700; cursor: pointer; transition: color 0.2s; }
.tabs-cookie-cutter__tab svg { display: block; width: 40px; height: 40px; margin: 0 auto 4px; overflow: visible; transition: transform 0.2s; }
.tabs-cookie-cutter__tab:hover svg { transform: translateY(-2px); }
.tabs-cookie-cutter__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-cookie-cutter[data-run='a'] .tabs-cookie-cutter__tab[aria-selected='true'] svg { animation: tabs-cookie-cutter-away-a 1.8s both; }
.tabs-cookie-cutter[data-run='b'] .tabs-cookie-cutter__tab[aria-selected='true'] svg { animation: tabs-cookie-cutter-away-b 1.8s both; }
.tabs-cookie-cutter__tab:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 0; }
.tabs-cookie-cutter__panel { min-height: 3em; margin-top: 8px; color: light-dark(#50535b, #c6c8cd); font-size: 12.5px; font-weight: 500; line-height: 1.5; }
@keyframes tabs-cookie-cutter-roll-a { 0% { opacity: 1; transform: translateX(-24px); } 38% { opacity: 1; transform: translateX(238px); } 39%, 100% { opacity: 0; transform: translateX(238px); } }
@keyframes tabs-cookie-cutter-roll-b { 0% { opacity: 1; transform: translateX(-24px); } 38% { opacity: 1; transform: translateX(238px); } 39%, 100% { opacity: 0; transform: translateX(238px); } }
@keyframes tabs-cookie-cutter-grain-a { to { background-position: 54px 0, 0 0; } }
@keyframes tabs-cookie-cutter-grain-b { to { background-position: 54px 0, 0 0; } }
@keyframes tabs-cookie-cutter-smooth-a { 0%, 14% { opacity: 1; transform: none; } 22% { opacity: 0; transform: scaleX(1.25); } 100% { opacity: 0; } }
@keyframes tabs-cookie-cutter-smooth-b { 0%, 14% { opacity: 1; transform: none; } 22% { opacity: 0; transform: scaleX(1.25); } 100% { opacity: 0; } }
@keyframes tabs-cookie-cutter-hand-a { 0%, 30% { opacity: 1; transform: translate(var(--fx), var(--fy)); filter: none; } 40% { transform: translate(var(--fx), calc(var(--fy) - 10px)) scale(1.12); filter: drop-shadow(0 8px 6px light-dark(rgba(0, 0, 0, 0.11), rgba(0, 0, 0, 0.35))); } 55% { transform: translate(0, -8px) scale(1.18); filter: drop-shadow(0 10px 7px light-dark(rgba(0, 0, 0, 0.09), rgba(0, 0, 0, 0.3))); } 62%, 67% { transform: scale(0.95); filter: drop-shadow(0 1px 1px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.4))); } 76% { transform: translate(0, -8px) scale(1.15); filter: drop-shadow(0 10px 7px light-dark(rgba(0, 0, 0, 0.09), rgba(0, 0, 0, 0.3))); } 95% { opacity: 1; transform: translate(var(--fx), var(--fy)) scale(1.02); filter: drop-shadow(0 2px 2px light-dark(rgba(0, 0, 0, 0.09), rgba(0, 0, 0, 0.3))); } 100% { opacity: 1; transform: translate(var(--fx), var(--fy)); filter: none; } }
@keyframes tabs-cookie-cutter-hand-b { 0%, 30% { opacity: 1; transform: translate(var(--fx), var(--fy)); filter: none; } 40% { transform: translate(var(--fx), calc(var(--fy) - 10px)) scale(1.12); filter: drop-shadow(0 8px 6px light-dark(rgba(0, 0, 0, 0.11), rgba(0, 0, 0, 0.35))); } 55% { transform: translate(0, -8px) scale(1.18); filter: drop-shadow(0 10px 7px light-dark(rgba(0, 0, 0, 0.09), rgba(0, 0, 0, 0.3))); } 62%, 67% { transform: scale(0.95); filter: drop-shadow(0 1px 1px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.4))); } 76% { transform: translate(0, -8px) scale(1.15); filter: drop-shadow(0 10px 7px light-dark(rgba(0, 0, 0, 0.09), rgba(0, 0, 0, 0.3))); } 95% { opacity: 1; transform: translate(var(--fx), var(--fy)) scale(1.02); filter: drop-shadow(0 2px 2px light-dark(rgba(0, 0, 0, 0.09), rgba(0, 0, 0, 0.3))); } 100% { opacity: 1; transform: translate(var(--fx), var(--fy)); filter: none; } }
@keyframes tabs-cookie-cutter-away-a { 0%, 100% { opacity: 0; } }
@keyframes tabs-cookie-cutter-away-b { 0%, 100% { opacity: 0; } }
@keyframes tabs-cookie-cutter-press-a { 0%, 63% { opacity: 0; transform: scale(0.96); } 66% { opacity: 1; transform: scale(0.96); } 80%, 100% { opacity: 1; transform: none; } }
@keyframes tabs-cookie-cutter-press-b { 0%, 63% { opacity: 0; transform: scale(0.96); } 66% { opacity: 1; transform: scale(0.96); } 80%, 100% { opacity: 1; transform: none; } }
@keyframes tabs-cookie-cutter-puff-a { 0%, 62% { opacity: 0; transform: scale(0.6); } 66% { opacity: 1; } 85%, 100% { opacity: 0; transform: scale(1.3); } }
@keyframes tabs-cookie-cutter-puff-b { 0%, 62% { opacity: 0; transform: scale(0.6); } 66% { opacity: 1; } 85%, 100% { opacity: 0; transform: scale(1.3); } }
@media (prefers-reduced-motion: reduce) {
.tabs-cookie-cutter *, .tabs-cookie-cutter *::after { animation: none !important; }
}
`;
function Cutter({ shape }: { shape: string }) {
return (
<svg viewBox="0 0 40 40" aria-hidden="true">
<path className="tabs-cookie-cutter__steel" d={shape} />
<path className="tabs-cookie-cutter__shine" d={shape} transform="translate(20 20) scale(0.86) translate(-20 -20)" />
</svg>
);
}
export function Preview() {
const id = useId();
const [{ sel, from, run }, setState] = useState({ sel: 0, from: -1, run: '' });
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const pick = (i: number) => setState((s) => (s.sel === i ? s : { sel: i, from: s.sel, run: s.run === 'a' ? 'b' : 'a' }));
const n = CUTTERS.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();
pick(next);
tabs.current[next]?.focus();
};
return (
<>
<style href="kit-tabs-cookie-cutter" precedence="kit">
{css}
</style>
<div className="tabs-cookie-cutter" data-run={run || undefined}>
<div className="tabs-cookie-cutter__desk">
<div className="tabs-cookie-cutter__dough" aria-hidden="true" />
{from >= 0 && (
<svg className="tabs-cookie-cutter__cut" data-old="" viewBox="-2 -2 44 44" aria-hidden="true">
<path d={CUTTERS[from].shape} />
</svg>
)}
<svg className="tabs-cookie-cutter__cut" data-new="" viewBox="-2 -2 44 44" aria-hidden="true">
<path d={CUTTERS[sel].shape} />
</svg>
<span className="tabs-cookie-cutter__puff" aria-hidden="true" />
<span className="tabs-cookie-cutter__pin" aria-hidden="true" />
<div className="tabs-cookie-cutter__tabs" role="tablist" aria-label="Cutters" onKeyDown={onKeyDown}>
{CUTTERS.map((c, i) => (
<button
key={c.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-cookie-cutter__tab"
type="button"
role="tab"
id={`${id}-tab-${i}`}
aria-selected={sel === i}
aria-controls={`${id}-panel-${i}`}
tabIndex={sel === i ? 0 : -1}
style={{ left: SLOT[i] }}
onClick={() => pick(i)}
>
<Cutter shape={c.shape} />
{c.label}
</button>
))}
</div>
<span className="tabs-cookie-cutter__hand" style={{ '--fx': `${SLOT[sel] - DOUGH.x}px`, '--fy': `${104 + 20 - DOUGH.y}px` } as CSSProperties} aria-hidden="true">
<Cutter shape={CUTTERS[sel].shape} />
</span>
</div>
{CUTTERS.map((c, i) => (
<div key={c.label} className="tabs-cookie-cutter__panel" role="tabpanel" id={`${id}-panel-${i}`} aria-labelledby={`${id}-tab-${i}`} hidden={sel !== i}>
{c.body}
</div>
))}
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initTabsCookieCutter(el) on the mounted element instead.
<div class="tabs-cookie-cutter">
<div class="tabs-cookie-cutter__desk">
<div class="tabs-cookie-cutter__dough" aria-hidden="true"></div>
<svg class="tabs-cookie-cutter__cut" data-new="" viewBox="-2 -2 44 44" aria-hidden="true">
<path d="M20.0 4.0L24.4 14.9L36.2 15.7L27.1 23.3L30.0 34.8L20.0 28.5L10.0 34.8L12.9 23.3L3.8 15.7L15.6 14.9Z"></path>
</svg>
<span class="tabs-cookie-cutter__puff" aria-hidden="true"></span>
<span class="tabs-cookie-cutter__pin" aria-hidden="true"></span>
<div class="tabs-cookie-cutter__tabs" role="tablist" aria-label="Cutters">
<button class="tabs-cookie-cutter__tab" type="button" role="tab" id="tabs-cookie-cutter-tab-0" aria-selected="true" aria-controls="tabs-cookie-cutter-panel-0" tabindex="0" style="left:33px">
<svg viewBox="0 0 40 40" aria-hidden="true">
<path class="tabs-cookie-cutter__steel" d="M20.0 4.0L24.4 14.9L36.2 15.7L27.1 23.3L30.0 34.8L20.0 28.5L10.0 34.8L12.9 23.3L3.8 15.7L15.6 14.9Z"></path>
<path class="tabs-cookie-cutter__shine" d="M20.0 4.0L24.4 14.9L36.2 15.7L27.1 23.3L30.0 34.8L20.0 28.5L10.0 34.8L12.9 23.3L3.8 15.7L15.6 14.9Z" transform="translate(20 20) scale(0.86) translate(-20 -20)"></path>
</svg>
Star
</button>
<button class="tabs-cookie-cutter__tab" type="button" role="tab" id="tabs-cookie-cutter-tab-1" aria-selected="false" aria-controls="tabs-cookie-cutter-panel-1" tabindex="-1" style="left:88px">
<svg viewBox="0 0 40 40" aria-hidden="true">
<path class="tabs-cookie-cutter__steel" d="M20 35C8 26 3 19 3 13C3 7.5 7.5 4 12 4C15.5 4 18.5 6 20 9C21.5 6 24.5 4 28 4C32.5 4 37 7.5 37 13C37 19 32 26 20 35Z"></path>
<path class="tabs-cookie-cutter__shine" d="M20 35C8 26 3 19 3 13C3 7.5 7.5 4 12 4C15.5 4 18.5 6 20 9C21.5 6 24.5 4 28 4C32.5 4 37 7.5 37 13C37 19 32 26 20 35Z" transform="translate(20 20) scale(0.86) translate(-20 -20)"></path>
</svg>
Heart
</button>
<button class="tabs-cookie-cutter__tab" type="button" role="tab" id="tabs-cookie-cutter-tab-2" aria-selected="false" aria-controls="tabs-cookie-cutter-panel-2" tabindex="-1" style="left:143px">
<svg viewBox="0 0 40 40" aria-hidden="true">
<path class="tabs-cookie-cutter__steel" d="M24 3A17 17 0 1 0 37 28A13 13 0 0 1 24 3Z"></path>
<path class="tabs-cookie-cutter__shine" d="M24 3A17 17 0 1 0 37 28A13 13 0 0 1 24 3Z" transform="translate(20 20) scale(0.86) translate(-20 -20)"></path>
</svg>
Moon
</button>
<button class="tabs-cookie-cutter__tab" type="button" role="tab" id="tabs-cookie-cutter-tab-3" aria-selected="false" aria-controls="tabs-cookie-cutter-panel-3" tabindex="-1" style="left:198px">
<svg viewBox="0 0 40 40" aria-hidden="true">
<path class="tabs-cookie-cutter__steel" d="M20 3L30 15H25L33 26H23.5V36H16.5V26H7L15 15H10Z"></path>
<path class="tabs-cookie-cutter__shine" d="M20 3L30 15H25L33 26H23.5V36H16.5V26H7L15 15H10Z" transform="translate(20 20) scale(0.86) translate(-20 -20)"></path>
</svg>
Tree
</button>
</div>
<span class="tabs-cookie-cutter__hand" style="--fx:-82px;--fy:74px" aria-hidden="true">
<svg viewBox="0 0 40 40" aria-hidden="true">
<path class="tabs-cookie-cutter__steel" d="M20.0 4.0L24.4 14.9L36.2 15.7L27.1 23.3L30.0 34.8L20.0 28.5L10.0 34.8L12.9 23.3L3.8 15.7L15.6 14.9Z"></path>
<path class="tabs-cookie-cutter__shine" d="M20.0 4.0L24.4 14.9L36.2 15.7L27.1 23.3L30.0 34.8L20.0 28.5L10.0 34.8L12.9 23.3L3.8 15.7L15.6 14.9Z" transform="translate(20 20) scale(0.86) translate(-20 -20)"></path>
</svg>
</span>
</div>
<div class="tabs-cookie-cutter__panel" role="tabpanel" id="tabs-cookie-cutter-panel-0" aria-labelledby="tabs-cookie-cutter-tab-0">Makes 24 from one sheet. Bake 9 minutes at 180°C.</div>
<div class="tabs-cookie-cutter__panel" role="tabpanel" id="tabs-cookie-cutter-panel-1" aria-labelledby="tabs-cookie-cutter-tab-1" hidden="">Makes 18. Ice them once they have cooled.</div>
<div class="tabs-cookie-cutter__panel" role="tabpanel" id="tabs-cookie-cutter-panel-2" aria-labelledby="tabs-cookie-cutter-tab-2" hidden="">Makes 30. Dust with sugar while still warm.</div>
<div class="tabs-cookie-cutter__panel" role="tabpanel" id="tabs-cookie-cutter-panel-3" aria-labelledby="tabs-cookie-cutter-tab-3" hidden="">Makes 16. Thread a ribbon through before baking.</div>
</div>.tabs-cookie-cutter { --flour: light-dark(#fbfcfd, #f1f2f3); --shade: light-dark(#373a40, #121417); --dough: light-dark(#f3f4f7, #c6c8cd); --steel: light-dark(#6d7179, #a7abb2); width: 230px; font-family: ui-rounded, 'SF Pro Rounded', system-ui, sans-serif; }
.tabs-cookie-cutter__desk { position: relative; height: 164px; }
/* Rolled-out dough with a dusting of flour. */
.tabs-cookie-cutter__dough { position: absolute; left: 14px; top: 4px; width: 202px; height: 92px; border-radius: 46% 54% 50% 48% / 52% 46% 56% 48%; background: radial-gradient(circle, color-mix(in oklab, var(--flour) 70%, transparent) 0.7px, transparent 1.1px) 0 0 / 7px 6px, radial-gradient(circle, color-mix(in oklab, var(--flour) 50%, transparent) 0.6px, transparent 1px) 3px 2px / 11px 9px, radial-gradient(ellipse at 40% 35%, color-mix(in oklab, var(--dough) 85%, light-dark(#373a40, #f1f2f3)), var(--dough) 70%); box-shadow: inset 0 -3px 6px color-mix(in oklab, var(--shade) 22%, transparent), 0 6px 12px -6px light-dark(rgba(0, 0, 0, 0.25), rgba(0, 0, 0, 0.6)); }
.tabs-cookie-cutter__cut { position: absolute; left: 93px; top: 28px; width: 44px; height: 44px; overflow: visible; }
.tabs-cookie-cutter__cut path { fill: color-mix(in oklab, var(--dough) 90%, light-dark(#373a40, #f1f2f3)); stroke: color-mix(in oklab, var(--dough) 55%, var(--shade)); stroke-width: 1.8; stroke-linejoin: round; }
.tabs-cookie-cutter__cut[data-old] { opacity: 0; }
.tabs-cookie-cutter[data-run='a'] .tabs-cookie-cutter__cut[data-old] { animation: tabs-cookie-cutter-smooth-a 1.8s both; }
.tabs-cookie-cutter[data-run='b'] .tabs-cookie-cutter__cut[data-old] { animation: tabs-cookie-cutter-smooth-b 1.8s both; }
.tabs-cookie-cutter[data-run='a'] .tabs-cookie-cutter__cut[data-new] { animation: tabs-cookie-cutter-press-a 1.8s both; }
.tabs-cookie-cutter[data-run='b'] .tabs-cookie-cutter__cut[data-new] { animation: tabs-cookie-cutter-press-b 1.8s both; }
/* The rolling pin passes over first and smooths the last cut away. */
.tabs-cookie-cutter__pin { position: absolute; left: 0; top: -8px; width: 16px; height: 112px; opacity: 0; pointer-events: none; }
.tabs-cookie-cutter__pin::before { content: ''; position: absolute; left: 6px; top: 0; bottom: 0; width: 4px; border-radius: 2px; background: light-dark(#cbced3, #393c41); }
.tabs-cookie-cutter__pin::after { content: ''; position: absolute; left: 0; top: 14px; bottom: 14px; width: 16px; border-radius: 4px; background: repeating-linear-gradient(90deg, transparent 0 5px, color-mix(in oklab, var(--shade) 12%, transparent) 5px 6px), linear-gradient(90deg, light-dark(#cbced3, #393c41), light-dark(#5f626a, #a7abb2) 40%, light-dark(#dbdde1, #2e3035)); background-size: 9px 100%, 100% 100%; }
.tabs-cookie-cutter[data-run='a'] .tabs-cookie-cutter__pin::after { animation: tabs-cookie-cutter-grain-a 0.7s linear; }
.tabs-cookie-cutter[data-run='b'] .tabs-cookie-cutter__pin::after { animation: tabs-cookie-cutter-grain-b 0.7s linear; }
.tabs-cookie-cutter[data-run='a'] .tabs-cookie-cutter__pin { animation: tabs-cookie-cutter-roll-a 1.8s both; }
.tabs-cookie-cutter[data-run='b'] .tabs-cookie-cutter__pin { animation: tabs-cookie-cutter-roll-b 1.8s both; }
/* The cutter in your hand: lifted from its place, pressed in, put back. */
.tabs-cookie-cutter__hand { position: absolute; left: 95px; top: 30px; width: 40px; height: 40px; opacity: 0; pointer-events: none; }
.tabs-cookie-cutter[data-run='a'] .tabs-cookie-cutter__hand { animation: tabs-cookie-cutter-hand-a 1.8s both; }
.tabs-cookie-cutter[data-run='b'] .tabs-cookie-cutter__hand { animation: tabs-cookie-cutter-hand-b 1.8s both; }
.tabs-cookie-cutter__puff { position: absolute; left: 85px; top: 20px; width: 60px; height: 60px; border-radius: 50%; background: radial-gradient(circle, color-mix(in oklab, light-dark(#373a40, #f1f2f3) 60%, transparent) 0.8px, transparent 1.3px) 0 0 / 9px 8px; mask: radial-gradient(closest-side, transparent 55%, #000 70%, transparent); opacity: 0; pointer-events: none; }
.tabs-cookie-cutter[data-run='a'] .tabs-cookie-cutter__puff { animation: tabs-cookie-cutter-puff-a 1.8s both; }
.tabs-cookie-cutter[data-run='b'] .tabs-cookie-cutter__puff { animation: tabs-cookie-cutter-puff-b 1.8s both; }
.tabs-cookie-cutter__steel { fill: none; stroke: var(--steel); stroke-width: 3; stroke-linejoin: round; }
.tabs-cookie-cutter__shine { fill: none; stroke: color-mix(in oklab, var(--steel) 45%, light-dark(#373a40, #f1f2f3)); stroke-width: 0.9; stroke-linejoin: round; }
.tabs-cookie-cutter__tabs { position: absolute; left: 0; right: 0; top: 104px; height: 60px; }
.tabs-cookie-cutter__tab { position: absolute; top: 0; width: 52px; height: 60px; margin-left: -26px; padding: 0; border: 0; border-radius: 8px; background: none; color: light-dark(#5f626a, #a7abb2); font: inherit; font-size: 11.5px; font-weight: 700; cursor: pointer; transition: color 0.2s; }
.tabs-cookie-cutter__tab svg { display: block; width: 40px; height: 40px; margin: 0 auto 4px; overflow: visible; transition: transform 0.2s; }
.tabs-cookie-cutter__tab:hover svg { transform: translateY(-2px); }
.tabs-cookie-cutter__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-cookie-cutter[data-run='a'] .tabs-cookie-cutter__tab[aria-selected='true'] svg { animation: tabs-cookie-cutter-away-a 1.8s both; }
.tabs-cookie-cutter[data-run='b'] .tabs-cookie-cutter__tab[aria-selected='true'] svg { animation: tabs-cookie-cutter-away-b 1.8s both; }
.tabs-cookie-cutter__tab:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 0; }
.tabs-cookie-cutter__panel { min-height: 3em; margin-top: 8px; color: light-dark(#50535b, #c6c8cd); font-size: 12.5px; font-weight: 500; line-height: 1.5; }
@keyframes tabs-cookie-cutter-roll-a { 0% { opacity: 1; transform: translateX(-24px); } 38% { opacity: 1; transform: translateX(238px); } 39%, 100% { opacity: 0; transform: translateX(238px); } }
@keyframes tabs-cookie-cutter-roll-b { 0% { opacity: 1; transform: translateX(-24px); } 38% { opacity: 1; transform: translateX(238px); } 39%, 100% { opacity: 0; transform: translateX(238px); } }
@keyframes tabs-cookie-cutter-grain-a { to { background-position: 54px 0, 0 0; } }
@keyframes tabs-cookie-cutter-grain-b { to { background-position: 54px 0, 0 0; } }
@keyframes tabs-cookie-cutter-smooth-a { 0%, 14% { opacity: 1; transform: none; } 22% { opacity: 0; transform: scaleX(1.25); } 100% { opacity: 0; } }
@keyframes tabs-cookie-cutter-smooth-b { 0%, 14% { opacity: 1; transform: none; } 22% { opacity: 0; transform: scaleX(1.25); } 100% { opacity: 0; } }
@keyframes tabs-cookie-cutter-hand-a { 0%, 30% { opacity: 1; transform: translate(var(--fx), var(--fy)); filter: none; } 40% { transform: translate(var(--fx), calc(var(--fy) - 10px)) scale(1.12); filter: drop-shadow(0 8px 6px light-dark(rgba(0, 0, 0, 0.11), rgba(0, 0, 0, 0.35))); } 55% { transform: translate(0, -8px) scale(1.18); filter: drop-shadow(0 10px 7px light-dark(rgba(0, 0, 0, 0.09), rgba(0, 0, 0, 0.3))); } 62%, 67% { transform: scale(0.95); filter: drop-shadow(0 1px 1px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.4))); } 76% { transform: translate(0, -8px) scale(1.15); filter: drop-shadow(0 10px 7px light-dark(rgba(0, 0, 0, 0.09), rgba(0, 0, 0, 0.3))); } 95% { opacity: 1; transform: translate(var(--fx), var(--fy)) scale(1.02); filter: drop-shadow(0 2px 2px light-dark(rgba(0, 0, 0, 0.09), rgba(0, 0, 0, 0.3))); } 100% { opacity: 1; transform: translate(var(--fx), var(--fy)); filter: none; } }
@keyframes tabs-cookie-cutter-hand-b { 0%, 30% { opacity: 1; transform: translate(var(--fx), var(--fy)); filter: none; } 40% { transform: translate(var(--fx), calc(var(--fy) - 10px)) scale(1.12); filter: drop-shadow(0 8px 6px light-dark(rgba(0, 0, 0, 0.11), rgba(0, 0, 0, 0.35))); } 55% { transform: translate(0, -8px) scale(1.18); filter: drop-shadow(0 10px 7px light-dark(rgba(0, 0, 0, 0.09), rgba(0, 0, 0, 0.3))); } 62%, 67% { transform: scale(0.95); filter: drop-shadow(0 1px 1px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.4))); } 76% { transform: translate(0, -8px) scale(1.15); filter: drop-shadow(0 10px 7px light-dark(rgba(0, 0, 0, 0.09), rgba(0, 0, 0, 0.3))); } 95% { opacity: 1; transform: translate(var(--fx), var(--fy)) scale(1.02); filter: drop-shadow(0 2px 2px light-dark(rgba(0, 0, 0, 0.09), rgba(0, 0, 0, 0.3))); } 100% { opacity: 1; transform: translate(var(--fx), var(--fy)); filter: none; } }
@keyframes tabs-cookie-cutter-away-a { 0%, 100% { opacity: 0; } }
@keyframes tabs-cookie-cutter-away-b { 0%, 100% { opacity: 0; } }
@keyframes tabs-cookie-cutter-press-a { 0%, 63% { opacity: 0; transform: scale(0.96); } 66% { opacity: 1; transform: scale(0.96); } 80%, 100% { opacity: 1; transform: none; } }
@keyframes tabs-cookie-cutter-press-b { 0%, 63% { opacity: 0; transform: scale(0.96); } 66% { opacity: 1; transform: scale(0.96); } 80%, 100% { opacity: 1; transform: none; } }
@keyframes tabs-cookie-cutter-puff-a { 0%, 62% { opacity: 0; transform: scale(0.6); } 66% { opacity: 1; } 85%, 100% { opacity: 0; transform: scale(1.3); } }
@keyframes tabs-cookie-cutter-puff-b { 0%, 62% { opacity: 0; transform: scale(0.6); } 66% { opacity: 1; } 85%, 100% { opacity: 0; transform: scale(1.3); } }
@media (prefers-reduced-motion: reduce) {
.tabs-cookie-cutter *, .tabs-cookie-cutter *::after { animation: none !important; }
}function initTabsCookieCutter(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 hand = root.querySelector('.tabs-cookie-cutter__hand');
var fresh = root.querySelector('.tabs-cookie-cutter__cut[data-new]');
// The last cut, which the rolling pin smooths away, is only needed once there has been one.
var old = fresh.cloneNode(true);
old.removeAttribute('data-new');
old.setAttribute('data-old', '');
fresh.parentNode.insertBefore(old, fresh);
var DOUGH = { x: 115, y: 50 }; // where the cutter presses
var FROM_Y = 104 + 20 - DOUGH.y; // the cutter's middle in its place on the bench
var SLOT = tabs.map(function (tab) { return parseFloat(tab.style.left); });
var SHAPE = tabs.map(function (tab) { return tab.querySelector('.tabs-cookie-cutter__steel').getAttribute('d'); });
var run = 'a';
function update(from) {
old.querySelector('path').setAttribute('d', SHAPE[from]);
fresh.querySelector('path').setAttribute('d', SHAPE[sel]);
hand.querySelectorAll('path').forEach(function (path) { path.setAttribute('d', SHAPE[sel]); });
hand.style.setProperty('--fx', SLOT[sel] - DOUGH.x + 'px');
hand.style.setProperty('--fy', FROM_Y + 'px');
// Pin, cutter and cut all replay together by switching between identical animations.
run = run === 'a' ? 'b' : 'a';
root.setAttribute('data-run', run);
}
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);
};
}
document.querySelectorAll('.tabs-cookie-cutter').forEach(initTabsCookieCutter);Goal: Create a "Cookie Cutter" tab.
Visual behavior: A rolling pin smooths the dough, then the cutter you pick lifts off the bench, presses into the dough and leaves its shape cut out.
Technique:
- A real tablist of buttons with roving tabindex and a labelled panel for each tab; everything drawn follows the selection.
- One 1.8 second timeline, split across several elements that all replay together by switching between two identical sets of keyframes: the pin rolls across, the old cut fades as it passes, the cutter flies, presses and returns.
- The cutter in the air is a separate copy starting from the variables --fx and --fy, the distance to its place on the bench, so one set of keyframes serves all four. The one on the bench is hidden meanwhile.
- The cut appears at the moment of pressing, with a ring of flour puffing out from it.
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 shape is simply cut in the dough.
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 "Cookie Cutter" tab.
Visual behavior: A rolling pin smooths the dough, then the cutter you pick lifts off the bench, presses into the dough and leaves its shape cut out.
Technique:
- A real tablist of buttons with roving tabindex and a labelled panel for each tab; everything drawn follows the selection.
- One 1.8 second timeline, split across several elements that all replay together by switching between two identical sets of keyframes: the pin rolls across, the old cut fades as it passes, the cutter flies, presses and returns.
- The cutter in the air is a separate copy starting from the variables --fx and --fy, the distance to its place on the bench, so one set of keyframes serves all four. The one on the bench is hidden meanwhile.
- The cut appears at the moment of pressing, with a ring of flour puffing out from it.
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 shape is simply cut in the dough.
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 rolling pin smooths the dough, then the cutter you pick lifts off the bench, presses into the dough and leaves its shape cut out.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, state change, keyframe
- Techniques
- keyframes, css variables, svg, filter
- 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 shape is simply cut in the dough.
More tabs
See all1.44 MB