Snow globe

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

const D = 156;
const R = D / 2;
const FLAKES = 96;
const rnd = (i: number) => {
  const v = Math.sin(i * 45.163) * 43758.5453;
  return v - Math.floor(v);
};
// The hill the town sits on, in globe coordinates.
const ground = (x: number) => R + 40 - Math.pow((x - R) / R, 2) * 4 + Math.sin(x / 18) * 1.5;

const css = `
  .card-snowglobe { flex: none; --night: light-dark(#50535b, #232529); --dusk: light-dark(#6d7179, #393c41); --snow: light-dark(#fbfcfd, #f1f2f3); --town: light-dark(#42454c, #1a1c1f); --plinth: light-dark(#42454c, #2e3035); position: relative; width: 196px; height: 226px; touch-action: none; cursor: grab; -webkit-user-select: none; user-select: none; }
  .card-snowglobe:active { cursor: grabbing; }
  .card-snowglobe__whole { position: absolute; inset: 0; display: block; width: 100%; padding: 0; border: 0; background: none; color: inherit; font: inherit; cursor: inherit; transform-origin: 50% 100%; will-change: transform; touch-action: none; }
  .card-snowglobe__whole:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; border-radius: 14px; }
  .card-snowglobe__glass { position: absolute; left: ${(196 - D) / 2}px; top: 2px; width: ${D}px; height: ${D}px; border-radius: 50%; background: radial-gradient(circle at 50% 74%, var(--dusk), var(--night) 72%); box-shadow: 0 0 0 1.5px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 34%, transparent), inset 0 0 0 1.5px color-mix(in oklab, var(--snow) 42%, transparent), inset 0 -14px 22px light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.26)); }
  .card-snowglobe__town { position: absolute; left: 0; top: 0; width: ${D}px; height: ${D}px; border-radius: 50%; }
  .card-snowglobe__dark { fill: var(--town); }
  .card-snowglobe__white { fill: var(--snow); }
  .card-snowglobe__moon { fill: color-mix(in oklab, var(--snow) 68%, transparent); }
  .card-snowglobe__lit { fill: var(--snow); animation: card-snowglobe-glow 3.4s ease-in-out infinite; }
  @keyframes card-snowglobe-glow {
    50% { opacity: 0.5; }
  }
  .card-snowglobe__canvas { position: absolute; inset: 0; width: ${D}px; height: ${D}px; color: var(--snow); pointer-events: none; }
  .card-snowglobe__shine { position: absolute; inset: 0; border-radius: 50%; background: radial-gradient(ellipse 30% 20% at 32% 19%, color-mix(in oklab, var(--snow) 58%, transparent), transparent 74%), radial-gradient(ellipse 60% 16% at 54% 92%, color-mix(in oklab, var(--snow) 16%, transparent), transparent); pointer-events: none; }
  .card-snowglobe__plinth { position: absolute; left: 24px; right: 24px; bottom: 6px; height: 72px; display: flex; flex-direction: column; align-items: center; justify-content: flex-end; padding-bottom: 8px; box-sizing: border-box; background: var(--plinth); clip-path: polygon(13% 0, 87% 0, 100% 100%, 0 100%); color: var(--snow); }
  .card-snowglobe__place { font: italic 700 19px/1 Georgia, 'Times New Roman', serif; }
  .card-snowglobe__note { margin-top: 3px; font-size: 10px; letter-spacing: 0.04em; }
  .card-snowglobe__shadow { position: absolute; left: 22px; right: 22px; bottom: 0; height: 10px; border-radius: 50%; background: radial-gradient(closest-side, color-mix(in oklab, var(--town) 62%, transparent), transparent); }
  @media (prefers-reduced-motion: reduce) {
    .card-snowglobe__lit { animation: none; }
  }
`;

export function SnowglobeCard() {
  const root = useRef<HTMLDivElement>(null);
  const whole = useRef<HTMLButtonElement>(null);
  const canvas = useRef<HTMLCanvasElement>(null);
  const jolt = useRef<() => void>(() => {});

  useEffect(() => {
    const el = root.current;
    const body = whole.current;
    const cv = canvas.current;
    const ctx = cv?.getContext('2d');
    if (!el || !body || !cv || !ctx) return;
    const calm = matchMedia('(prefers-reduced-motion: reduce)').matches;
    const dpr = Math.min(2, window.devicePixelRatio || 1);
    cv.width = D * dpr;
    cv.height = D * dpr;
    ctx.scale(dpr, dpr);
    const flakes = Array.from({ length: FLAKES }, (_, i) => {
      const x = R + (rnd(i) - 0.5) * (D - 24);
      const r = 0.7 + rnd(i + 200) * 1.25;
      const resting = i % 3 !== 0;
      return { x, y: resting ? ground(x) - rnd(i + 90) * 2 : rnd(i + 100) * ground(x), vx: 0, vy: 0, r, rest: resting };
    });
    let color = getComputedStyle(cv).color;
    let frames = 0;
    let px = 0; // the globe's position, driven by your hand or the spring
    let py = 0;
    let bvx = 0;
    let bvy = 0;
    let swirl = 0;
    let held = false;
    let moved = false;
    let sx = 0;
    let sy = 0;
    let hx = 0;
    let hy = 0;
    let raf = 0;
    let last = 0;
    let running = false;

    const stir = (hard: number) => {
      swirl = Math.min(1, swirl + hard * 0.5);
      for (const f of flakes) {
        if (f.rest && hard > 0.2) {
          f.rest = false;
          f.vy = -(40 + Math.random() * 130) * hard;
          f.vx = (Math.random() - 0.5) * 80 * hard;
        }
      }
    };
    const draw = () => {
      if (++frames % 40 === 0) color = getComputedStyle(cv).color;
      ctx.clearRect(0, 0, D, D);
      ctx.fillStyle = color;
      for (const f of flakes) {
        ctx.beginPath();
        ctx.arc(f.x, f.y, f.r, 0, Math.PI * 2);
        ctx.fill();
      }
    };
    const tick = (t: number) => {
      const dt = Math.min(0.033, (t - last) / 1000 || 0.016);
      last = t;
      const ox = px;
      const oy = py;
      if (held) {
        px += (hx - px) * Math.min(1, dt * 24);
        py += (hy - py) * Math.min(1, dt * 24);
      } else {
        bvx += (-px * 160 - bvx * 11) * dt;
        bvy += (-py * 160 - bvy * 11) * dt;
        px += bvx * dt;
        py += bvy * dt;
      }
      const mvx = (px - ox) / dt;
      const mvy = (py - oy) / dt;
      const speed = Math.hypot(mvx, mvy);
      if (speed > 170) stir(Math.min(1, speed / 700));
      swirl *= Math.exp(-dt * 0.55);
      body.style.transform = `translate(${px.toFixed(2)}px,${py.toFixed(2)}px) rotate(${(px * 0.34).toFixed(2)}deg)`;

      for (const f of flakes) {
        if (f.rest) continue;
        // The water lags behind the glass, so the snow slides the other way, and it keeps turning.
        const dx = f.x - R;
        const dy = f.y - R;
        const tx = -dy / R;
        const ty = dx / R;
        const fluidX = -mvx * 0.42 + tx * swirl * 95 + Math.sin(t / 900 + f.r * 9) * 4;
        const fluidY = -mvy * 0.42 + ty * swirl * 95;
        f.vx += (fluidX - f.vx) * Math.min(1, dt * 3.4);
        f.vy += (fluidY + 10 + f.r * 8 - f.vy) * Math.min(1, dt * 2.2);
        f.x += f.vx * dt;
        f.y += f.vy * dt;
        const d = Math.hypot(f.x - R, f.y - R);
        if (d > R - 4) {
          const k = (R - 4) / d;
          f.x = R + (f.x - R) * k;
          f.y = R + (f.y - R) * k;
        }
        if (f.y >= ground(f.x) && swirl < 0.3 && f.vy > 0) {
          f.y = ground(f.x) - Math.random() * 2;
          f.rest = true;
        }
      }
      // A slow fall that never quite stops, so the scene is alive at rest.
      if (!calm && Math.random() < dt * 2) {
        const pick = flakes[Math.floor(Math.random() * FLAKES)];
        if (pick.rest) {
          pick.rest = false;
          pick.x = R + (Math.random() - 0.5) * 110;
          pick.y = 22 + Math.random() * 12;
          pick.vx = 0;
          pick.vy = 0;
        }
      }
      draw();
      raf = requestAnimationFrame(tick);
    };
    const io = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting && !running) {
        running = true;
        last = performance.now();
        raf = requestAnimationFrame(tick);
      } else if (!entry.isIntersecting && running) {
        running = false;
        cancelAnimationFrame(raf);
      }
    });
    io.observe(el);
    draw();
    jolt.current = () => {
      bvx = 330;
      bvy = -120;
      stir(1);
    };
    const down = (e: PointerEvent) => {
      held = true;
      moved = false;
      sx = e.clientX;
      sy = e.clientY;
      hx = px;
      hy = py;
      el.setPointerCapture(e.pointerId);
    };
    const move = (e: PointerEvent) => {
      if (!held) return;
      const x = e.clientX - sx;
      const y = e.clientY - sy;
      if (Math.hypot(x, y) > 4) moved = true;
      hx = Math.max(-34, Math.min(34, x));
      hy = Math.max(-16, Math.min(14, y));
    };
    const up = () => {
      if (held && !moved) jolt.current();
      held = false;
      bvx = 0;
      bvy = 0;
    };
    el.addEventListener('pointerdown', down);
    el.addEventListener('pointermove', move);
    el.addEventListener('pointerup', up);
    el.addEventListener('pointercancel', up);
    return () => {
      io.disconnect();
      cancelAnimationFrame(raf);
      el.removeEventListener('pointerdown', down);
      el.removeEventListener('pointermove', move);
      el.removeEventListener('pointerup', up);
      el.removeEventListener('pointercancel', up);
    };
  }, []);

  return (
    <>
      <style>
        {css}
      </style>
      <div ref={root} className="card-snowglobe">
        <span className="card-snowglobe__shadow" aria-hidden="true" />
        <button
          ref={whole}
          type="button"
          className="card-snowglobe__whole"
          aria-label="Oslo, minus 3 degrees and snowing. Press to shake the globe."
          // A keyboard press has no drag to read, so it just gives the globe a shake.
          onClick={(e) => {
            if (e.detail === 0) jolt.current();
          }}
        >
          <span className="card-snowglobe__plinth">
            <span className="card-snowglobe__place">Oslo</span>
            <span className="card-snowglobe__note">&minus;3&deg;, snowing</span>
          </span>
          <span className="card-snowglobe__glass">
            <svg className="card-snowglobe__town" viewBox={`0 0 ${D} ${D}`} aria-hidden="true">
              <circle className="card-snowglobe__moon" cx="108" cy="44" r="12" />
              <path className="card-snowglobe__dark" d="M26 118 L26 96 L34 88 L42 96 L42 118 Z" />
              <path className="card-snowglobe__dark" d="M42 118 L42 86 L52 76 L62 86 L62 118 Z" />
              <path className="card-snowglobe__dark" d="M62 118 L62 80 L68 80 L72 42 L76 80 L82 80 L82 118 Z" />
              <path className="card-snowglobe__dark" d="M82 118 L82 92 L94 82 L106 92 L106 118 Z" />
              <path className="card-snowglobe__dark" d="M116 118 L124 96 L120 96 L127 82 L123 82 L130 68 L137 82 L133 82 L140 96 L136 96 L144 118 Z" />
              <rect className="card-snowglobe__lit" x="49" y="92" width="5" height="7" />
              <rect className="card-snowglobe__lit" x="69" y="88" width="6" height="9" style={{ animationDelay: '-1.2s' }} />
              <rect className="card-snowglobe__lit" x="91" y="97" width="5" height="6" style={{ animationDelay: '-2.3s' }} />
              <rect className="card-snowglobe__lit" x="31" y="102" width="4" height="5" style={{ animationDelay: '-0.6s' }} />
              <path className="card-snowglobe__white" d="M0 118 C30 112 60 120 90 115 C112 112 136 118 156 113 L156 156 L0 156 Z" />
              <path className="card-snowglobe__white" d="M42 86 L52 76 L62 86 L57 86 L52 81 L47 86 Z M82 92 L94 82 L106 92 L100 92 L94 87 L88 92 Z M26 96 L34 88 L42 96 L38 96 L34 92 L30 96 Z" />
            </svg>
            <canvas ref={canvas} className="card-snowglobe__canvas" aria-hidden="true" />
            <span className="card-snowglobe__shine" />
          </span>
        </button>
      </div>
    </>
  );
}

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

Install with shadcn

npx shadcn@latest add @movin/card-snowglobe
Source on GitHub

About this component

A place card you can pick up. Drag to shake and the snow lifts off the ground and swirls against the motion of the glass, then slowly settles on the town.

Dependencies
None
Difficulty
Advanced
Triggers
card, click, continuous, spring
Techniques
raf, spring, event position, radial gradient
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

The globe is a real button whose aria-label gives the place and weather; a press from the keyboard gives it a shake. The drawing and canvas are aria-hidden. Under prefers-reduced-motion the slow snowfall stops and the windows stop flickering.

More cards

See all
Papilio machaonSwallowtail, plate IV
Specimen
Zoetrope

Yes. Your plan runs to the end of the period you paid for, then stops.

Accordion

Inbox

  • Ana Ruiz

    The prints are in, come by after 4

  • Jun Bae

    Proofs attached, two small fixes

Actions