← Backgrounds

Clouds

<CloudsBackground /> Soft clouds drifting across the sky in parallax.

Animated · Source · Markdown

Your appContent sits on top of the background.
Theme
Usage
<CloudsBackground>
  {children}
</CloudsBackground>
Props and theme tokens
PropTypeDefaultDescription
themePartial<CloudsTheme>—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#9ccbee
skyBottom--sky-bottom#eef6fc
cloud--cloud#ffffff
cloudShade--cloud-shade#e2eef8
sun--sun#fff4d1
Source files

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

components/backgrounds/clouds-background.tsx
// <CloudsBackground>: soft clouds drifting across the sky in three parallax layers,
// under a gently glowing sun. Animated.

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

export const cloudsTheme = {
  skyTop: '#9ccbee',
  skyBottom: '#eef6fc',
  cloud: '#ffffff',
  cloudShade: '#e2eef8',
  sun: '#fff4d1',
};
export type CloudsTheme = typeof cloudsTheme;

// Far layers are smaller, fainter and slower; the near layer is larger and quicker.
const LAYERS = [
  { count: 5, width: [80, 110], duration: [110, 140], opacity: 0.55, top: [8, 38] },
  { count: 4, width: [130, 170], duration: [75, 95], opacity: 0.8, top: [20, 60] },
  { count: 3, width: [190, 240], duration: [50, 62], opacity: 0.95, top: [45, 82] },
];

const rand = seeded(88);
const between = ([a, b]: number[]) => a + rand() * (b - a);
const CLOUDS = LAYERS.flatMap(layer =>
  Array.from({ length: layer.count }, (_, i) => {
    const duration = r2(between(layer.duration));
    return {
      width: r2(between(layer.width)),
      top: r2(between(layer.top)),
      duration,
      // Spread evenly along the loop so the sky is never empty.
      delay: r2(duration * ((i + 0.2 + rand() * 0.6) / layer.count)),
      opacity: layer.opacity,
      flip: rand() > 0.5,
    };
  }),
);

/** One cloud: a flat-bottomed pill with three rounded puffs, shaded toward the base. */
function Cloud({ width, flip, gradient }: { width: number; flip: boolean; gradient: string }) {
  return (
    <svg
      width={width}
      height={width * 0.42}
      viewBox="0 0 100 42"
      style={{ display: 'block', transform: flip ? 'scaleX(-1)' : undefined }}
    >
      <g fill={`url(#${gradient})`}>
        <rect x="4" y="22" width="92" height="18" rx="9" />
        <circle cx="30" cy="24" r="14" />
        <circle cx="52" cy="17" r="17" />
        <circle cx="74" cy="25" r="12" />
      </g>
    </svg>
  );
}

/** Soft clouds drifting across the sky in three parallax layers. */
export function CloudsBackground(props: BackgroundProps<CloudsTheme>) {
  const gradient = 'qs-clouds-shade';
  return (
    <BackgroundFrame
      {...props}
      defaultTheme={cloudsTheme}
      layer={
        <>
          <div className={styles.sky} />
          <div className={styles.sun} />
          <svg width="0" height="0" style={{ position: 'absolute' }} aria-hidden="true">
            <defs>
              {/* In each cloud's own 100×42 space, so the whole cloud shades as one shape. */}
              <linearGradient id={gradient} gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="0" y2="42">
                <stop offset="0.5" stopColor="var(--cloud)" />
                <stop offset="1" stopColor="var(--cloud-shade)" />
              </linearGradient>
            </defs>
          </svg>
          {CLOUDS.map((c, i) => (
            <div
              key={i}
              className={styles.drift}
              style={{ top: `${c.top}%`, opacity: c.opacity, animationDuration: `${c.duration}s`, animationDelay: `${-c.delay}s` } as CSSProperties}
            >
              <Cloud width={c.width} flip={c.flip} gradient={gradient} />
            </div>
          ))}
        </>
      }
    />
  );
}
components/backgrounds/clouds-background.module.css
.sky {
  position: absolute;
  inset: 0;
  background: linear-gradient(var(--sky-top), var(--sky-bottom));
}

/* A soft sun: a pale disc inside a wide glow that breathes slowly. */
.sun {
  position: absolute;
  top: -60px;
  right: -40px;
  width: 280px;
  height: 280px;
  border-radius: 50%;
  background: radial-gradient(circle, var(--sun) 0 18%, color-mix(in srgb, var(--sun) 45%, transparent) 30%, transparent 68%);
  animation: glow 6s ease-in-out infinite alternate;
}

/* Clouds travel the full width, from just off the left edge to just off the right. */
.drift {
  position: absolute;
  left: 0;
  animation: drift linear infinite;
  will-change: transform;
}

@keyframes drift {
  from { transform: translateX(-280px); }
  to   { transform: translateX(calc(100cqw + 40px)); }
}
@keyframes glow {
  from { opacity: 0.85; transform: scale(1); }
  to   { opacity: 1; transform: scale(1.06); }
}
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); }
}