← Backgrounds

Night sky

<NightSkyBackground /> Twinkling stars, the Milky Way and shooting stars.

Animated · Source · Markdown

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

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