← Scenes

City

<CityScene /> A tram passing on a rainy city night.

Animated · Source · Markdown

Theme
Usage
<CityScene />
Props and theme tokens
PropTypeDefaultDescription
durationnumber24Seconds for the tram to cross the frame.
themePartial<CityTheme>—Colour overrides. Keys listed under theme tokens.
pausedbooleanfalseFreezes all motion. Also paused automatically while off-screen.
classNamestring—Applied to the root element.
styleCSSProperties—Merged into the root element style, after theme variables.
childrenReactNode—Rendered above the art in an absolutely positioned layer.
Theme keyCSS variableDefault
skyTop--sky-top#151b30
skyBottom--sky-bottom#3d3a58
far--far#262d48
near--near#1a2038
street--street#12172a
window--window#ffcf7a
lamp--lamp#ffe2a3
tram--tram#c8463a
umbrella--umbrella#e0503c
sign--sign#ff7aa8
rain--rain#c9d6ec
Source files

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

components/scenes/city-scene.tsx
// <CityScene>: a city street on a rainy night. Windows glow in two rows of buildings, a streetlamp
// lights the rain falling through it, a figure waits under a red umbrella, and a tram hums past,
// its lights smeared in the wet road. `duration` is seconds for the tram to cross (default 24).

import type { CSSProperties } from 'react';
import { W, seeded } from './geometry';
import { SceneFrame, useSvgId, type SceneProps } from './scene';
import styles from './city-scene.module.css';

export const cityTheme = {
  skyTop: '#151b30',
  skyBottom: '#3d3a58',
  far: '#262d48',
  near: '#1a2038',
  street: '#12172a',
  window: '#ffcf7a',
  lamp: '#ffe2a3',
  tram: '#c8463a',
  umbrella: '#e0503c',
  sign: '#ff7aa8',
  rain: '#c9d6ec',
};
export type CityTheme = typeof cityTheme;

const CURB = 532;
const RAIL = 586;
const WIRE = 470;

const rand = seeded(73);

type Building = { x: number; w: number; top: number; windows: { x: number; y: number; lit: boolean; flicker: number | null }[] };

/** A row of buildings standing on `base`, with a grid of windows, some lit. */
function row(base: number, minH: number, maxH: number, gap: number, litChance: number): Building[] {
  const out: Building[] = [];
  for (let x = -10; x < W + 10; ) {
    const w = 34 + rand() * 40;
    const top = base - (minH + rand() * (maxH - minH));
    const windows: Building['windows'] = [];
    for (let wy = top + 12; wy < base - 18; wy += 16) {
      for (let wx = x + 7; wx < x + w - 10; wx += 11) {
        const lit = rand() < litChance;
        windows.push({ x: wx, y: wy, lit, flicker: lit && rand() < 0.08 ? 3 + rand() * 6 : null });
      }
    }
    out.push({ x, w, top, windows });
    x += w + gap;
  }
  return out;
}

const FAR = row(486, 120, 260, 2, 0.28);
const NEAR = row(CURB, 70, 190, 6, 0.4).filter(b => b.x < 120 || b.x > 250);

// Rain: two depths. Each streak rests at a scattered (x, y); the fall runs relative to it.
const RAIN = [
  { count: 50, len: [8, 12], fall: [0.9, 1.3], opacity: 0.35, width: 1 },
  { count: 28, len: [14, 22], fall: [0.6, 0.8], opacity: 0.6, width: 1.4 },
].flatMap(d =>
  Array.from({ length: d.count }, () => {
    const y = rand() * 700;
    return {
      x: rand() * (W + 60),
      y,
      len: d.len[0] + rand() * (d.len[1] - d.len[0]),
      fall: d.fall[0] + rand() * (d.fall[1] - d.fall[0]),
      delay: rand() * 2,
      opacity: d.opacity,
      width: d.width,
    };
  }),
);

const RIPPLES = Array.from({ length: 12 }, () => ({
  x: rand() * W,
  y: CURB + 70 + rand() * 90,
  r: 6 + rand() * 8,
  dur: 1.2 + rand() * 1.2,
  delay: rand() * 2,
}));

const TRAM_WINDOWS = [-112, -92, -72, -52, -32, -12];

function Tram() {
  return (
    <g>
      {/* Pantograph up to the wire */}
      <path d={`M-70 -46 L-58 -${RAIL - WIRE - 2} L-46 -46`} fill="none" stroke="#0d1122" strokeWidth="2" />
      <path d="M-128 -8 V-40 Q-128 -48 -120 -48 H2 Q14 -48 18 -36 L22 -8 Z" fill="var(--tram)" />
      <rect x="-128" y="-14" width="150" height="6" fill="#000" opacity="0.25" />
      <path d="M-128 -44 H8" stroke="#ffffff" strokeOpacity="0.25" strokeWidth="2" />
      <g fill="var(--window)">
        {TRAM_WINDOWS.map(x => (
          <rect key={x} x={x} y="-38" width="14" height="14" rx="2" />
        ))}
        <path d="M6 -38 H12 Q16 -36 17 -26 H6 Z" />
      </g>
      <circle cx="18" cy="-14" r="2.4" fill="var(--lamp)" />
      <g fill="#0d1122">
        {[-110, -90, -6, 14].map(x => (
          <circle key={x} cx={x - 8} cy="-6" r="4.5" />
        ))}
      </g>
    </g>
  );
}

/** A city street on a rainy night, with a tram passing. */
export function CityScene(props: SceneProps<CityTheme>) {
  const sky = useSvgId('sky');
  const cone = useSvgId('cone');
  const glow = useSvgId('glow');
  const smear = useSvgId('smear');
  const street = useSvgId('street');

  return (
    <SceneFrame
      {...props}
      defaultTheme={cityTheme}
      defaultDuration={24}
      art={
        <>
          <defs>
            <linearGradient id={sky} x1="0" y1="0" x2="0" y2="1">
              <stop offset="0" stopColor="var(--sky-top)" />
              <stop offset="0.7" stopColor="var(--sky-bottom)" />
            </linearGradient>
            <linearGradient id={street} x1="0" y1="0" x2="0" y2="1">
              <stop offset="0" stopColor="var(--sky-bottom)" stopOpacity="0.35" />
              <stop offset="0.25" stopColor="var(--street)" />
            </linearGradient>
            <linearGradient id={cone} x1="0" y1="0" x2="0" y2="1">
              <stop offset="0" stopColor="var(--lamp)" stopOpacity="0.5" />
              <stop offset="1" stopColor="var(--lamp)" stopOpacity="0" />
            </linearGradient>
            <radialGradient id={glow}>
              <stop offset="0" stopColor="var(--lamp)" stopOpacity="0.85" />
              <stop offset="1" stopColor="var(--lamp)" stopOpacity="0" />
            </radialGradient>
            {/* Light smeared down the wet road */}
            <linearGradient id={smear} x1="0" y1="0" x2="0" y2="1">
              <stop offset="0" stopColor="var(--window)" stopOpacity="0.55" />
              <stop offset="1" stopColor="var(--window)" stopOpacity="0" />
            </linearGradient>
          </defs>

          <rect width={W} height="700" fill={`url(#${sky})`} />

          {/* Two rows of buildings, the far one hazier */}
          {[
            { list: FAR, fill: 'var(--far)', opacity: 0.55 },
            { list: NEAR, fill: 'var(--near)', opacity: 0.9 },
          ].map(({ list, fill, opacity }, r) => (
            <g key={r}>
              {list.map((b, i) => (
                <g key={i}>
                  <rect x={b.x.toFixed(1)} y={b.top.toFixed(1)} width={b.w.toFixed(1)} height={(700 - b.top).toFixed(1)} fill={fill} />
                  <g fill="var(--window)" opacity={opacity}>
                    {b.windows
                      .filter(w => w.lit)
                      .map((w, j) => (
                        <rect
                          key={j}
                          className={w.flicker ? styles.flicker : undefined}
                          style={w.flicker ? { animationDuration: `${w.flicker.toFixed(2)}s` } : undefined}
                          x={w.x.toFixed(1)}
                          y={w.y.toFixed(1)}
                          width="5"
                          height="7"
                        />
                      ))}
                  </g>
                </g>
              ))}
            </g>
          ))}

          {/* Café front between the near buildings: awning, lit window and a neon sign */}
          <g>
            <rect x="128" y="452" width="116" height="80" fill="var(--near)" />
            <rect x="140" y="482" width="64" height="40" fill="var(--window)" opacity="0.75" />
            <path d="M161 482 V522 M182 482 V522 M140 506 H204" stroke="var(--near)" strokeWidth="2.5" />
            <rect x="146" y="510" width="52" height="3" fill="#000" opacity="0.2" />
            <path d="M134 470 H238 L232 482 H140 Z" fill="var(--umbrella)" />
            <path d="M140 482 l6 5 l6 -5 l6 5 l6 -5 l6 5 l6 -5 l6 5 l6 -5 l6 5 l6 -5 l6 5 l6 -5 l6 5 l6 -5 l6 5 l2 -5" fill="none" stroke="var(--umbrella)" strokeWidth="2" />
            <rect x="212" y="490" width="22" height="42" fill="#0d1122" />
            <g className={styles.neon}>
              <rect x="150" y="456" width="44" height="10" rx="5" fill="none" stroke="var(--sign)" strokeWidth="2" />
              <rect x="146" y="452" width="52" height="18" rx="9" fill="var(--sign)" opacity="0.15" />
            </g>
          </g>

          {/* Overhead wire for the tram */}
          <path d={`M0 ${WIRE} Q200 ${WIRE + 8} ${W} ${WIRE}`} fill="none" stroke="#0d1122" strokeWidth="1.4" />

          {/* Street, wet and shining */}
          <rect x="0" y={CURB} width={W} height={700 - CURB} fill={`url(#${street})`} />
          <rect x="0" y={CURB} width={W} height="6" fill="#2a3150" />
          <rect x="140" y={CURB + 6} width="64" height="110" fill={`url(#${smear})`} opacity="0.5" className={styles.shimmer} />
          <rect x="284" y={CURB + 6} width="22" height="130" fill={`url(#${smear})`} opacity="0.6" className={styles.shimmer} style={{ animationDelay: '-0.7s' }} />
          <path d={`M0 ${RAIL} H${W} M0 ${RAIL + 10} H${W}`} stroke="#3a4266" strokeWidth="1.5" />

          <g fill="none" stroke="var(--rain)" strokeWidth="1">
            {RIPPLES.map((r, i) => (
              <ellipse
                key={i}
                className={styles.ripple}
                cx={r.x.toFixed(1)}
                cy={r.y.toFixed(1)}
                rx={r.r.toFixed(1)}
                ry={(r.r * 0.3).toFixed(1)}
                style={{ animationDuration: `${r.dur.toFixed(2)}s`, animationDelay: `${(-r.delay).toFixed(2)}s` }}
              />
            ))}
          </g>

          {/* Tram, with its reflection travelling beneath it */}
          <g className={styles.tram}>
            <g transform={`translate(0 ${RAIL})`}>
              <Tram />
            </g>
            <g transform={`translate(0 ${RAIL + 6}) scale(1 -0.8)`} opacity="0.22">
              <Tram />
            </g>
          </g>

          {/* Streetlamp: a pool of light with the rain showing in it */}
          <g>
            <path d="M296 380 L250 700 H350 Z" fill={`url(#${cone})`} opacity="0.5" />
            <rect x="293" y="380" width="4" height={CURB - 380 + 4} fill="#0d1122" />
            <path d="M286 380 H306 L302 372 H290 Z" fill="#0d1122" />
            <circle className={styles.lamp} cx="296" cy="384" r="26" fill={`url(#${glow})`} />
            <rect x="290" y="380" width="12" height="5" rx="2" fill="var(--lamp)" />
          </g>

          {/* Someone waiting under a red umbrella */}
          <g transform={`translate(84 ${CURB + 2})`}>
            <path d="M-6 0 V-30 Q-6 -36 0 -36 Q6 -36 6 -30 V0 Z" fill="#0d1122" />
            <circle cx="0" cy="-41" r="5.5" fill="#0d1122" />
            <path d="M0 -36 V-60" stroke="#0d1122" strokeWidth="1.6" />
            <g className={styles.umbrella}>
              <path d="M-24 -56 Q0 -80 24 -56 Q18 -60 12 -56 Q6 -60 0 -56 Q-6 -60 -12 -56 Q-18 -60 -24 -56 Z" fill="var(--umbrella)" />
            </g>
          </g>

          {/* Rain, drawn last so it falls in front of everything */}
          <g stroke="var(--rain)" strokeLinecap="round">
            {RAIN.map((d, i) => (
              <g key={i} transform={`translate(${d.x.toFixed(1)} ${d.y.toFixed(1)})`} opacity={d.opacity}>
                <path
                  className={styles.fall}
                  style={
                    {
                      '--from': `${(-d.y - 30).toFixed(1)}px`,
                      '--to': `${(720 - d.y).toFixed(1)}px`,
                      animationDuration: `${d.fall.toFixed(2)}s`,
                      animationDelay: `${(-d.delay).toFixed(2)}s`,
                    } as CSSProperties
                  }
                  d={`M0 0 l${(-d.len * 0.2).toFixed(1)} ${d.len.toFixed(1)}`}
                  strokeWidth={d.width}
                />
              </g>
            ))}
          </g>
        </>
      }
    />
  );
}
components/scenes/city-scene.module.css
/* The tram crosses once per --duration; everything else is ambient. */
.tram {
  transform: translateX(240px); /* resting spot for reduced motion */
  animation: tram var(--duration) linear infinite;
  animation-delay: calc(var(--duration) * -0.4);
}

/* Rain falls past each streak's resting point (set through --from and --to), drifting left
   by a fifth of the distance to match the streak's slant. */
.fall { animation: fall linear infinite; }

.ripple {
  transform-box: fill-box;
  transform-origin: center;
  opacity: 0;
  animation: ripple ease-out infinite;
}

.flicker { animation: flicker steps(1) infinite; }
.neon    { animation: neon 5s steps(1) infinite; }
.lamp    { animation: lamp 3s ease-in-out infinite alternate; }
.shimmer {
  transform-box: fill-box;
  transform-origin: 50% 0%;
  animation: shimmer 1.6s ease-in-out infinite alternate;
}

.umbrella {
  transform-box: fill-box;
  transform-origin: 50% 100%;
  animation: tilt 4s ease-in-out infinite alternate;
}

@keyframes tram {
  from { transform: translateX(-30px); }
  to   { transform: translateX(560px); }
}
@keyframes fall {
  from { transform: translate(0, var(--from)); }
  to   { transform: translate(calc((var(--to) - var(--from)) * -0.2), var(--to)); }
}
@keyframes ripple {
  0%   { transform: scale(0.2); opacity: 0; }
  15%  { opacity: 0.7; }
  100% { transform: scale(1); opacity: 0; }
}
@keyframes flicker {
  0%, 100% { opacity: 1; }
  60%      { opacity: 0.15; }
  64%      { opacity: 1; }
}
/* The neon sign stutters twice, then holds. */
@keyframes neon {
  0%, 100% { opacity: 1; }
  80%      { opacity: 0.3; }
  82%      { opacity: 1; }
  85%      { opacity: 0.4; }
  87%      { opacity: 1; }
}
@keyframes lamp    { from { opacity: 0.75; } to { opacity: 1; } }
@keyframes shimmer { from { transform: scaleX(0.85); opacity: 0.35; } to { transform: scaleX(1.1); opacity: 0.6; } }
@keyframes tilt {
  from { transform: rotate(-4deg); }
  to   { transform: rotate(3deg); }
}
components/scenes/geometry.ts
// Shared geometry for the scenes. Every scene draws into a portrait 400×700 viewBox.

export const W = 400;
export const H = 700;

/** One sine term of a ridge: [amplitude, wavelength, phase?]. */
export type Ridge = ReadonlyArray<readonly [amp: number, len: number, phase?: number]>;

/** Height of a ridge made of summed sines at x. */
export function ridgeY(x: number, y: number, components: Ridge): number {
  let h = y;
  for (const [amp, len, phase = 0] of components) h += amp * Math.sin((x / len) * Math.PI * 2 + phase);
  return h;
}

/**
 * A closed band 2×W wide whose edge follows ridgeY, filled toward `toward` (H = down, 0 = up).
 * Sliding it by W loops seamlessly as long as every wavelength divides W evenly.
 */
export function bandPath(y: number, components: Ridge, toward = H): string {
  let d = `M0 ${toward}`;
  for (let x = 0; x <= W * 2; x += 8) d += ` L${x} ${ridgeY(x, y, components).toFixed(1)}`;
  return `${d} L${W * 2} ${toward} Z`;
}

/** Deterministic PRNG so scattered details (stars, trees, bubbles) 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;
  };
}
components/scenes/scene.tsx
import { useId, type CSSProperties, type ReactNode } from 'react';
import { H, bandPath, type Ridge } from './geometry';
import styles from './scene.module.css';

export { SceneFrame } from './scene-frame';
export { styles as sceneStyles };

/** Props every scene accepts. `T` is the scene's theme (colour tokens). */
export type SceneProps<T> = {
  /** Seconds for the scene's main crossing. */
  duration?: number;
  /** Override any of the scene's colours. */
  theme?: Partial<T>;
  /** Freeze the animation. Scenes also pause on their own while off-screen. */
  paused?: boolean;
  className?: string;
  style?: CSSProperties;
  /** Content rendered on top of the scene (headings, buttons…). */
  children?: ReactNode;
};

/** A per-instance prefix for gradient ids, so several copies of a scene can share a page. */
export function useSvgId(name: string): string {
  return `qs${useId().replace(/[^a-zA-Z0-9_-]/g, '')}${name}`;
}

export type WaveLayerProps = {
  y: number;
  components: Ridge;
  fill: string;
  /** Seconds per sideways loop; 0 keeps the band still. */
  drift?: number;
  /** Seconds per up-and-down swell; 0 disables it. */
  swell?: number;
  reverse?: boolean;
  delay?: number;
  /** Fill toward the bottom (H) or the top (0) of the scene. */
  toward?: number;
  opacity?: number;
};

/** A band of water, hills or sand that can drift sideways and swell. */
export function WaveLayer({ y, components, fill, drift = 0, swell = 0, reverse, delay = 0, toward = H, opacity }: WaveLayerProps) {
  const path = (
    <path
      className={drift ? `${styles.drift} ${reverse ? styles.reverse : ''}` : undefined}
      style={drift ? { animationDuration: `${drift}s` } : undefined}
      fill={fill}
      opacity={opacity}
      d={bandPath(y, components, toward)}
    />
  );
  if (!swell) return path;
  return (
    <g className={styles.swell} style={{ animationDuration: `${swell}s`, animationDelay: `${-delay}s` }}>
      {path}
    </g>
  );
}
components/scenes/scene-frame.tsx
'use client';

import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import { H, W } from './geometry';
import styles from './scene.module.css';

// skyTop → --sky-top, water1 → --water-1
const toVar = (key: string) => `--${key.replace(/([A-Z])/g, '-$1').replace(/(\d+)/g, '-$1').toLowerCase()}`;

export type SceneFrameProps = {
  defaultTheme: Record<string, string>;
  defaultDuration: number;
  art: ReactNode;
  duration?: number;
  theme?: Record<string, string | undefined>;
  paused?: boolean;
  className?: string;
  style?: CSSProperties;
  children?: ReactNode;
};

/**
 * The frame shared by every scene: sizing, theming, overlay and off-screen pausing.
 * It's the only client component; the scene art itself renders on the server.
 */
export function SceneFrame({
  defaultTheme,
  defaultDuration,
  art,
  duration = defaultDuration,
  theme,
  paused,
  className,
  style,
  children,
}: SceneFrameProps) {
  const ref = useRef<HTMLDivElement>(null);
  const [offscreen, setOffscreen] = useState(false);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const observer = new IntersectionObserver(([entry]) => setOffscreen(!entry.isIntersecting));
    observer.observe(el);
    return () => observer.disconnect();
  }, []);

  const vars: Record<string, string> = { '--duration': `${duration}s` };
  for (const [key, value] of Object.entries({ ...defaultTheme, ...theme })) {
    if (value) vars[toVar(key)] = value;
  }

  const classes = [styles.root, (paused || offscreen) && styles.paused, className].filter(Boolean).join(' ');

  return (
    <div ref={ref} className={classes} style={{ ...vars, ...style } as CSSProperties}>
      <svg
        className={styles.art}
        viewBox={`0 0 ${W} ${H}`}
        preserveAspectRatio="xMidYMid slice"
        aria-hidden="true"
        focusable="false"
      >
        {art}
      </svg>
      {children && <div className={styles.overlay}>{children}</div>}
    </div>
  );
}
components/scenes/scene.module.css
.root {
  position: relative;
  display: block;
  width: 100%;
  aspect-ratio: 4 / 7;
  overflow: hidden;
  contain: content;
}

.art {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}

.overlay {
  position: absolute;
  inset: 0;
}

/* Shared motion primitives used by every scene */
.drift   { animation: drift linear infinite; }
.reverse { animation-name: driftReverse; }
.swell   { animation: swell ease-in-out infinite alternate; }
.across  { animation: across linear infinite; }
.glint   { animation: glint ease-in-out infinite alternate; }

@keyframes drift        { to   { transform: translateX(-400px); } }
@keyframes driftReverse { from { transform: translateX(-400px); } to { transform: translateX(0); } }
@keyframes swell  { from { transform: translateY(-3px); } to { transform: translateY(3px); } }
@keyframes across { from { transform: translateX(-260px); } to { transform: translateX(660px); } }
@keyframes glint  { from { opacity: 0.25; } to { opacity: 0.85; } }

.paused * { animation-play-state: paused !important; }

@media (prefers-reduced-motion: reduce) {
  .root * { animation: none !important; }
}