Snow globe
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">−3°, 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>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initCardSnowglobe(el) on the mounted element instead.
<div class="card-snowglobe">
<span class="card-snowglobe__shadow" aria-hidden="true"></span>
<button type="button" class="card-snowglobe__whole" aria-label="Oslo, minus 3 degrees and snowing. Press to shake the globe.">
<span class="card-snowglobe__plinth">
<span class="card-snowglobe__place">Oslo</span>
<span class="card-snowglobe__note">−3°, snowing</span>
</span>
<span class="card-snowglobe__glass">
<svg class="card-snowglobe__town" viewBox="0 0 156 156" aria-hidden="true">
<circle class="card-snowglobe__moon" cx="108" cy="44" r="12"></circle>
<path class="card-snowglobe__dark" d="M26 118 L26 96 L34 88 L42 96 L42 118 Z"></path>
<path class="card-snowglobe__dark" d="M42 118 L42 86 L52 76 L62 86 L62 118 Z"></path>
<path class="card-snowglobe__dark" d="M62 118 L62 80 L68 80 L72 42 L76 80 L82 80 L82 118 Z"></path>
<path class="card-snowglobe__dark" d="M82 118 L82 92 L94 82 L106 92 L106 118 Z"></path>
<path class="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"></path>
<rect class="card-snowglobe__lit" x="49" y="92" width="5" height="7"></rect>
<rect class="card-snowglobe__lit" x="69" y="88" width="6" height="9" style="animation-delay:-1.2s"></rect>
<rect class="card-snowglobe__lit" x="91" y="97" width="5" height="6" style="animation-delay:-2.3s"></rect>
<rect class="card-snowglobe__lit" x="31" y="102" width="4" height="5" style="animation-delay:-0.6s"></rect>
<path class="card-snowglobe__white" d="M0 118 C30 112 60 120 90 115 C112 112 136 118 156 113 L156 156 L0 156 Z"></path>
<path class="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"></path>
</svg>
<canvas class="card-snowglobe__canvas" aria-hidden="true"></canvas>
<span class="card-snowglobe__shine"></span>
</span>
</button>
</div>.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: 20px; top: 2px; width: 156px; height: 156px; 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: 156px; height: 156px; 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: 156px; height: 156px; 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; }
}function initCardSnowglobe(root) {
var D = 156;
var R = D / 2;
var FLAKES = 96;
var body = root.querySelector('.card-snowglobe__whole');
var cv = root.querySelector('.card-snowglobe__canvas');
var ctx = cv.getContext('2d');
var calm = matchMedia('(prefers-reduced-motion: reduce)').matches;
var dpr = Math.min(2, window.devicePixelRatio || 1);
cv.width = D * dpr;
cv.height = D * dpr;
ctx.scale(dpr, dpr);
function rnd(i) {
var v = Math.sin(i * 45.163) * 43758.5453;
return v - Math.floor(v);
}
// The hill the town sits on, in globe coordinates.
function ground(x) {
return R + 40 - Math.pow((x - R) / R, 2) * 4 + Math.sin(x / 18) * 1.5;
}
var flakes = [];
for (var i = 0; i < FLAKES; i++) {
var fx = R + (rnd(i) - 0.5) * (D - 24);
var resting = i % 3 !== 0;
flakes.push({ x: fx, y: resting ? ground(fx) - rnd(i + 90) * 2 : rnd(i + 100) * ground(fx), vx: 0, vy: 0, r: 0.7 + rnd(i + 200) * 1.25, rest: resting });
}
var color = getComputedStyle(cv).color;
var frames = 0;
var px = 0;
var py = 0;
var bvx = 0;
var bvy = 0;
var swirl = 0;
var held = false;
var moved = false;
var sx = 0;
var sy = 0;
var hx = 0;
var hy = 0;
var raf = 0;
var last = 0;
var running = false;
function stir(hard) {
swirl = Math.min(1, swirl + hard * 0.5);
flakes.forEach(function (f) {
if (f.rest && hard > 0.2) {
f.rest = false;
f.vy = -(40 + Math.random() * 130) * hard;
f.vx = (Math.random() - 0.5) * 80 * hard;
}
});
}
function draw() {
// The colour comes from the CSS, so the snow follows the page's theme.
if (++frames % 40 === 0) color = getComputedStyle(cv).color;
ctx.clearRect(0, 0, D, D);
ctx.fillStyle = color;
flakes.forEach(function (f) {
ctx.beginPath();
ctx.arc(f.x, f.y, f.r, 0, Math.PI * 2);
ctx.fill();
});
}
function tick(t) {
var dt = Math.min(0.033, (t - last) / 1000 || 0.016);
last = t;
var ox = px;
var 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;
}
var mvx = (px - ox) / dt;
var mvy = (py - oy) / dt;
var 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)';
flakes.forEach(function (f) {
if (f.rest) return;
// The water lags behind the glass, so the snow slides the other way, and it keeps turning.
var dx = f.x - R;
var dy = f.y - R;
var fluidX = -mvx * 0.42 + (-dy / R) * swirl * 95 + Math.sin(t / 900 + f.r * 9) * 4;
var fluidY = -mvy * 0.42 + (dx / R) * 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;
var d = Math.hypot(f.x - R, f.y - R);
if (d > R - 4) {
var 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) {
var 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);
}
var io = new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting && !running) {
running = true;
last = performance.now();
raf = requestAnimationFrame(tick);
} else if (!entries[0].isIntersecting && running) {
running = false;
cancelAnimationFrame(raf);
}
});
function jolt() {
bvx = 330;
bvy = -120;
stir(1);
}
function onDown(e) {
held = true;
moved = false;
sx = e.clientX;
sy = e.clientY;
hx = px;
hy = py;
root.setPointerCapture(e.pointerId);
}
function onMove(e) {
if (!held) return;
var x = e.clientX - sx;
var 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));
}
function onUp() {
if (held && !moved) jolt();
held = false;
bvx = 0;
bvy = 0;
}
// A keyboard press has no drag to read, so it just gives the globe a shake.
function onClick(e) {
if (e.detail === 0) jolt();
}
io.observe(root);
draw();
root.addEventListener('pointerdown', onDown);
root.addEventListener('pointermove', onMove);
root.addEventListener('pointerup', onUp);
root.addEventListener('pointercancel', onUp);
body.addEventListener('click', onClick);
return function () {
io.disconnect();
cancelAnimationFrame(raf);
root.removeEventListener('pointerdown', onDown);
root.removeEventListener('pointermove', onMove);
root.removeEventListener('pointerup', onUp);
root.removeEventListener('pointercancel', onUp);
body.removeEventListener('click', onClick);
};
}
document.querySelectorAll('.card-snowglobe').forEach(initCardSnowglobe);Goal: Create a "Snow globe" card.
Visual behavior: 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.
Technique:
- Dragging moves the globe, and a spring brings it home. Its speed each frame drives the snow on a canvas: a hard shake lifts settled flakes and adds a swirl that fades away, and the water lags behind the glass, so the flakes slide the other way.
- The snow colour is read from a CSS color with getComputedStyle, so the canvas follows the theme. A flake lifts off the ground now and then so the scene keeps snowing at rest.
Accessibility: 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.
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 "Snow globe" card.
Visual behavior: 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.
Technique:
- Dragging moves the globe, and a spring brings it home. Its speed each frame drives the snow on a canvas: a hard shake lifts settled flakes and adds a swirl that fades away, and the water lags behind the glass, so the flakes slide the other way.
- The snow colour is read from a CSS color with getComputedStyle, so the canvas follows the theme. A flake lifts off the ground now and then so the scene keeps snowing at rest.
Accessibility: 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.
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 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.