← Backgrounds

Snowfall

<SnowfallBackground /> Snow falling at three depths onto soft drifts.

Animated · Source · Markdown

Your appContent sits on top of the background.
Theme
Usage
<SnowfallBackground>
  {children}
</SnowfallBackground>
Props and theme tokens
PropTypeDefaultDescription
themePartial<SnowfallTheme>—Colour overrides. Keys listed under theme tokens.
pausedbooleanfalseFreezes the animation.
classNamestring—Applied to the root element. Size it like any block element.
styleCSSProperties—Merged into the root element style, after theme variables.
childrenReactNode—Your content. The background paints behind it.
Theme keyCSS variableDefault
skyTop--sky-top#bcd3ee
skyBottom--sky-bottom#eef4fb
flake--flake#ffffff
drift--drift#ffffff
driftShadow--drift-shadow#d9e6f4
Source files

Copy into components/backgrounds/. The shared files are needed once for all backgrounds.

components/backgrounds/snowfall-background.tsx
// <SnowfallBackground>: snow falling at three depths onto soft drifts. Animated.
// Far flakes are small and slow, near flakes are six-armed and spin as they sway.

import type { CSSProperties } from 'react';
import { BackgroundFrame, backgroundStyles as base, r2, seeded, type BackgroundProps } from './background';
import styles from './snowfall-background.module.css';

export const snowfallTheme = {
  skyTop: '#bcd3ee',
  skyBottom: '#eef4fb',
  flake: '#ffffff',
  drift: '#ffffff',
  driftShadow: '#d9e6f4',
};
export type SnowfallTheme = typeof snowfallTheme;

const LAYERS = [
  { count: 40, size: [2, 3.5], fall: [18, 26], sway: [6, 14], opacity: 0.6 },
  { count: 24, size: [4, 6], fall: [12, 17], sway: [12, 24], opacity: 0.85 },
  { count: 12, size: [12, 18], fall: [8, 12], sway: [18, 34], opacity: 1 },
];

const rand = seeded(41);
const between = ([a, b]: number[]) => a + rand() * (b - a);
const FLAKES = LAYERS.flatMap((layer, depth) =>
  Array.from({ length: layer.count }, () => ({
    depth,
    x: r2(rand() * 100),
    y: r2(rand() * 100),
    size: r2(between(layer.size)),
    fall: r2(between(layer.fall)),
    delay: r2(rand() * 30),
    sway: r2(between(layer.sway)),
    swayTime: r2(2 + rand() * 3),
    spin: r2(6 + rand() * 8),
    opacity: layer.opacity,
  })),
);

// Six arms, each with a small V near the tip.
const ARM = 'M0 0 V-9 M0 -6 L-2.5 -8.5 M0 -6 L2.5 -8.5';
function Flake({ size }: { size: number }) {
  return (
    <svg className={styles.flake} width={size} height={size} viewBox="-10 -10 20 20" fill="none" strokeWidth="1.6" strokeLinecap="round">
      {[0, 60, 120, 180, 240, 300].map(a => <path key={a} d={ARM} transform={`rotate(${a})`} />)}
    </svg>
  );
}

/** Snow falling at three depths onto soft drifts. */
export function SnowfallBackground(props: BackgroundProps<SnowfallTheme>) {
  return (
    <BackgroundFrame
      {...props}
      defaultTheme={snowfallTheme}
      layer={
        <>
          <div className={styles.sky} />
          {FLAKES.map((f, i) => (
            <div
              key={i}
              className={`${base.particle} ${styles.fall}`}
              style={{ left: `${f.x}%`, '--y': `${f.y}%`, animationDuration: `${f.fall}s`, animationDelay: `${-f.delay}s`, opacity: f.opacity } as CSSProperties}
            >
              <div className={styles.sway} style={{ '--sway': `${f.sway}px`, animationDuration: `${f.swayTime}s` } as CSSProperties}>
                {f.depth === 2 ? (
                  <span className={styles.spin} style={{ animationDuration: `${f.spin}s` }}>
                    <Flake size={f.size} />
                  </span>
                ) : (
                  <span className={styles.dot} style={{ width: f.size, height: f.size }} />
                )}
              </div>
            </div>
          ))}
          <svg className={styles.drifts} viewBox="0 0 400 72" preserveAspectRatio="none">
            <path fill="var(--drift-shadow)" d="M0 40 C60 18 110 30 160 34 S260 14 320 28 S380 30 400 24 V72 H0 Z" />
            <path fill="var(--drift)" d="M0 52 C50 36 100 46 150 48 S240 32 300 42 S370 46 400 40 V72 H0 Z" />
          </svg>
        </>
      }
    />
  );
}
components/backgrounds/snowfall-background.module.css
.sky {
  position: absolute;
  inset: 0;
  background: linear-gradient(var(--sky-top), var(--sky-bottom));
}

.fall {
  animation: fall linear infinite;
  will-change: transform;
}

.sway { animation: sway ease-in-out infinite alternate; }
.spin { animation: spin linear infinite; display: block; }

.dot {
  display: block;
  border-radius: 50%;
  background: var(--flake);
  box-shadow: 0 0 2px rgb(90 120 160 / 0.25);
}

.flake {
  display: block;
  stroke: var(--flake);
  filter: drop-shadow(0 0 1px rgb(90 120 160 / 0.35));
}

.drifts {
  position: absolute;
  left: 0;
  bottom: 0;
  width: 100%;
  height: 72px;
}

@keyframes fall {
  from { transform: translateY(-24px); }
  to   { transform: translateY(calc(100cqh + 24px)); }
}
@keyframes sway {
  from { transform: translateX(calc(var(--sway) * -1)); }
  to   { transform: translateX(var(--sway)); }
}
@keyframes spin { to { transform: rotate(360deg); } }
components/backgrounds/background.tsx
// Shared frame for every background: theming, layering and pausing.
// Backgrounds are pure server components: no client JavaScript.

import type { CSSProperties, ReactNode } from 'react';
import styles from './background.module.css';

export { styles as backgroundStyles };

/** Deterministic PRNG so particles render identically on server and client. */
export function seeded(seed: number): () => number {
  return () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

/** Round for stable, compact inline styles. */
export const r2 = (n: number) => Math.round(n * 100) / 100;

/** Props every background accepts. `T` is the background's theme (colour tokens). */
export type BackgroundProps<T> = {
  /** Override any of the background's colours. */
  theme?: Partial<T>;
  /** Freeze animated backgrounds. Static backgrounds ignore it. */
  paused?: boolean;
  className?: string;
  style?: CSSProperties;
  /** Your content. The background paints behind it. */
  children?: ReactNode;
};

// baseColor → --base-color, blob1 → --blob-1
const toVar = (key: string) => `--${key.replace(/([A-Z])/g, '-$1').replace(/(\d+)/g, '-$1').toLowerCase()}`;

type BackgroundFrameProps<T extends Record<string, string>> = BackgroundProps<T> & {
  defaultTheme: T;
  /** The texture itself, rendered in an absolutely positioned layer behind the children. */
  layer: ReactNode;
};

export function BackgroundFrame<T extends Record<string, string>>({
  defaultTheme,
  layer,
  theme,
  paused,
  className,
  style,
  children,
}: BackgroundFrameProps<T>) {
  const vars: Record<string, string> = {};
  for (const [key, value] of Object.entries({ ...defaultTheme, ...theme })) {
    if (value) vars[toVar(key)] = value;
  }
  const classes = [styles.root, paused && styles.paused, className].filter(Boolean).join(' ');

  return (
    <div className={classes} style={{ ...vars, ...style } as CSSProperties}>
      <div className={styles.layer} aria-hidden="true">
        {layer}
      </div>
      {children}
    </div>
  );
}
components/backgrounds/background.module.css
/* The root establishes a stacking context so the layer sits behind children
   without escaping behind the page. */
.root {
  position: relative;
  isolation: isolate;
}

.layer {
  position: absolute;
  inset: 0;
  z-index: -1;
  overflow: hidden;
  pointer-events: none;
  /* Lets particles move by the layer's own size: 100cqh is its full height, 100cqw its width. */
  container-type: size;
}

/* A particle starts just above the layer at a random horizontal position. */
.particle {
  position: absolute;
  top: 0;
}

.paused,
.paused * { animation-play-state: paused !important; }

@media (prefers-reduced-motion: reduce) {
  .layer,
  .layer * { animation: none !important; }
  /* Without motion, particles rest at a scattered position instead of the top edge. */
  .particle { top: var(--y, 0); }
}