← Backgrounds
Snowfall
<SnowfallBackground /> Snow falling at three depths onto soft drifts.
Your appContent sits on top of the background.
<SnowfallBackground>
{children}
</SnowfallBackground>Props and theme tokens
| Prop | Type | Default | Description |
|---|---|---|---|
theme | Partial<SnowfallTheme> | — | Colour overrides. Keys listed under theme tokens. |
paused | boolean | false | Freezes the animation. |
className | string | — | Applied to the root element. Size it like any block element. |
style | CSSProperties | — | Merged into the root element style, after theme variables. |
children | ReactNode | — | Your content. The background paints behind it. |
| Theme key | CSS variable | Default |
|---|---|---|
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.
// <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>
</>
}
/>
);
}
.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); } }
// 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>
);
}
/* 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); }
}