Glassware

No dependencies
Code format
React + Tailwind
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>
    </>
  );
}

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

Install with shadcn

npx shadcn@latest add @movin/radio-glassware
Source on GitHub

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.

More radios

See all
Multitool
Paper Stock
Pigeonhole
Pillbox