← Backgrounds
Night sky
<NightSkyBackground /> Twinkling stars, the Milky Way and shooting stars.
Your appContent sits on top of the background.
<NightSkyBackground>
{children}
</NightSkyBackground>Props and theme tokens
| Prop | Type | Default | Description |
|---|---|---|---|
theme | Partial<NightSkyTheme> | — | 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 | #070b1f |
skyBottom | --sky-bottom | #1d2550 |
milkyWay | --milky-way | #8f9fe0 |
star | --star | #ffffff |
warmStar | --warm-star | #ffe2b0 |
Source files
Copy into components/backgrounds/. The shared files are needed once for all backgrounds.
// <NightSkyBackground>: a deep night sky with stars twinkling at three sizes, a faint band of the
// Milky Way, and a shooting star every few seconds. Animated.
import type { CSSProperties } from 'react';
import { BackgroundFrame, r2, seeded, type BackgroundProps } from './background';
import styles from './night-sky-background.module.css';
export const nightSkyTheme = {
skyTop: '#070b1f',
skyBottom: '#1d2550',
milkyWay: '#8f9fe0',
star: '#ffffff',
warmStar: '#ffe2b0',
};
export type NightSkyTheme = typeof nightSkyTheme;
const rand = seeded(83);
const STARS = Array.from({ length: 110 }, () => {
const big = rand() > 0.9;
return {
x: r2(rand() * 100),
y: r2(rand() * 100),
size: big ? r2(2.5 + rand() * 1.5) : r2(0.8 + rand() * 1.4),
warm: rand() > 0.8,
twinkle: r2(2 + rand() * 4),
delay: r2(rand() * 6),
big,
};
});
// Shooting stars: where each starts, and when in its long, mostly empty cycle it flashes.
const METEORS = [
{ x: 72, y: 12, cycle: 9, delay: 2 },
{ x: 40, y: 6, cycle: 13, delay: 8 },
{ x: 90, y: 30, cycle: 17, delay: 12 },
];
/** A deep night sky with twinkling stars, the Milky Way and shooting stars. */
export function NightSkyBackground(props: BackgroundProps<NightSkyTheme>) {
return (
<BackgroundFrame
{...props}
defaultTheme={nightSkyTheme}
layer={
<>
<div className={styles.sky} />
<div className={styles.milkyWay} />
{STARS.map((s, i) => (
<span
key={i}
className={`${styles.star} ${s.big ? styles.big : ''}`}
style={{
left: `${s.x}%`,
top: `${s.y}%`,
width: s.size,
height: s.size,
background: s.warm ? 'var(--warm-star)' : 'var(--star)',
animationDuration: `${s.twinkle}s`,
animationDelay: `${-s.delay}s`,
}}
/>
))}
{METEORS.map((m, i) => (
<span
key={i}
className={styles.meteor}
style={{ left: `${m.x}%`, top: `${m.y}%`, animationDuration: `${m.cycle}s`, animationDelay: `${-m.delay}s` } as CSSProperties}
/>
))}
</>
}
/>
);
}
.sky {
position: absolute;
inset: 0;
background: linear-gradient(var(--sky-top), var(--sky-bottom));
}
/* A soft diagonal band, made of two blurred ellipses of light. */
.milkyWay {
position: absolute;
inset: -20%;
background:
radial-gradient(ellipse 60% 14% at 50% 50%, color-mix(in srgb, var(--milky-way) 30%, transparent), transparent 70%),
radial-gradient(ellipse 30% 8% at 60% 46%, color-mix(in srgb, var(--milky-way) 22%, transparent), transparent 70%);
transform: rotate(-28deg);
opacity: 0.8;
}
.star {
position: absolute;
border-radius: 50%;
animation: twinkle ease-in-out infinite alternate;
}
/* The brightest stars get a soft glow. */
.big { box-shadow: 0 0 6px 1px color-mix(in srgb, var(--star) 55%, transparent); }
/* A streak that is invisible for most of its cycle, then races down-left and fades. */
.meteor {
position: absolute;
width: 90px;
height: 1.5px;
border-radius: 1px;
background: linear-gradient(to left, var(--star), transparent);
transform-origin: 0 50%;
opacity: 0;
animation: meteor linear infinite;
}
@keyframes twinkle {
from { opacity: 0.25; transform: scale(0.8); }
to { opacity: 1; transform: scale(1); }
}
@keyframes meteor {
0%, 92% { transform: rotate(150deg) translateX(0) scaleX(0.3); opacity: 0; }
93% { opacity: 1; }
98% { transform: rotate(150deg) translateX(-160px) scaleX(1); opacity: 0; }
100% { transform: rotate(150deg) translateX(-160px) scaleX(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); }
}