← Backgrounds
Rain
<RainBackground /> Soft rain on a slant, rippling the puddles below.
Your appContent sits on top of the background.
<RainBackground>
{children}
</RainBackground>Props and theme tokens
| Prop | Type | Default | Description |
|---|---|---|---|
theme | Partial<RainTheme> | — | 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 | #9fb3c4 |
skyBottom | --sky-bottom | #dbe4ec |
drop | --drop | #ffffff |
ground | --ground | #b7c6d3 |
ripple | --ripple | #ffffff |
Source files
Copy into components/backgrounds/. The shared files are needed once for all backgrounds.
// <RainBackground>: soft rain falling on a slant at two depths, with ripples spreading in the
// puddles along the bottom. Animated.
import type { CSSProperties } from 'react';
import { BackgroundFrame, backgroundStyles as base, r2, seeded, type BackgroundProps } from './background';
import styles from './rain-background.module.css';
export const rainTheme = {
skyTop: '#9fb3c4',
skyBottom: '#dbe4ec',
drop: '#ffffff',
ground: '#b7c6d3',
ripple: '#ffffff',
};
export type RainTheme = typeof rainTheme;
const LAYERS = [
{ count: 46, length: [10, 16], fall: [1.2, 1.6], width: 1, opacity: 0.45 },
{ count: 26, length: [20, 30], fall: [0.7, 0.95], width: 1.6, opacity: 0.75 },
];
const rand = seeded(61);
const between = ([a, b]: number[]) => a + rand() * (b - a);
const DROPS = LAYERS.flatMap(layer =>
Array.from({ length: layer.count }, () => ({
// Start further right than the box: the slant carries drops left as they fall.
x: r2(rand() * 125),
y: r2(rand() * 100),
length: r2(between(layer.length)),
fall: r2(between(layer.fall)),
delay: r2(rand() * 3),
width: layer.width,
opacity: layer.opacity,
})),
);
const RIPPLES = Array.from({ length: 14 }, () => ({
x: r2(3 + rand() * 94),
y: r2(6 + rand() * 30),
size: r2(14 + rand() * 18),
time: r2(1.6 + rand() * 1.4),
delay: r2(rand() * 3),
}));
/** Soft rain falling on a slant, with ripples in the puddles below. */
export function RainBackground(props: BackgroundProps<RainTheme>) {
return (
<BackgroundFrame
{...props}
defaultTheme={rainTheme}
layer={
<>
<div className={styles.sky} />
{DROPS.map((d, i) => (
<div
key={i}
className={`${base.particle} ${styles.fall}`}
style={{ left: `${d.x}%`, '--y': `${d.y}%`, animationDuration: `${d.fall}s`, animationDelay: `${-d.delay}s` } as CSSProperties}
>
<span className={styles.streak} style={{ height: d.length, width: d.width, opacity: d.opacity }} />
</div>
))}
<div className={styles.ground}>
{RIPPLES.map((r, i) => (
<span
key={i}
className={styles.ripple}
style={{ left: `${r.x}%`, bottom: `${r.y}%`, width: r.size, height: r.size * 0.32, animationDuration: `${r.time}s`, animationDelay: `${-r.delay}s` }}
/>
))}
</div>
</>
}
/>
);
}
.sky {
position: absolute;
inset: 0;
background: linear-gradient(var(--sky-top), var(--sky-bottom));
}
/* Drops fall the layer's full height plus a little, drifting left by a fifth of that: a slant
of about 11°, which the streak matches. */
.fall {
animation: fall linear infinite;
will-change: transform;
}
.streak {
display: block;
border-radius: 1px;
background: linear-gradient(transparent, var(--drop));
transform: rotate(11.3deg);
}
.ground {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 56px;
background: linear-gradient(transparent, var(--ground) 40%);
}
.ripple {
position: absolute;
border: 1px solid var(--ripple);
border-radius: 50%;
transform: translateX(-50%) scale(0.1);
opacity: 0;
animation: ripple ease-out infinite;
}
@keyframes fall {
from { transform: translate(0, -40px); }
to { transform: translate(calc(-0.2 * (100cqh + 80px)), calc(100cqh + 40px)); }
}
@keyframes ripple {
0% { transform: translateX(-50%) scale(0.1); opacity: 0; }
10% { opacity: 0.9; }
100% { transform: translateX(-50%) scale(1); opacity: 0; }
}
// 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); }
}