Glassware
import { useId, useState, type CSSProperties } from 'react';
// Four glasses in one 262×140 drawing. `inside` is the liquid's clip; `full` and
// `empty` are the liquid's top edge when filled and drained; `lip` is the rim
// corner it pours from (left, right).
const GLASSES = [
{ value: 'coupe', label: 'Coupe', tilt: 50, cx: 40, rim: 70, lip: [14, 66], full: 74, empty: 94, outline: 'M14 70Q14 92 40 94Q66 92 66 70M40 94V127M27 129.5H53', inside: 'M15 70Q15 91 40 93Q65 91 65 70Z', shine: 'M20 74Q22 86 32 90' },
{ value: 'highball', label: 'Highball', tilt: 84, cx: 104, rim: 44, lip: [88, 120], full: 56, empty: 124, outline: 'M88 44L90 128Q90 130 92 130H116Q118 130 118 128L120 44', inside: 'M89.6 44L91.4 124H116.6L118.4 44Z', shine: 'M93 52L94.4 116' },
{ value: 'rocks', label: 'Rocks', tilt: 66, cx: 162, rim: 86, lip: [140, 184], full: 96, empty: 122, outline: 'M140 86L142 128Q142 130 144 130H180Q182 130 182 128L184 86', inside: 'M141.6 86L143.3 122H180.7L182.4 86Z', shine: 'M145.5 92L146.6 116' },
{ value: 'flute', label: 'Flute', tilt: 80, cx: 224, rim: 38, lip: [213, 235], full: 48, empty: 90, outline: 'M213 38Q212 80 224 92Q236 80 235 38M224 92V127M213 129.5H235', inside: 'M214.2 38Q213.6 79 224 90Q234.4 79 233.8 38Z', shine: 'M217 46Q216.5 70 220 80' },
];
const css = `
.radio-glassware { --glass: color-mix(in oklab, light-dark(#373a40, #f1f2f3) 5%, transparent); --edge: light-dark(#5f626a, #a7abb2); --drink: color-mix(in oklab, light-dark(#50535b, #c6c8cd) 72%, transparent); position: relative; width: 262px; height: 158px; }
.radio-glassware__art { position: absolute; inset: 0 0 18px; width: 262px; height: 140px; overflow: visible; pointer-events: none; }
.radio-glassware__table { stroke: light-dark(#dbdde1, #2e3035); stroke-width: 1; }
.radio-glassware__cup { transform-box: view-box; transform-origin: var(--ox) var(--oy); }
.radio-glassware__cup > path:first-of-type { fill: none; stroke: var(--edge); stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round; transition: stroke 0.3s; }
.radio-glassware__body { fill: var(--glass); }
.radio-glassware__shine { fill: none; stroke: light-dark(#373a40, #f1f2f3); stroke-width: 1.4; stroke-linecap: round; opacity: 0.3; }
.radio-glassware__cup--on > path:first-of-type { stroke: light-dark(#42454c, #e5e6e9); }
/* The liquid is a tall slab under the glass's inside shape; sliding it down drains it. */
.radio-glassware__liquid { transform: translateY(var(--drop)); transition: transform 0.6s cubic-bezier(0.5, 0, 0.75, 0.6) 0.32s; }
.radio-glassware__cup--on .radio-glassware__liquid { transform: none; transition: transform 0.65s cubic-bezier(0.2, 0.6, 0.3, 1) 0.48s; }
.radio-glassware__liquid rect { fill: var(--drink); }
.radio-glassware__liquid line { stroke: light-dark(#373a40, #f1f2f3); stroke-width: 1; opacity: 0.5; }
/* The full glass lifts, tips toward the new one and pours; the stream is one drawn arc. */
.radio-glassware[data-pour='0'] .radio-glassware__cup--from { animation: radio-glassware-tip-a 1.25s cubic-bezier(0.45, 0, 0.3, 1); }
.radio-glassware[data-pour='1'] .radio-glassware__cup--from { animation: radio-glassware-tip-b 1.25s cubic-bezier(0.45, 0, 0.3, 1); }
@keyframes radio-glassware-tip-a { 28%, 72% { transform: translate(var(--dx), -22px) rotate(var(--tilt)); } }
@keyframes radio-glassware-tip-b { 28%, 72% { transform: translate(var(--dx), -22px) rotate(var(--tilt)); } }
.radio-glassware__stream { fill: none; stroke: var(--drink); stroke-width: 2.6; stroke-linecap: round; stroke-dasharray: 1 1; stroke-dashoffset: 1; }
.radio-glassware[data-pour='0'] .radio-glassware__stream { animation: radio-glassware-pour-a 1.25s linear; }
.radio-glassware[data-pour='1'] .radio-glassware__stream { animation: radio-glassware-pour-b 1.25s linear; }
@keyframes radio-glassware-pour-a { 0%, 26% { stroke-dashoffset: 1; } 46% { stroke-dashoffset: 0; } 66% { stroke-dashoffset: 0; } 86%, 100% { stroke-dashoffset: -1; } }
@keyframes radio-glassware-pour-b { 0%, 26% { stroke-dashoffset: 1; } 46% { stroke-dashoffset: 0; } 66% { stroke-dashoffset: 0; } 86%, 100% { stroke-dashoffset: -1; } }
/* What each glass is served with, once it is full. */
.radio-glassware__garnish { opacity: 0; transition: opacity 0.2s; }
.radio-glassware__cup--on .radio-glassware__garnish { opacity: 1; transition-delay: 1s; }
.radio-glassware__garnish path, .radio-glassware__garnish rect { fill: none; stroke: light-dark(#42454c, #e5e6e9); stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round; }
.radio-glassware__straw { transform-box: view-box; transform: translateY(-34px); }
.radio-glassware__cup--on .radio-glassware__straw { transform: none; transition: transform 0.6s cubic-bezier(0.3, 1.5, 0.5, 1) 1.02s; }
.radio-glassware__ice { transform-box: fill-box; transform-origin: center; transform: translateY(-36px) rotate(-40deg); }
.radio-glassware__cup--on .radio-glassware__ice { animation: radio-glassware-bob 2.6s ease-in-out 1.7s infinite; transform: rotate(12deg); transition: transform 0.7s cubic-bezier(0.3, 1.4, 0.5, 1) 1s; }
@keyframes radio-glassware-bob { 50% { transform: translateY(1.5px) rotate(8deg); } }
.radio-glassware__bubbles circle { fill: light-dark(#373a40, #f1f2f3); opacity: 0; }
.radio-glassware__cup--on .radio-glassware__bubbles circle { animation: radio-glassware-fizz 1.6s ease-in infinite; }
@keyframes radio-glassware-fizz { 0% { opacity: 0; transform: none; } 20% { opacity: 0.7; } 100% { opacity: 0; transform: translateY(-34px); } }
.radio-glassware__opt { position: absolute; top: 0; bottom: 0; width: 60px; display: flex; align-items: flex-end; justify-content: center; color: light-dark(#6d7179, #7d8087); font: italic 500 12px Georgia, 'Times New Roman', serif; cursor: pointer; transition: color 0.25s; }
.radio-glassware__opt:hover { color: light-dark(#42454c, #e5e6e9); }
.radio-glassware__opt:has(input:checked) { color: light-dark(#373a40, #f1f2f3); }
.radio-glassware__opt:has(input:focus-visible) { outline: 1.5px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; border-radius: 6px; }
.radio-glassware__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none; }
@media (prefers-reduced-motion: reduce) {
.radio-glassware * { animation: none !important; transition: none !important; }
.radio-glassware__stream { display: none; }
}
`;
export function Preview() {
const name = useId();
const [state, setState] = useState({ on: 1, from: -1, pour: -1 });
const pick = (i: number) => setState((s) => (s.on === i ? s : { on: i, from: s.on, pour: s.pour + 1 }));
// The stream runs from the tipped glass's lip to the middle of the new rim.
const from = GLASSES[state.from];
const to = GLASSES[state.on];
const right = from ? to.cx > from.cx : true;
const dx = right ? 6 : -6;
const lip = from ? { x: (right ? from.lip[1] : from.lip[0]) + dx, y: from.rim - 20 } : { x: 0, y: 0 };
const end = { x: to.cx, y: to.rim + 8 };
const peak = Math.min(lip.y, end.y) - 26;
const stream = from ? `M${lip.x} ${lip.y}Q${(lip.x + end.x) / 2} ${peak} ${end.x} ${end.y}` : '';
return (
<>
<style href="kit-radios-glassware" precedence="kit">
{css}
</style>
<div className="radio-glassware" role="radiogroup" aria-label="Serve in" data-pour={state.pour < 0 ? undefined : state.pour % 2}>
<svg className="radio-glassware__art" viewBox="0 0 262 140" aria-hidden="true">
<defs>
{GLASSES.map((g) => (
<clipPath key={g.value} id={`${name}-${g.value}`}>
<path d={g.inside} />
</clipPath>
))}
</defs>
<line className="radio-glassware__table" x1={0} y1={131} x2={262} y2={131} />
{GLASSES.map((g, i) => {
const on = state.on === i;
const isFrom = state.from === i;
const edge = right ? g.lip[1] : g.lip[0];
return (
<g
key={g.value}
className={`radio-glassware__cup${on ? ' radio-glassware__cup--on' : ''}${isFrom ? ' radio-glassware__cup--from' : ''}`}
style={{ '--ox': `${edge}px`, '--oy': `${g.rim}px`, '--dx': `${dx}px`, '--tilt': `${right ? g.tilt : -g.tilt}deg`, '--drop': `${g.empty - g.full + 2}px` } as CSSProperties}
>
<path d={g.outline} />
<path className="radio-glassware__body" d={g.inside} />
<g clipPath={`url(#${name}-${g.value})`}>
<g className="radio-glassware__liquid">
<rect x={g.cx - 40} y={g.full} width={80} height={g.empty - g.full + 4} />
<line x1={g.cx - 40} y1={g.full + 0.5} x2={g.cx + 40} y2={g.full + 0.5} />
</g>
{g.value === 'flute' && (
<g className="radio-glassware__bubbles">
{[[221, 86, 0], [226, 84, 0.5], [223, 88, 1.0], [227, 87, 1.3]].map(([x, y, d]) => (
<circle key={d} cx={x} cy={y} r={0.9} style={{ animationDelay: `${d}s` }} />
))}
</g>
)}
</g>
<g className="radio-glassware__garnish">
{g.value === 'coupe' && <path d="M58 68C62 62 66 64 63 60C60 56 66 54 64 50" />}
{g.value === 'highball' && <path className="radio-glassware__straw" d="M110 120L114 30L122 24" />}
{g.value === 'rocks' && <rect className="radio-glassware__ice" x={154} y={95} width={13} height={13} rx={2} />}
</g>
<path className="radio-glassware__shine" d={g.shine} />
</g>
);
})}
{stream && <path key={state.pour} className="radio-glassware__stream" pathLength={1} d={stream} />}
</svg>
{GLASSES.map((g, i) => (
<label key={g.value} className="radio-glassware__opt" style={{ left: g.cx - 30 }}>
<input className="radio-glassware__input" type="radio" name={name} value={g.value} checked={state.on === i} onChange={() => pick(i)} />
{g.label}
</label>
))}
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initRadioGlassware(el) on the mounted element instead.
<div class="radio-glassware" role="radiogroup" aria-label="Serve in">
<svg class="radio-glassware__art" viewBox="0 0 262 140" aria-hidden="true">
<defs>
<clipPath id="radio-glassware-coupe">
<path d="M15 70Q15 91 40 93Q65 91 65 70Z"></path>
</clippath>
<clipPath id="radio-glassware-highball">
<path d="M89.6 44L91.4 124H116.6L118.4 44Z"></path>
</clippath>
<clipPath id="radio-glassware-rocks">
<path d="M141.6 86L143.3 122H180.7L182.4 86Z"></path>
</clippath>
<clipPath id="radio-glassware-flute">
<path d="M214.2 38Q213.6 79 224 90Q234.4 79 233.8 38Z"></path>
</clippath>
</defs>
<line class="radio-glassware__table" x1="0" y1="131" x2="262" y2="131"></line>
<g class="radio-glassware__cup" style="--ox:66px;--oy:70px;--dx:6px;--tilt:50deg;--drop:22px">
<path d="M14 70Q14 92 40 94Q66 92 66 70M40 94V127M27 129.5H53"></path>
<path class="radio-glassware__body" d="M15 70Q15 91 40 93Q65 91 65 70Z"></path>
<g clip-path="url(#radio-glassware-coupe)">
<g class="radio-glassware__liquid">
<rect x="0" y="74" width="80" height="24"></rect>
<line x1="0" y1="74.5" x2="80" y2="74.5"></line>
</g>
</g>
<g class="radio-glassware__garnish">
<path d="M58 68C62 62 66 64 63 60C60 56 66 54 64 50"></path>
</g>
<path class="radio-glassware__shine" d="M20 74Q22 86 32 90"></path>
</g>
<g class="radio-glassware__cup radio-glassware__cup--on" style="--ox:120px;--oy:44px;--dx:6px;--tilt:84deg;--drop:70px">
<path d="M88 44L90 128Q90 130 92 130H116Q118 130 118 128L120 44"></path>
<path class="radio-glassware__body" d="M89.6 44L91.4 124H116.6L118.4 44Z"></path>
<g clip-path="url(#radio-glassware-highball)">
<g class="radio-glassware__liquid">
<rect x="64" y="56" width="80" height="72"></rect>
<line x1="64" y1="56.5" x2="144" y2="56.5"></line>
</g>
</g>
<g class="radio-glassware__garnish">
<path class="radio-glassware__straw" d="M110 120L114 30L122 24"></path>
</g>
<path class="radio-glassware__shine" d="M93 52L94.4 116"></path>
</g>
<g class="radio-glassware__cup" style="--ox:184px;--oy:86px;--dx:6px;--tilt:66deg;--drop:28px">
<path d="M140 86L142 128Q142 130 144 130H180Q182 130 182 128L184 86"></path>
<path class="radio-glassware__body" d="M141.6 86L143.3 122H180.7L182.4 86Z"></path>
<g clip-path="url(#radio-glassware-rocks)">
<g class="radio-glassware__liquid">
<rect x="122" y="96" width="80" height="30"></rect>
<line x1="122" y1="96.5" x2="202" y2="96.5"></line>
</g>
</g>
<g class="radio-glassware__garnish">
<rect class="radio-glassware__ice" x="154" y="95" width="13" height="13" rx="2"></rect>
</g>
<path class="radio-glassware__shine" d="M145.5 92L146.6 116"></path>
</g>
<g class="radio-glassware__cup" style="--ox:235px;--oy:38px;--dx:6px;--tilt:80deg;--drop:44px">
<path d="M213 38Q212 80 224 92Q236 80 235 38M224 92V127M213 129.5H235"></path>
<path class="radio-glassware__body" d="M214.2 38Q213.6 79 224 90Q234.4 79 233.8 38Z"></path>
<g clip-path="url(#radio-glassware-flute)">
<g class="radio-glassware__liquid">
<rect x="184" y="48" width="80" height="46"></rect>
<line x1="184" y1="48.5" x2="264" y2="48.5"></line>
</g>
<g class="radio-glassware__bubbles">
<circle cx="221" cy="86" r="0.9" style="animation-delay:0s"></circle>
<circle cx="226" cy="84" r="0.9" style="animation-delay:0.5s"></circle>
<circle cx="223" cy="88" r="0.9" style="animation-delay:1s"></circle>
<circle cx="227" cy="87" r="0.9" style="animation-delay:1.3s"></circle>
</g>
</g>
<g class="radio-glassware__garnish"></g>
<path class="radio-glassware__shine" d="M217 46Q216.5 70 220 80"></path>
</g>
</svg>
<label class="radio-glassware__opt" style="left:10px">
<input class="radio-glassware__input" type="radio" name="radio-glassware" value="coupe"/>
Coupe
</label>
<label class="radio-glassware__opt" style="left:74px">
<input class="radio-glassware__input" type="radio" name="radio-glassware" checked="" value="highball"/>
Highball
</label>
<label class="radio-glassware__opt" style="left:132px">
<input class="radio-glassware__input" type="radio" name="radio-glassware" value="rocks"/>
Rocks
</label>
<label class="radio-glassware__opt" style="left:194px">
<input class="radio-glassware__input" type="radio" name="radio-glassware" value="flute"/>
Flute
</label>
</div>.radio-glassware { --glass: color-mix(in oklab, light-dark(#373a40, #f1f2f3) 5%, transparent); --edge: light-dark(#5f626a, #a7abb2); --drink: color-mix(in oklab, light-dark(#50535b, #c6c8cd) 72%, transparent); position: relative; width: 262px; height: 158px; }
.radio-glassware__art { position: absolute; inset: 0 0 18px; width: 262px; height: 140px; overflow: visible; pointer-events: none; }
.radio-glassware__table { stroke: light-dark(#dbdde1, #2e3035); stroke-width: 1; }
.radio-glassware__cup { transform-box: view-box; transform-origin: var(--ox) var(--oy); }
.radio-glassware__cup > path:first-of-type { fill: none; stroke: var(--edge); stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round; transition: stroke 0.3s; }
.radio-glassware__body { fill: var(--glass); }
.radio-glassware__shine { fill: none; stroke: light-dark(#373a40, #f1f2f3); stroke-width: 1.4; stroke-linecap: round; opacity: 0.3; }
.radio-glassware__cup--on > path:first-of-type { stroke: light-dark(#42454c, #e5e6e9); }
/* The liquid is a tall slab under the glass's inside shape; sliding it down drains it. */
.radio-glassware__liquid { transform: translateY(var(--drop)); transition: transform 0.6s cubic-bezier(0.5, 0, 0.75, 0.6) 0.32s; }
.radio-glassware__cup--on .radio-glassware__liquid { transform: none; transition: transform 0.65s cubic-bezier(0.2, 0.6, 0.3, 1) 0.48s; }
.radio-glassware__liquid rect { fill: var(--drink); }
.radio-glassware__liquid line { stroke: light-dark(#373a40, #f1f2f3); stroke-width: 1; opacity: 0.5; }
/* The full glass lifts, tips toward the new one and pours; the stream is one drawn arc. */
.radio-glassware[data-pour='0'] .radio-glassware__cup--from { animation: radio-glassware-tip-a 1.25s cubic-bezier(0.45, 0, 0.3, 1); }
.radio-glassware[data-pour='1'] .radio-glassware__cup--from { animation: radio-glassware-tip-b 1.25s cubic-bezier(0.45, 0, 0.3, 1); }
@keyframes radio-glassware-tip-a { 28%, 72% { transform: translate(var(--dx), -22px) rotate(var(--tilt)); } }
@keyframes radio-glassware-tip-b { 28%, 72% { transform: translate(var(--dx), -22px) rotate(var(--tilt)); } }
.radio-glassware__stream { fill: none; stroke: var(--drink); stroke-width: 2.6; stroke-linecap: round; stroke-dasharray: 1 1; stroke-dashoffset: 1; }
.radio-glassware[data-pour='0'] .radio-glassware__stream { animation: radio-glassware-pour-a 1.25s linear; }
.radio-glassware[data-pour='1'] .radio-glassware__stream { animation: radio-glassware-pour-b 1.25s linear; }
@keyframes radio-glassware-pour-a { 0%, 26% { stroke-dashoffset: 1; } 46% { stroke-dashoffset: 0; } 66% { stroke-dashoffset: 0; } 86%, 100% { stroke-dashoffset: -1; } }
@keyframes radio-glassware-pour-b { 0%, 26% { stroke-dashoffset: 1; } 46% { stroke-dashoffset: 0; } 66% { stroke-dashoffset: 0; } 86%, 100% { stroke-dashoffset: -1; } }
/* What each glass is served with, once it is full. */
.radio-glassware__garnish { opacity: 0; transition: opacity 0.2s; }
.radio-glassware__cup--on .radio-glassware__garnish { opacity: 1; transition-delay: 1s; }
.radio-glassware__garnish path, .radio-glassware__garnish rect { fill: none; stroke: light-dark(#42454c, #e5e6e9); stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round; }
.radio-glassware__straw { transform-box: view-box; transform: translateY(-34px); }
.radio-glassware__cup--on .radio-glassware__straw { transform: none; transition: transform 0.6s cubic-bezier(0.3, 1.5, 0.5, 1) 1.02s; }
.radio-glassware__ice { transform-box: fill-box; transform-origin: center; transform: translateY(-36px) rotate(-40deg); }
.radio-glassware__cup--on .radio-glassware__ice { animation: radio-glassware-bob 2.6s ease-in-out 1.7s infinite; transform: rotate(12deg); transition: transform 0.7s cubic-bezier(0.3, 1.4, 0.5, 1) 1s; }
@keyframes radio-glassware-bob { 50% { transform: translateY(1.5px) rotate(8deg); } }
.radio-glassware__bubbles circle { fill: light-dark(#373a40, #f1f2f3); opacity: 0; }
.radio-glassware__cup--on .radio-glassware__bubbles circle { animation: radio-glassware-fizz 1.6s ease-in infinite; }
@keyframes radio-glassware-fizz { 0% { opacity: 0; transform: none; } 20% { opacity: 0.7; } 100% { opacity: 0; transform: translateY(-34px); } }
.radio-glassware__opt { position: absolute; top: 0; bottom: 0; width: 60px; display: flex; align-items: flex-end; justify-content: center; color: light-dark(#6d7179, #7d8087); font: italic 500 12px Georgia, 'Times New Roman', serif; cursor: pointer; transition: color 0.25s; }
.radio-glassware__opt:hover { color: light-dark(#42454c, #e5e6e9); }
.radio-glassware__opt:has(input:checked) { color: light-dark(#373a40, #f1f2f3); }
.radio-glassware__opt:has(input:focus-visible) { outline: 1.5px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; border-radius: 6px; }
.radio-glassware__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none; }
@media (prefers-reduced-motion: reduce) {
.radio-glassware * { animation: none !important; transition: none !important; }
.radio-glassware__stream { display: none; }
}function initRadioGlassware(root) {
// Where each glass pours from (its rim corners), how high its rim is and how far it tips.
var GLASSES = [
{ cx: 40, rim: 70, lip: [14, 66], tilt: 50 },
{ cx: 104, rim: 44, lip: [88, 120], tilt: 84 },
{ cx: 162, rim: 86, lip: [140, 184], tilt: 66 },
{ cx: 224, rim: 38, lip: [213, 235], tilt: 80 },
];
var cups = Array.prototype.slice.call(root.querySelectorAll('.radio-glassware__cup'));
var inputs = Array.prototype.slice.call(root.querySelectorAll('.radio-glassware__input'));
var art = root.querySelector('.radio-glassware__art');
var on = inputs.findIndex(function (i) { return i.checked; });
var pour = -1;
// The pour needs both ends, so remember which glass held the drink.
function onChange(e) {
var to = inputs.indexOf(e.target);
if (to < 0 || to === on) return;
var from = on;
on = to;
pour += 1;
var a = GLASSES[from];
var b = GLASSES[to];
var right = b.cx > a.cx;
var dx = right ? 6 : -6;
cups.forEach(function (cup, k) {
var g = GLASSES[k];
cup.classList.toggle('radio-glassware__cup--on', k === to);
cup.classList.toggle('radio-glassware__cup--from', k === from);
cup.style.setProperty('--ox', (right ? g.lip[1] : g.lip[0]) + 'px');
cup.style.setProperty('--dx', dx + 'px');
cup.style.setProperty('--tilt', (right ? g.tilt : -g.tilt) + 'deg');
});
root.setAttribute('data-pour', String(pour % 2));
// The stream: one arc from the tipped glass's lip to the middle of the new rim.
var lip = { x: (right ? a.lip[1] : a.lip[0]) + dx, y: a.rim - 20 };
var end = { x: b.cx, y: b.rim + 8 };
var peak = Math.min(lip.y, end.y) - 26;
var old = art.querySelector('.radio-glassware__stream');
if (old) old.remove();
var stream = document.createElementNS('http://www.w3.org/2000/svg', 'path');
stream.setAttribute('class', 'radio-glassware__stream');
stream.setAttribute('pathLength', '1');
stream.setAttribute('d', 'M' + lip.x + ' ' + lip.y + 'Q' + (lip.x + end.x) / 2 + ' ' + peak + ' ' + end.x + ' ' + end.y);
art.appendChild(stream);
}
root.addEventListener('change', onChange);
return function () {
root.removeEventListener('change', onChange);
};
}
document.querySelectorAll('.radio-glassware').forEach(initRadioGlassware);Goal: Create a "Glassware" radio.
Visual behavior: Choose the glass: the full one lifts, tips toward the new one and pours an arc into it, which fills and gets its straw, ice cube, twist or rising bubbles.
Technique:
- Native radio inputs sharing one name inside a labelled role="radiogroup", visually hidden but focusable; each option is a <label> wrapping its input, so the arrow keys and clicks both work.
- A small state remembers which glass held the drink, because the pour needs both ends: it tips that glass about its rim toward the new one (rotation and lift from CSS variables) and draws the stream as one quadratic arc from its lip to the new rim by dash offset, head first, then tail.
- Each glass's liquid is a slab clipped to the inside of the glass, slid down to drain and up to fill, with the fill starting just after the stream lands. The garnish arrives once the glass is full: the straw drops in on a spring, the ice falls and bobs, bubbles rise in the flute.
Accessibility: A role="radiogroup" labelled "Serve in" around native radio inputs sharing one name, so the arrow keys move the choice and Tab enters the group once. Each glass is a <label> named by the glass, and focus outlines it. The drawing is aria-hidden. Under prefers-reduced-motion the drink moves at once and no stream is drawn.
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 "Glassware" radio.
Visual behavior: Choose the glass: the full one lifts, tips toward the new one and pours an arc into it, which fills and gets its straw, ice cube, twist or rising bubbles.
Technique:
- Native radio inputs sharing one name inside a labelled role="radiogroup", visually hidden but focusable; each option is a <label> wrapping its input, so the arrow keys and clicks both work.
- A small state remembers which glass held the drink, because the pour needs both ends: it tips that glass about its rim toward the new one (rotation and lift from CSS variables) and draws the stream as one quadratic arc from its lip to the new rim by dash offset, head first, then tail.
- Each glass's liquid is a slab clipped to the inside of the glass, slid down to drain and up to fill, with the fill starting just after the stream lands. The garnish arrives once the glass is full: the straw drops in on a spring, the ice falls and bobs, bubbles rise in the flute.
Accessibility: A role="radiogroup" labelled "Serve in" around native radio inputs sharing one name, so the arrow keys move the choice and Tab enters the group once. Each glass is a <label> named by the glass, and focus outlines it. The drawing is aria-hidden. Under prefers-reduced-motion the drink moves at once and no stream is drawn.
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
Choose the glass: the full one lifts, tips toward the new one and pours an arc into it, which fills and gets its straw, ice cube, twist or rising bubbles.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- click, state change, keyframe, draw
- Techniques
- keyframes, css variables, stroke dashoffset, svg, state machine
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A role="radiogroup" labelled "Serve in" around native radio inputs sharing one name, so the arrow keys move the choice and Tab enters the group once. Each glass is a <label> named by the glass, and focus outlines it. The drawing is aria-hidden. Under prefers-reduced-motion the drink moves at once and no stream is drawn.