Dandelion

No dependencies
Code format
React + Tailwind
import { useEffect, useRef, useState, type CSSProperties } from 'react';

const CX = 118;
const CY = 74;
const R = 46;
const COUNT = 54;
const rnd = (i: number) => {
  const v = Math.sin(i * 12.9898) * 43758.5453;
  return v - Math.floor(v);
};

// Seeds sit on a sphere (a golden-angle spiral), so the ones facing you look
// short with a full round parachute, and the ones at the rim long with a fan.
const SEEDS = Array.from({ length: COUNT }, (_, i) => {
  const z = 1 - (2 * (i + 0.5)) / COUNT;
  const flat = Math.sqrt(1 - z * z);
  const a = i * 2.39996;
  const len = 6 + R * flat;
  const tx = Math.cos(a) * len;
  const ty = Math.sin(a) * len;
  const spread = 0.8 + (1 - flat) * 2.34;
  let d = `M${(Math.cos(a) * 4).toFixed(1)} ${(Math.sin(a) * 4).toFixed(1)}L${tx.toFixed(1)} ${ty.toFixed(1)}`;
  for (let k = 0; k < 9; k++) {
    const b = a + (k / 8 - 0.5) * 2 * spread;
    d += `M${tx.toFixed(1)} ${ty.toFixed(1)}L${(tx + Math.cos(b) * 9).toFixed(1)} ${(ty + Math.sin(b) * 9).toFixed(1)}`;
  }
  // Flying, it turns until the parachute is on top.
  let upright = (-90 - (a * 180) / Math.PI) % 360;
  if (upright < -180) upright += 360;
  if (upright > 180) upright -= 360;
  return {
    d,
    front: z > -0.15,
    style: {
      '--dx': `${(110 + rnd(i) * 150).toFixed(0)}px`,
      '--dy': `${(-30 - rnd(i + 80) * 100 - (ty < 0 ? 20 : 0)).toFixed(0)}px`,
      '--r': `${(upright + (rnd(i + 40) - 0.5) * 30).toFixed(0)}deg`,
      '--t': `${(1.5 + rnd(i + 20) * 1).toFixed(2)}s`,
      '--d': `${((1 - (tx + 50) / 100) * 0.26 + rnd(i + 7) * 0.32).toFixed(2)}s`,
      '--g': `${(rnd(i + 11) * 0.5).toFixed(2)}s`,
    } as CSSProperties,
  };
});

const css = `
  .btn-dandelion { flex: none; position: relative; display: block; width: 236px; height: 222px; padding: 0; border: 0; border-radius: 12px; background: none; color: light-dark(#373a40, #f1f2f3); cursor: pointer; }
  .btn-dandelion:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
  .btn-dandelion svg { position: absolute; left: 0; top: 0; width: 236px; height: 196px; overflow: visible; }
  .btn-dandelion__plant { transform-box: view-box; transform-origin: ${CX}px 192px; animation: btn-dandelion-sway 4.6s ease-in-out infinite; }
  @keyframes btn-dandelion-sway {
    0%, 100% { transform: rotate(-1deg); }
    50% { transform: rotate(1.3deg); }
  }
  .btn-dandelion:hover .btn-dandelion__plant { animation-duration: 1.5s; }
  .btn-dandelion[data-phase='blown'] .btn-dandelion__plant { animation: btn-dandelion-gust 2.4s cubic-bezier(0.2, 0.7, 0.3, 1) both; }
  @keyframes btn-dandelion-gust {
    0% { transform: rotate(0); }
    14% { transform: rotate(11deg); }
    34% { transform: rotate(-3deg); }
    60% { transform: rotate(2deg); }
    100% { transform: rotate(0); }
  }
  .btn-dandelion__stem { fill: none; stroke: light-dark(#50535b, #c6c8cd); stroke-width: 1.8; stroke-linecap: round; }
  .btn-dandelion__leaf { fill: light-dark(#6d7179, #7d8087); }
  .btn-dandelion__seed { fill: none; stroke: light-dark(#373a40, #f1f2f3); stroke-width: 0.62; stroke-linecap: round; }
  .btn-dandelion__seed--back { stroke: light-dark(#5f626a, #a7abb2); stroke-width: 0.5; }
  .btn-dandelion__heart { fill: light-dark(#50535b, #c6c8cd); }
  .btn-dandelion[data-phase='blown'] .btn-dandelion__seed { transform-box: fill-box; transform-origin: center; animation: btn-dandelion-fly var(--t) cubic-bezier(0.3, 0.5, 0.5, 1) var(--d) both; }
  @keyframes btn-dandelion-fly {
    0% { transform: none; opacity: 1; }
    30% { transform: translate(calc(var(--dx) * 0.26), calc(var(--dy) * 0.12 + 12px)) rotate(calc(var(--r) * 0.6)); opacity: 1; }
    66% { transform: translate(calc(var(--dx) * 0.64), calc(var(--dy) * 0.66 - 4px)) rotate(calc(var(--r) * 1.06)); opacity: 0.9; }
    100% { transform: translate(var(--dx), var(--dy)) rotate(var(--r)); opacity: 0; }
  }
  .btn-dandelion[data-phase='grow'] .btn-dandelion__seed { transform-box: view-box; transform-origin: 0 0; animation: btn-dandelion-grow 0.8s cubic-bezier(0.3, 1.3, 0.5, 1) var(--g) both; }
  @keyframes btn-dandelion-grow {
    from { transform: scale(0); opacity: 0; }
  }
  .btn-dandelion__label { position: absolute; left: 0; right: 0; bottom: 0; color: light-dark(#42454c, #e5e6e9); font: italic 500 16px/1.2 Georgia, 'Times New Roman', serif; text-align: center; }
  @media (prefers-reduced-motion: reduce) {
    .btn-dandelion__plant, .btn-dandelion[data-phase='blown'] .btn-dandelion__plant { animation: none; }
    .btn-dandelion[data-phase='blown'] .btn-dandelion__seed { animation: none; opacity: 0; }
    .btn-dandelion[data-phase='grow'] .btn-dandelion__seed { animation-duration: 0.01s; animation-delay: 0s; }
  }
`;

export function DandelionButton() {
  const [phase, setPhase] = useState<'rest' | 'blown' | 'grow'>('rest');
  const timers = useRef<number[]>([]);
  useEffect(() => () => timers.current.forEach((t) => window.clearTimeout(t)), []);

  const press = () => {
    if (phase !== 'rest') return;
    setPhase('blown');
    timers.current = [window.setTimeout(() => setPhase('grow'), 3400), window.setTimeout(() => setPhase('rest'), 4800)];
  };

  return (
    <>
      <style>
        {css}
      </style>
      <button type="button" className="btn-dandelion" aria-label={phase === 'rest' ? 'Make a wish' : 'Wish sent'} data-phase={phase} onClick={press}>
        <svg viewBox="0 0 236 196" aria-hidden="true">
          <g className="btn-dandelion__plant">
            <path className="btn-dandelion__leaf" d={`M${CX} 192 C${CX - 16} 184 ${CX - 22} 190 ${CX - 30} 180 L${CX - 26} 187 L${CX - 42} 176 L${CX - 36} 186 L${CX - 56} 174 C${CX - 52} 188 ${CX - 30} 196 ${CX} 194 Z`} />
            <path className="btn-dandelion__leaf" d={`M${CX} 192 C${CX + 14} 182 ${CX + 18} 188 ${CX + 26} 176 L${CX + 24} 184 L${CX + 38} 170 L${CX + 34} 182 L${CX + 50} 170 C${CX + 48} 186 ${CX + 28} 196 ${CX} 194 Z`} />
            <path className="btn-dandelion__stem" d={`M${CX} ${CY} C${CX + 4} 110 ${CX - 7} 150 ${CX} 192`} />
            <g transform={`translate(${CX} ${CY})`}>
              {SEEDS.map((s, i) => (
                <path key={i} className={`btn-dandelion__seed${s.front ? '' : ' btn-dandelion__seed--back'}`} d={s.d} style={s.style} />
              ))}
              <circle className="btn-dandelion__heart" r="4.5" />
            </g>
          </g>
        </svg>
        <span className="btn-dandelion__label">{phase === 'rest' ? 'Make a wish' : 'Wish sent'}</span>
      </button>
    </>
  );
}

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

Install with shadcn

npx shadcn@latest add @movin/button-dandelion
Source on GitHub

About this component

A dandelion clock in hair-thin line. Press and a gust takes the seeds, each one turning upright under its parachute as it drifts off, then a new head grows.

Dependencies
None
Difficulty
Advanced
Triggers
click, delay stagger, state change, continuous
Techniques
keyframes, stagger, svg, css variables, state machine
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

A real button whose name follows the label; the plant is aria-hidden. Under prefers-reduced-motion the seeds vanish and return without flying, and the plant stops swaying.

More buttons

See all
Doorway
Footlight
Gumball
Honk