← Backgrounds
Clouds
<CloudsBackground /> Soft clouds drifting across the sky in parallax.
Your appContent sits on top of the background.
<CloudsBackground>
{children}
</CloudsBackground>Props and theme tokens
| Prop | Type | Default | Description |
|---|---|---|---|
theme | Partial<CloudsTheme> | — | 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 | #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.
// <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>
))}
</>
}
/>
);
}
.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); }
}
// 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); }
}