← Backgrounds

Rain

<RainBackground /> Soft rain on a slant, rippling the puddles below.

Animated · Source · Markdown

Your appContent sits on top of the background.
Theme
Usage
<RainBackground>
  {children}
</RainBackground>
Props and theme tokens
PropTypeDefaultDescription
themePartial<RainTheme>—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#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.

components/backgrounds/rain-background.tsx
// <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>
        </>
      }
    />
  );
}
components/backgrounds/rain-background.module.css
.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; }
}
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); }
}