Snow Globe

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

const T = 4.4;
const D = 148; // globe diameter
// Deterministic scatter so the flakes land in the same drifts every time.
let seed = 7;
const rand = () => ((seed = (seed * 16807) % 2147483647) / 2147483647);
const FLAKES = Array.from({ length: 30 }, () => {
  const x0 = 16 + rand() * 116;
  const y0 = 116 + Math.abs(x0 - 74) * -0.12 + rand() * 8;
  const x1 = 14 + rand() * 120;
  const y1 = 12 + rand() * 50;
  return { x0, y0, x1, y1, x2: x1 + (rand() - 0.5) * 40, y2: y1 + 30 + rand() * 20, s: 2 + rand() * 1.8, d: rand() * 0.25 };
});
const px = (n: number) => `${n.toFixed(1)}px`;

const css = `
  .loader-snow-globe { --water: light-dark(#dbdde1, #232529); --snow: light-dark(#fbfcfd, #f1f2f3); --house: light-dark(#6d7179, #7d8087); --roof: light-dark(#50535b, #393c41); --lamp: light-dark(#f3f4f7, #e5e6e9); --fir: light-dark(#50535b, #7d8087); --base: light-dark(#50535b, #393c41); position: relative; width: 200px; height: 194px; }
  /* Shaken from the base: a hard rock either way, then still while it settles. */
  .loader-snow-globe__whole { position: absolute; inset: 0; transform-origin: 100px 190px; animation: loader-snow-globe-shake ${T}s infinite; }
  .loader-snow-globe__base { position: absolute; left: 30px; top: 138px; width: 140px; height: 50px; background: linear-gradient(90deg, color-mix(in oklab, var(--base) 75%, light-dark(#fbfcfd, #121417)), var(--base) 35%, color-mix(in oklab, var(--base) 75%, light-dark(#373a40, #f1f2f3)) 60%, color-mix(in oklab, var(--base) 80%, light-dark(#fbfcfd, #121417))); clip-path: polygon(10% 0, 90% 0, 100% 100%, 0 100%); border-radius: 0 0 6px 6px; }
  .loader-snow-globe__plaque { position: absolute; left: 76px; top: 160px; width: 48px; height: 14px; border-radius: 2px; background: color-mix(in oklab, var(--base) 55%, light-dark(#373a40, #f1f2f3)); box-shadow: inset 0 0 0 1px color-mix(in oklab, light-dark(#fbfcfd, #121417) 30%, transparent); }
  .loader-snow-globe__glass { position: absolute; left: 26px; top: 4px; width: ${D}px; height: ${D}px; border-radius: 50%; overflow: hidden; background: radial-gradient(circle at 50% 40%, color-mix(in oklab, var(--water) 80%, light-dark(#373a40, #f1f2f3)), var(--water) 70%); }
  .loader-snow-globe__glass::after { content: ''; position: absolute; inset: 0; border-radius: 50%; box-shadow: inset 0 0 0 1.5px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 30%, transparent), inset -10px -14px 22px color-mix(in oklab, light-dark(#fbfcfd, #121417) 30%, transparent); background: radial-gradient(ellipse 22% 12% at 30% 20%, color-mix(in oklab, var(--snow) 55%, transparent), transparent); }
  /* The little scene: a drift of snow, a house with a lit window, a fir. */
  .loader-snow-globe__drift { position: absolute; left: -10px; right: -10px; top: 112px; height: 60px; border-radius: 50% 50% 0 0 / 40% 40% 0 0; background: linear-gradient(var(--snow), color-mix(in oklab, var(--snow) 80%, var(--water))); }
  .loader-snow-globe__house { position: absolute; left: 40px; top: 92px; width: 34px; height: 24px; background: var(--house); box-shadow: inset 0 0 0 1px color-mix(in oklab, light-dark(#fbfcfd, #121417) 25%, transparent); }
  .loader-snow-globe__house::before { content: ''; position: absolute; left: -5px; right: -5px; top: -17px; height: 18px; background: var(--roof); clip-path: polygon(50% 0, 100% 100%, 0 100%); }
  .loader-snow-globe__house::after { content: ''; position: absolute; left: 7px; top: 7px; width: 7px; height: 7px; background: var(--lamp); box-shadow: 13px 4px 0 -1px var(--roof), 13px 8px 0 -1px var(--roof); }
  .loader-snow-globe__fir { position: absolute; left: 88px; top: 70px; width: 26px; height: 46px; background: var(--fir); clip-path: polygon(50% 0, 78% 34%, 64% 34%, 92% 66%, 74% 66%, 100% 92%, 56% 92%, 56% 100%, 44% 100%, 44% 92%, 0 92%, 26% 66%, 8% 66%, 36% 34%, 22% 34%); }
  .loader-snow-globe__flake { position: absolute; left: 0; top: 0; width: var(--s); height: var(--s); border-radius: 50%; background: var(--snow); transform: translate(var(--x0), var(--y0)); animation: loader-snow-globe-flake ${T}s infinite; animation-delay: var(--d); }
  @keyframes loader-snow-globe-shake {
    0% { transform: rotate(0deg); }
    3% { transform: rotate(-10deg); }
    6% { transform: rotate(8deg); }
    9% { transform: rotate(-6deg); }
    12% { transform: rotate(3.5deg); }
    15% { transform: rotate(-1.2deg); }
    18%, 100% { transform: rotate(0deg); }
  }
  @keyframes loader-snow-globe-flake {
    0%, 3% { transform: translate(var(--x0), var(--y0)); animation-timing-function: cubic-bezier(0.1, 0.7, 0.3, 1); }
    16% { transform: translate(var(--x1), var(--y1)); animation-timing-function: ease-in-out; }
    42% { transform: translate(var(--x2), var(--y2)); animation-timing-function: ease-in-out; }
    70% { transform: translate(calc((var(--x0) + var(--x2)) / 2 + 8px), calc((var(--y0) + var(--y2)) / 2)); animation-timing-function: ease-in-out; }
    96%, 100% { transform: translate(var(--x0), var(--y0)); }
  }
  @media (prefers-reduced-motion: reduce) {
    .loader-snow-globe *, .loader-snow-globe__whole { animation: none !important; }
    .loader-snow-globe__flake:nth-child(3n) { transform: translate(var(--x2), var(--y2)); }
  }
`;

export function Preview() {
  return (
    <>
      <style href="kit-loaders-snow-globe" precedence="kit">
        {css}
      </style>
      <div className="loader-snow-globe" role="status" aria-label="Loading">
        <span className="loader-snow-globe__whole" aria-hidden="true">
          <span className="loader-snow-globe__base" />
          <span className="loader-snow-globe__plaque" />
          <span className="loader-snow-globe__glass">
            <span className="loader-snow-globe__drift" />
            <span className="loader-snow-globe__house" />
            <span className="loader-snow-globe__fir" />
            {FLAKES.map((f, i) => (
              <i
                key={i}
                className="loader-snow-globe__flake"
                style={{ '--x0': px(f.x0), '--y0': px(f.y0), '--x1': px(f.x1), '--y1': px(f.y1), '--x2': px(f.x2), '--y2': px(f.y2), '--s': px(f.s), '--d': `${(-f.d).toFixed(2)}s` } as CSSProperties}
              />
            ))}
          </span>
        </span>
      </div>
    </>
  );
}

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

Install with shadcn

npx shadcn@latest add @movin/loader-snow-globe
Source on GitHub

About this component

A snow globe gets a shake: the flakes swirl up round the little house and drift slowly back down to settle.

Dependencies
None
Difficulty
Intermediate
Triggers
continuous, loop, delay stagger
Techniques
keyframes, css variables, radial gradient
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

The root has role="status" and aria-label="Loading"; everything inside it is aria-hidden. Under prefers-reduced-motion the snow lies settled with a few flakes still falling.

More loaders

See all
Spirograph
Stairs
Stamp
Stretch