← Backgrounds

Fireflies

<FirefliesBackground /> Fireflies blinking over a dusky meadow.

Animated · Source · Markdown

Your appContent sits on top of the background.
Theme
Usage
<FirefliesBackground>
  {children}
</FirefliesBackground>
Props and theme tokens
PropTypeDefaultDescription
themePartial<FirefliesTheme>—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#0e1a30
skyBottom--sky-bottom#33284d
firefly--firefly#fbf8c4
glow--glow#d6f36a
grass--grass#0a1120
moon--moon#f3ecd2
Source files

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

components/backgrounds/fireflies-background.tsx
// <FirefliesBackground>: fireflies wandering and blinking over a dusky meadow,
// with a crescent moon and swaying grass. Animated.

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

export const firefliesTheme = {
  skyTop: '#0e1a30',
  skyBottom: '#33284d',
  firefly: '#fbf8c4',
  glow: '#d6f36a',
  grass: '#0a1120',
  moon: '#f3ecd2',
};
export type FirefliesTheme = typeof firefliesTheme;

const rand = seeded(73);
const offset = (range: number) => `${r2((rand() - 0.5) * range)}px`;

const FIREFLIES = Array.from({ length: 30 }, () => ({
  x: r2(3 + rand() * 94),
  y: r2(18 + rand() * 72),
  wander: r2(10 + rand() * 10),
  blink: r2(2.8 + rand() * 3.5),
  delay: r2(rand() * 10),
  path: { '--ax': offset(80), '--ay': offset(50), '--bx': offset(80), '--by': offset(50), '--cx': offset(80), '--cy': offset(50) },
  scale: r2(0.7 + rand() * 0.7),
}));

// Blades are placed at percentage positions so the meadow spans any width without stretching.
const BLADES = Array.from({ length: 70 }, () => ({
  x: r2(rand() * 101),
  h: r2(28 + rand() * 52),
  lean: r2((rand() - 0.5) * 16),
  w: r2(2.5 + rand() * 2.5),
  sway: r2(2.5 + rand() * 2.5),
  delay: r2(rand() * 4),
}));

/** Fireflies wandering and blinking over a dusky meadow. */
export function FirefliesBackground(props: BackgroundProps<FirefliesTheme>) {
  return (
    <BackgroundFrame
      {...props}
      defaultTheme={firefliesTheme}
      layer={
        <>
          <div className={styles.sky} />
          <svg className={styles.moon} viewBox="0 0 54 54">
            <defs>
              <mask id="qs-fireflies-moon">
                <rect width="54" height="54" fill="#fff" />
                <circle cx="36" cy="20" r="21" fill="#000" />
              </mask>
            </defs>
            <circle cx="27" cy="27" r="22" fill="var(--moon)" mask="url(#qs-fireflies-moon)" />
          </svg>

          {FIREFLIES.map((f, i) => (
            <div
              key={i}
              className={styles.firefly}
              style={{ left: `${f.x}%`, top: `${f.y}%`, animationDuration: `${f.wander}s`, animationDelay: `${-f.delay}s`, scale: f.scale, ...f.path } as CSSProperties}
            >
              <span className={styles.light} style={{ animationDuration: `${f.blink}s`, animationDelay: `${-f.delay}s` }} />
            </div>
          ))}

          <svg className={styles.grass}>
            {BLADES.map((b, i) => (
              <svg key={i} x={`${b.x}%`} y="100%" overflow="visible">
                <path
                  className={styles.blade}
                  style={{ animationDuration: `${b.sway}s`, animationDelay: `${-b.delay}s` }}
                  d={`M${-b.w} 0 Q${r2(b.lean * 0.4)} ${r2(-b.h * 0.6)} ${b.lean} ${-b.h} Q${r2(b.lean * 0.4 + 1)} ${r2(-b.h * 0.5)} ${b.w} 0 Z`}
                  fill="var(--grass)"
                />
              </svg>
            ))}
          </svg>
        </>
      }
    />
  );
}
components/backgrounds/fireflies-background.module.css
.sky {
  position: absolute;
  inset: 0;
  background: linear-gradient(var(--sky-top), var(--sky-bottom));
}

.moon {
  position: absolute;
  top: 28px;
  right: 36px;
  width: 54px;
  height: 54px;
  filter: drop-shadow(0 0 14px rgb(255 245 210 / 0.35));
}

/* Each firefly wanders a four-point loop and blinks on its own rhythm. */
.firefly {
  position: absolute;
  animation: wander ease-in-out infinite;
}

.light {
  display: block;
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: var(--firefly);
  box-shadow: 0 0 6px 2px var(--glow), 0 0 18px 6px color-mix(in srgb, var(--glow) 45%, transparent);
  animation: blink ease-in-out infinite;
}

.grass {
  position: absolute;
  left: 0;
  bottom: 0;
  width: 100%;
  height: 90px;
  overflow: visible;
}

.blade {
  animation: sway ease-in-out infinite alternate;
}

@keyframes wander {
  0%, 100% { transform: translate(0, 0); }
  25% { transform: translate(var(--ax), var(--ay)); }
  50% { transform: translate(var(--bx), var(--by)); }
  75% { transform: translate(var(--cx), var(--cy)); }
}
@keyframes blink {
  0%, 35%, 100% { opacity: 0.12; }
  50% { opacity: 1; }
  65% { opacity: 0.3; }
}
@keyframes sway {
  from { transform: rotate(-4deg); }
  to   { transform: rotate(4deg); }
}
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); }
}