Skyline
Animated City Skyline for React
A printed city along the foot of a section: halftone towers in the distance, ink towers in front with their windows knocked out, a flat sun and stippled trees. The first time it shows, the towers rise out of the ground and their windows switch on one by one. Give it your numbers and each one becomes a tower with a sign on its roof. Hover a tower and it lights up floor by floor. An elevated line can run across the front, with a train you can send from a button.
<Skyline />replays the demo when you let go.
Westlight maps which way every window faces and how far each door is from the station, so you can pick a home for the hours you are actually in it.
1,284 flats · 9 lines · updated hourly
"use client";
import * as React from "react";
import { Skyline } from "@/components/ui/skyline";
export function SkylineDemo() {
const [trains, setTrains] = React.useState(0);
return (
<div className="relative w-full max-w-5xl overflow-hidden rounded-2xl border border-zinc-950/10 bg-[#fafaf9] text-zinc-950 dark:border-white/10 dark:bg-[#07080c] dark:text-zinc-100">
<div className="relative z-10 mx-auto flex max-w-xl flex-col items-center px-6 pt-12 text-center sm:pt-16">
<h2 className="font-serif text-4xl leading-[1.02] font-normal tracking-[-0.02em] text-balance sm:text-5xl">
Rent the flat where the <em className="text-[#2747ff] dark:text-[#8292ff]">evening light</em> comes in
</h2>
<p className="mt-4 max-w-md text-[15px] leading-relaxed text-pretty text-zinc-600 dark:text-zinc-400">
Westlight maps which way every window faces and how far each door is from the station, so you can pick a
home for the hours you are actually in it.
</p>
<div className="mt-7 flex flex-wrap items-center justify-center gap-2.5">
<a
href="#"
className="inline-flex h-10 items-center gap-1.5 rounded-full bg-zinc-950 px-5 text-sm font-medium text-[#fafaf9] transition-colors hover:bg-zinc-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-950 dark:bg-zinc-100 dark:text-zinc-950 dark:hover:bg-white dark:focus-visible:outline-zinc-100"
>
Browse west-facing flats
<svg viewBox="0 0 16 16" fill="none" className="size-3.5" aria-hidden>
<path d="M3 8h10m-4-4 4 4-4 4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</a>
<button
type="button"
onClick={() => setTrains((n) => n + 1)}
className="inline-flex h-10 items-center rounded-full border border-zinc-950/20 px-5 text-sm font-medium transition-[background-color,scale] duration-150 hover:bg-zinc-950/5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-950 active:scale-[0.97] dark:border-white/25 dark:hover:bg-white/5 dark:focus-visible:outline-zinc-100"
>
Check the commute
</button>
</div>
<p className="mt-5 font-mono text-[11px] tracking-[0.12em] text-zinc-500 uppercase tabular-nums dark:text-zinc-500">
1,284 flats <span className="text-[#ff5a1f]">·</span> 9 lines <span className="text-[#ff5a1f]">·</span> updated hourly
</p>
</div>
<Skyline
seed={12}
train
headway={7}
departures={trains}
sun={0.64}
className="mt-6 h-56 [--skyline-sun:#ff5a1f] [--skyline-tint:#4562ff] sm:mt-2 sm:h-72 dark:text-[#1b1f2e] dark:[--skyline-light:#ffd27a] dark:[--skyline-tint:#3149f0]"
/>
</div>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content. Skyline: A printed city along the foot of a section: halftone towers in the distance, ink towers in front with their windows knocked out, a flat sun and stippled trees. The first time it shows, the towers rise out of the ground and their windows switch on one by one. Give it your numbers and each one becomes a tower with a sign on its roof. Hover a tower and it lights up floor by floor. An elevated line can run across the front, with a train you can send from a button. This demo, Hero City: A flat-hunting app's hero with the city along its foot. "Check the commute" sends a train across the line. 1. Install it: npx shadcn@latest add @keyframeui/skyline-demo (If the CLI can't find @keyframeui, use https://keyframeui.com/r/skyline-demo.json instead.) It adds the demo, components/skyline-demo.tsx, and the component, components/ui/skyline.tsx. 2. Make it ours: our copy, data, images and colors. Keep the structure the motion depends on. If we only need the effect, use <Skyline> on its own. 3. These props set its feel. Change them before you change the file: - seed (default 7): Reshapes the generated city, the far print, the clouds, the trees and which windows are lit. Any whole number. - lights (default 0.6): Share of the windows that are lit, from 0 to 1. A new value switches them on or off one at a time. - train (default false): An elevated line across the front, with trains crossing on it, behind the trees. - headway (default 9): Seconds between trains, counted from each departure. `0` runs them only when sent with `departures`. - trees (default true): Stippled trees along the foot of the scene, in front of everything. - idle (default true): Let the city live while it's on screen: a window switches on or off every so often and far dots blink. - intro (default true): Raise the towers and switch their lights on the first time the scene shows. - riseDuration (default 0.55): Seconds each tower takes to rise out of the ground the first time the scene shows. - stagger (default 0.03): Seconds between towers starting to rise, left to right. The spread stops growing after twelve towers. - trainSpeed (default 1): How fast trains cross, as a multiple of the default pace (about two seconds across 1280px). `2` is twice as fast. - flicker (default 0.34): Seconds between windows switching on or off while the city idles. 4. Don't change, in components/ui/skyline.tsx: - Towers, windows and glow are painted on the `near` canvas by one rAF loop that stops once nothing moves; React only re-renders for layout, signs and focus, never per frame. - Trains are two DOM layers moved by WAAPI `transform` alone, stacked between the `near` and `front` canvases, so they cross in front of the towers and behind the trees. - Windows switch on and off whole, one at a time in random order: lights, not fades. A far dot's blink repaints only its own cell. - Idle flicker, twinkles and the train timetable run only while the scene is on screen and the tab is visible. - Reduced motion draws the city lit at once, with nothing rising, blinking or crossing; hover still lights a tower and shows its sign, and a departure fades a train in standing still. Its timing is all in the TIMING block at the top of that file. Docs: https://keyframeui.com/components/skyline.md
Installation
Add the component on its own with the CLI, or copy the file in by hand.
The CLI writes components/ui/skyline.tsx and installs motion, clsx and tailwind-merge. It needs the @keyframeui registry in components.json; add it once per project.
npx shadcn@latest add @keyframeui/skylineInstall the dependencies
npm i motion clsx tailwind-mergeAdd the cn helper
Skip this if you already use shadcn/ui.
lib/utils.tsimport { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }Copy the source into your project
components/ui/skyline.tsx"use client"; import * as React from "react"; import { cubicBezier, useReducedMotion } from "motion/react"; import { cn } from "@/lib/utils"; /** * Keep: * - Towers, windows and glow are painted on the `near` canvas by one rAF loop that stops once * nothing moves; React only re-renders for layout, signs and focus, never per frame. * - Trains are two DOM layers moved by WAAPI `transform` alone, stacked between the `near` and * `front` canvases, so they cross in front of the towers and behind the trees. * - Windows switch on and off whole, one at a time in random order: lights, not fades. A far dot's * blink repaints only its own cell. * - Idle flicker, twinkles and the train timetable run only while the scene is on screen and the * tab is visible. * - Reduced motion draws the city lit at once, with nothing rising, blinking or crossing; hover * still lights a tower and shows its sign, and a departure fades a train in standing still. */ const TIMING = { /** An ink tower rising out of the ground on first view, in seconds (the `riseDuration` prop). */ riseDuration: 0.55, /** Between towers rising, left to right, in seconds (the `stagger` prop). */ stagger: 0.03, /** When a rising tower's windows start switching on, as a share of its rise. */ land: 0.6, /** A landed tower's windows switching on, one by one, in seconds. */ switchOn: 0.45, /** The far print, the trees and the sun fading up on first view, in ms. */ appear: 320, /** The sun rising into place on first view, in ms. */ sun: 600, /** The curve towers rise, reshape and light up on, as cubic-bezier points. */ ease: [0.23, 1, 0.32, 1], /** The same curve for the first view's fade-ups and the sun, as a CSS easing. */ cssEase: "cubic-bezier(0.23, 1, 0.32, 1)", /** A tower lighting up, bottom floor to top, in seconds. */ glow: 0.18, /** And going back, in seconds. */ unglow: 0.12, /** A new `lights` switching windows on or off, in seconds. */ lights: 0.45, /** A tower growing or shrinking to a new value, in seconds. */ reshape: 0.45, /** A sign riding along as its tower grows or shrinks, as Tailwind classes. */ signRide: "duration-[450ms] ease-[cubic-bezier(0.23,1,0.32,1)]", /** A sign rising 6px out of the roof and back, as Tailwind classes. */ sign: "duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]", /** A tower's label brightening while it's lit, as a Tailwind class. */ label: "duration-150", /** Between windows switching at idle, in seconds (the `flicker` prop). */ flicker: 0.34, /** How long a switched window stays switched, min and max, in seconds. */ flickerHold: [1.6, 4.2], /** Between far dots blinking out, in ms. */ twinkle: 110, /** How long a far dot stays out, min and max, in seconds. */ twinkleHold: [0.3, 1.2], /** Between trains, counted from each departure, in seconds (the `headway` prop). */ headway: 9, /** After the towers start rising, before the first train leaves, in seconds. */ firstTrain: 0.9, /** Back on screen or in the tab, before the next train, as a share of `headway`. */ resume: 0.5, /** A crossing train: min and max px/s, and scene widths per second between them. */ trainPace: [380, 820, 0.55], /** How fast trains cross, as a multiple of that pace (the `trainSpeed` prop). */ trainSpeed: 1, /** A crossing train's curve: a steady speed. */ trainEase: "linear", /** How long a train stands on the line under reduced motion, in seconds. */ park: 2.4, /** A standing train fading in and out, in ms. */ parkFade: 200, } as const; /* ───────────────────────────────────────────────────────── * SKYLINE STORYBOARD * * rest a printed city along the foot of the box: far towers * screened in halftone dots in the tint, ink towers in * front with paper windows knocked out of them, a flat sun * disc behind and stippled trees along the bottom. Give it * `towers` and each tower is one of your numbers, labelled * under it; leave them out and a city grows from `seed` * view the first time a quarter of it shows, the far print and * the trees fade up (0.32s) and the sun rises 14px into * place, while the ink towers rise out of the ground left * to right, 30ms apart (the spread capped at 0.36s), each in * 0.55s on a strong ease-out. As a tower lands its windows * switch on one at a time, in random order, over 0.45s: * lights, not fades * hover the tower under the pointer lights up bottom floor to * top in the glow color in 0.18s and goes back in 0.12s. * With `towers` the others' windows dim and a sign rises * 6px out of the roof with the label and the value (0.16s) * lights a new `lights` switches windows on or off one at a time, * in random order, over 0.45s * values new `towers` values grow or shrink each tower in 0.45s * from wherever it stands, its sign riding along * train with `train` an elevated line runs across the front. A * train crosses it every `headway` seconds at a steady * speed, about two seconds across 1280px, passing behind * the trees; bump `departures` and one leaves at once. It * moves by transform alone, on its own layer * idle while it's on screen a window switches on or off every * 0.34s and a few far dots blink out and back. All of it * sleeps off screen and in a hidden tab * touch a tap on a tower shows its sign until the next tap * keys the towers are one tab stop: the arrows walk along them, * Home and End jump to the ends, and focus shows the sign * reduced nothing rises, blinks or flickers: the city is drawn lit. * Hover still lights a tower and shows its sign, at once. A * departure fades a train in, standing on the line for 2.4s * ───────────────────────────────────────────────────────── */ const SKYLINE = { spread: 12, // towers; the stagger stops growing after this many (0.36s by default) sunRise: 14, // px the sun rises into place on first view dim: 0.55, // how far the other towers' windows dim while one is lit (with `towers`) spacing: 48, // px kept between two trains on the line } as const; const CITY = { pitch: 5, // px between the far print's dots floor: 7, // px per floor far: [26, 66], // px; a far tower's width near: [34, 82], // px; an ink tower's width, when the city grows from `seed` tree: [46, 110], // px between trees cloud: 460, // px of width per cloud top: 0.06, // share of the height left over the tallest tower, when the city grows from `seed` headroom: 44, // px left over the tallest tower for its sign, with `towers` labels: 28, // px; the strip under the towers for their labels deck: 0.27, // the line's deck above the ground, as a share of the height car: [14, 28, 0.072], // px min, max, and share of the height: a train car line: 0.3, // share of a generated city's towers drawn in line, paper with an ink outline gap: 1.5, // px of paper between overlapping towers } as const; const EASE_OUT = cubicBezier(...TIMING.ease); const SUN_DEFAULT = "#ff5a1f"; const TAU = Math.PI * 2; // The train's art, in viewBox units: four cars 78 long with 4 between, 24 tall. const TRAIN = { cars: 4, car: 78, gap: 4, height: 24 } as const; const TRAIN_WIDTH = TRAIN.cars * TRAIN.car + (TRAIN.cars - 1) * TRAIN.gap; const TRAIN_CLASS = "pointer-events-none absolute top-0 left-0 opacity-0 will-change-transform"; const clamp = (v: number, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, v)); /** A small, fast, seedable random number generator (mulberry32). */ function random(seed: number) { let a = seed >>> 0 || 1; return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } export interface SkylineTower { /** The tower's name, under it and on its sign. */ label: string; /** Its height, in your own units: the tallest tower stands nearly as tall as the scene. */ value: number; /** What its sign says. Defaults to `value` with thousands separators. */ display?: string; } export interface SkylineProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> { /** One tower per number, left to right, labelled under it. Leave it out for a city grown from `seed`. */ towers?: SkylineTower[]; /** Reshapes the generated city, the far print, the clouds, the trees and which windows are lit. Any whole number. */ seed?: number; /** Share of the windows that are lit, from 0 to 1. A new value switches them on or off one at a time. */ lights?: number; /** The sun disc behind the city. A number from 0 to 1 sets where it stands across the scene. */ sun?: boolean | number; /** An elevated line across the front, with trains crossing on it. */ train?: boolean; /** Seconds between trains, counted from each departure. `0` runs them only when sent with `departures`. */ headway?: number; /** Bump this number to send a train at once, from a button for instance. */ departures?: number; /** Stippled trees along the foot of the scene, in front of everything. */ trees?: boolean; /** With `towers`: show each tower's sign only while it's hovered, focused or tapped, or all the time. */ signs?: "hover" | "always"; /** Let the city live while it's on screen: a window switches on or off every so often and far dots blink. */ idle?: boolean; /** Raise the towers and switch their lights on the first time the scene shows. */ intro?: boolean; /** Seconds each tower takes to rise out of the ground on first view. */ riseDuration?: number; /** Seconds between towers rising, left to right, on first view. The spread stops growing after twelve towers. */ stagger?: number; /** How fast trains cross, as a multiple of the default pace (about two seconds across 1280px). */ trainSpeed?: number; /** Seconds between windows switching on or off while the city idles. */ flicker?: number; /** What the scene shows, for screen readers. Without `towers` and without a label, the scene is decorative. */ label?: string; } /* ── The city ───────────────────────────────────────────── */ type Crown = "flat" | "step" | "antenna" | "spire" | "slant"; type Facade = "grid" | "slits" | "bands"; /** A window, placed from the tower's left edge and up from the ground. */ type Win = { x: number; y: number; w: number; h: number; floor: number; r: number; on: number }; type Tower = { index: number; x: number; w: number; h: number; // target height of the roof over the ground, px crown: Crown; line: boolean; // drawn in line, paper with an ink outline, instead of solid ink flip: boolean; // which way a slanted roof falls wins: Win[]; // bottom floor first from: number; // the rise or reshape tween now: number; start: number; dur: number; land: number; // s; when its windows start switching on glow: number; // 0–1: how far up the glow has lit it glowFrom: number; glowTo: number; glowStart: number; glowDur: number; }; type Dot = { x: number; y: number; s: number; round: boolean }; type Tree = { x: number; trunk: number; blobs: { x: number; y: number; r: number }[] }; /** What the overlay needs to place labels, signs and hit areas. */ export type SkylineLayout = { width: number; height: number; ground: number; risen: boolean; towers: { x: number; w: number; h: number; slot: number; slotWidth: number }[]; }; type Options = { towers: SkylineTower[] | null; seed: number; lights: number; sun: boolean; sunX: number; train: boolean; headway: number; trees: boolean; idle: boolean; intro: boolean; rise: number; stagger: number; trainSpeed: number; flicker: number; reduced: boolean; }; type Parts = { root: HTMLElement; back: HTMLCanvasElement; near: HTMLCanvasElement; front: HTMLCanvasElement; sun: HTMLElement; trains: HTMLElement[]; tint: HTMLElement; glow: HTMLElement; }; /** How far below the roof the windows stop, for the crown's shape. */ function crownDepth(t: Tower, h: number) { if (t.crown === "step") return Math.min(26, h * 0.14) + 3; if (t.crown === "spire") return Math.min(t.w * 0.8, h * 0.22, 40) + 3; if (t.crown === "slant") return Math.min(t.w * 0.35, h * 0.2) + 3; return 5; } function outline(t: Tower, ground: number, h: number) { const p = new Path2D(); const x0 = t.x; const x1 = t.x + t.w; const top = ground - h; if (t.crown === "step") { const sh = Math.min(26, h * 0.14); const inset = t.w * 0.16; p.moveTo(x0, ground); p.lineTo(x0, top + sh); p.lineTo(x0 + inset, top + sh); p.lineTo(x0 + inset, top); p.lineTo(x1 - inset, top); p.lineTo(x1 - inset, top + sh); p.lineTo(x1, top + sh); p.lineTo(x1, ground); } else if (t.crown === "spire") { const sh = Math.min(t.w * 0.8, h * 0.22, 40); p.moveTo(x0, ground); p.lineTo(x0, top + sh); p.lineTo(x0 + t.w * 0.28, top + sh); p.lineTo((x0 + x1) / 2, top); p.lineTo(x1 - t.w * 0.28, top + sh); p.lineTo(x1, top + sh); p.lineTo(x1, ground); } else if (t.crown === "slant") { const d = Math.min(t.w * 0.35, h * 0.2); p.moveTo(x0, ground); p.lineTo(x0, top + (t.flip ? 0 : d)); p.lineTo(x1, top + (t.flip ? d : 0)); p.lineTo(x1, ground); } else { p.rect(x0, top, t.w, h); } p.closePath(); return p; } /** A tower's windows, floor by floor up to `height`. */ function facade(rand: () => number, w: number, height: number, kind: Facade): Win[] { const wins: Win[] = []; const floors = Math.max(0, Math.floor((height - 6) / CITY.floor)); if (kind === "bands") { const inset = Math.min(4, w * 0.15); for (let f = 0; f < floors; f++) { wins.push({ x: inset, y: 5 + f * CITY.floor + 2, w: w - inset * 2, h: 2, floor: f, r: rand(), on: rand() }); } return wins; } const [pitch, ww, wh] = kind === "slits" ? [5, 2, CITY.floor - 3] : [6, 3, 3]; const cols = Math.max(1, Math.floor((w - 6 + (pitch - ww)) / pitch)); const left = (w - (cols * pitch - (pitch - ww))) / 2; for (let f = 0; f < floors; f++) { for (let c = 0; c < cols; c++) { wins.push({ x: left + c * pitch, y: 5 + f * CITY.floor, w: ww, h: wh, floor: f, r: rand(), on: rand() }); } } return wins; } /** The nearest painted background behind the scene: windows and the train are cut from it. */ function paperBehind(el: HTMLElement) { for (let n: HTMLElement | null = el; n; n = n.parentElement) { const bg = getComputedStyle(n).backgroundColor; if (bg && bg !== "transparent" && !/(,\s*0\)|\/\s*0\))$/.test(bg)) return bg; } return window.matchMedia("(prefers-color-scheme: dark)").matches ? "#0a0a0a" : "#ffffff"; } function createCity() { let parts: Parts | null = null; let back: CanvasRenderingContext2D | null = null; let near: CanvasRenderingContext2D | null = null; let front: CanvasRenderingContext2D | null = null; let opts: Options = { towers: null, seed: 7, lights: 0.6, sun: true, sunX: 0.62, train: false, headway: TIMING.headway, trees: true, idle: true, intro: true, rise: TIMING.riseDuration, stagger: TIMING.stagger, trainSpeed: TIMING.trainSpeed, flicker: TIMING.flicker, reduced: false, }; let width = 0; let height = 0; let dpr = 1; let ground = 0; let deck = 0; let car = 18; let towers: Tower[] = []; let order: Tower[] = []; // draw order: taller ones behind let dots: Dot[] = []; let twinkles: number[] = []; // dots that may blink: the square ones on far towers let trees: Tree[] = []; let colors = { ink: "#000", paper: "#fff", tint: "#888", glow: SUN_DEFAULT, light: "#fff" }; let lit = false; // a light color was set: the city is lit up, so every tower is solid ink let entered = false; // the intro has started (or was skipped) let risen = false; // and finished let visible = false; let raf = 0; let pending = 0; // s; until when the intro's windows are still switching on let threshold = { from: 0.6, to: 0.6, start: 0, value: 0.6 }; let active: number | null = null; const flips = new Map<number, number>(); // tower * 4096 + window → s when it goes back const out = new Map<number, number>(); // far dot → s when it comes back const running = new Map<HTMLElement, Animation>(); let lastDeparture = -Infinity; let flickerTimer = 0; let twinkleTimer = 0; let headTimer = 0; let listener: ((layout: SkylineLayout) => void) | null = null; let jitter = random(1); const cleanups: (() => void)[] = []; const clock = () => performance.now() / 1000; const between = (a: number, b: number) => a + (b - a) * jitter(); const isData = () => Boolean(opts.towers && opts.towers.length); function readColors() { if (!parts) return; const root = getComputedStyle(parts.root); const own = (name: string) => root.getPropertyValue(name).trim(); const paper = own("--skyline-paper") || paperBehind(parts.root); colors = { ink: root.color, paper, tint: getComputedStyle(parts.tint).color, glow: getComputedStyle(parts.glow).color, light: own("--skyline-light") || paper, }; lit = Boolean(own("--skyline-light")); parts.root.style.setProperty("--skyline-paper-now", paper); } /** Grow the city for the current size and options. Keeps each tower's height where it stood. */ function build(keep: boolean) { const rand = random(opts.seed * 7919 + 13); const data = opts.towers && opts.towers.length ? opts.towers : null; ground = data ? height - CITY.labels : height; const top = data ? CITY.headroom : height * CITY.top; const avail = Math.max(0, ground - top); car = clamp(height * CITY.car[2], CITY.car[0], CITY.car[1]); deck = ground - height * CITY.deck; const previous = keep ? towers : []; const t0 = clock(); // Towers stand shorter around the sun, so it sets in a gap instead of behind a wall. const dip = (center: number) => (opts.sun ? Math.exp(-((center - opts.sunX) ** 2) / 0.006) : 0); // The far print: towers and clouds screened onto one grid of dots. const far: { x: number; w: number; h: number; tone: number }[] = []; for (let x = -rand() * 30; x < width; ) { const w = CITY.far[0] + (CITY.far[1] - CITY.far[0]) * rand(); const center = (x + w / 2) / Math.max(1, width); const swell = (0.7 + 0.3 * Math.exp(-((center - 0.55) ** 2) / 0.09)) * (1 - 0.3 * dip(center)); far.push({ x, w, h: avail * (0.32 + 0.6 * rand()) * swell, tone: 0.6 + 0.4 * rand() }); x += w + (-12 + 18 * rand()); } const clouds: { x: number; y: number; rx: number; ry: number }[] = []; if (!data) { const count = Math.max(1, Math.round(width / CITY.cloud)); for (let i = 0; i < count; i++) { const rx = 46 + 54 * rand(); clouds.push({ x: (width * (i + 0.15 + 0.7 * rand())) / count, y: top + avail * (0.04 + 0.26 * rand()), rx, ry: rx * (0.3 + 0.12 * rand()), }); } } const p = CITY.pitch; const cols = Math.ceil(width / p); const rows = Math.ceil(ground / p); dots = []; twinkles = []; for (let gy = 0; gy < rows; gy++) { const cy = gy * p + p / 2; for (let gx = 0; gx < cols; gx++) { const cx = gx * p + p / 2; let tone = 0; let round = false; for (const f of far) { if (cx < f.x || cx >= f.x + f.w || cy < ground - f.h) continue; tone = Math.max(tone, f.tone * (0.3 + 0.7 * ((ground - cy) / f.h) ** 0.8)); } for (const c of clouds) { const d = ((cx - c.x) / c.rx) ** 2 + ((cy - c.y) / c.ry) ** 2; if (d >= 1) continue; const t = (1 - d) ** 0.7 * 0.9; if (t > tone) { tone = t; round = true; } } const r = rand(); if (tone < 0.1 || (!round && r < 0.07)) continue; tone *= 0.88 + 0.2 * rand(); if (!round) twinkles.push(dots.length); dots.push({ x: cx, y: cy, s: round ? p * 0.5 * Math.sqrt(tone) : p * 0.86 * Math.sqrt(tone), round }); } } // The ink towers. towers = []; const make = ( index: number, x: number, w: number, h: number, crown: Crown, kind: Facade, reach: number, line = false, ): Tower => { const before = previous[index]; const from = before ? before.now : 0; return { index, x, w, h, crown, line, flip: rand() < 0.5, wins: facade(rand, w, reach, kind), from, now: entered ? from : 0, start: t0, dur: before && !opts.reduced ? TIMING.reshape : 0, land: entered ? -Infinity : Infinity, glow: 0, glowFrom: 0, glowTo: 0, glowStart: 0, glowDur: 0, }; }; if (data) { const pad = 8; const slot = (width - pad * 2) / data.length; const w = clamp(slot * 0.58, 4, 112); const max = Math.max(0, ...data.map((d) => d.value)) || 1; const kinds: Facade[] = ["grid", "slits", "grid", "bands"]; data.forEach((d, i) => { const h = d.value > 0 ? Math.max(CITY.floor * 2 + 6, (d.value / max) * avail) : 0; towers.push(make(i, pad + i * slot + (slot - w) / 2, w, h, i % 3 === 1 ? "step" : "flat", kinds[i % 4], avail)); }); order = towers; } else { const crowns: Crown[] = ["flat", "flat", "step", "antenna", "spire", "slant"]; const kinds: Facade[] = ["grid", "grid", "slits", "bands"]; let i = 0; for (let x = -(4 + 20 * rand()); x < width + 10; ) { const w = CITY.near[0] + (CITY.near[1] - CITY.near[0]) * rand(); const center = (x + w / 2) / Math.max(1, width); const swell = (0.55 + 0.45 * Math.exp(-((center - 0.58) ** 2) / 0.07)) * (1 - 0.5 * dip(center)); const h = avail * clamp((0.18 + 0.7 * rand()) * swell + 0.06, 0.14, 0.96); const crown = crowns[Math.floor(rand() * crowns.length)]; const line = rand() < CITY.line; towers.push(make(i++, x, w, h, crown, kinds[Math.floor(rand() * kinds.length)], h, line)); x += w + (-10 + 50 * rand()); } order = [...towers].sort((a, b) => b.h - a.h); } // Trees along the foot. trees = []; if (opts.trees) { const scale = clamp(height / 420, 0.85, 1.3); for (let x = -10 + 30 * rand(); x < width + 20; x += CITY.tree[0] + (CITY.tree[1] - CITY.tree[0]) * rand()) { const r = (17 + 12 * rand()) * scale; const cy = ground - r * (1.05 + 0.55 * rand()); const blobs = [{ x, y: cy, r }]; const extra = 2 + Math.floor(rand() * 3); for (let b = 0; b < extra; b++) { blobs.push({ x: x + r * (-0.75 + 1.5 * rand()), y: cy + r * (-0.35 + 0.8 * rand()), r: r * (0.55 + 0.3 * rand()) }); } trees.push({ x, trunk: cy, blobs }); } } jitter = random(opts.seed * 31 + 5); flips.clear(); out.clear(); } function size() { if (!parts) return; width = parts.root.clientWidth; height = parts.root.clientHeight; dpr = Math.min(window.devicePixelRatio || 1, 2); for (const canvas of [parts.back, parts.near, parts.front]) { canvas.width = Math.round(width * dpr); canvas.height = Math.round(height * dpr); } for (const ctx of [back, near, front]) ctx?.setTransform(dpr, 0, 0, dpr, 0, 0); } function placeTrains() { if (!parts) return; for (const el of parts.trains) { el.style.top = `${deck - car + 1}px`; el.style.height = `${car}px`; el.style.width = `${(car * TRAIN_WIDTH) / TRAIN.height}px`; } } function notify() { if (!listener || !isData()) return; const n = towers.length; const pad = 8; const slot = (width - pad * 2) / Math.max(1, n); listener({ width, height, ground, risen, towers: towers.map((t, i) => ({ x: t.x, w: t.w, h: t.h, slot: pad + i * slot, slotWidth: slot })), }); } /* ── Drawing ── */ function drawDot(ctx: CanvasRenderingContext2D, d: Dot) { if (d.round) { ctx.beginPath(); ctx.arc(d.x, d.y, d.s, 0, TAU); ctx.fill(); } else { ctx.fillRect(d.x - d.s / 2, d.y - d.s / 2, d.s, d.s); } } function drawBack() { if (!back) return; back.clearRect(0, 0, width, height); back.fillStyle = colors.tint; back.beginPath(); dots.forEach((d, i) => { if (!d.round || out.has(i)) return; back!.moveTo(d.x + d.s, d.y); back!.arc(d.x, d.y, d.s, 0, TAU); }); back.fill(); dots.forEach((d, i) => { if (!d.round && !out.has(i)) back!.fillRect(d.x - d.s / 2, d.y - d.s / 2, d.s, d.s); }); } /** Repaint one far dot's cell, so a blink costs one dot, not the print. */ function paintDot(i: number, on: boolean) { const d = dots[i]; if (!back || !d) return; const half = CITY.pitch / 2; back.clearRect(d.x - half, d.y - half, CITY.pitch, CITY.pitch); if (!on) return; back.fillStyle = colors.tint; drawDot(back, d); } function drawFront() { if (!front) return; front.clearRect(0, 0, width, height); if (!trees.length) return; front.fillStyle = colors.ink; front.strokeStyle = colors.ink; front.lineWidth = 1.5; for (const t of trees) { front.beginPath(); front.moveTo(t.x, ground); front.lineTo(t.x, t.trunk); front.stroke(); front.beginPath(); for (const b of t.blobs) { front.moveTo(b.x + b.r, b.y); front.arc(b.x, b.y, b.r, 0, TAU); } front.fill(); } // Knock the light out of each crown: specks that grow toward the upper left, // so the dark side reads as shade, like a stipple drawing. const rand = random(opts.seed * 131 + 7); const step = 3.4; front.globalCompositeOperation = "destination-out"; front.beginPath(); for (const t of trees) { let x0 = Infinity; let x1 = -Infinity; let y0 = Infinity; let y1 = -Infinity; for (const b of t.blobs) { x0 = Math.min(x0, b.x - b.r); x1 = Math.max(x1, b.x + b.r); y0 = Math.min(y0, b.y - b.r); y1 = Math.max(y1, b.y + b.r); } let row = 0; for (let y = y0; y <= y1; y += step * 0.87, row++) { for (let x = x0 + (row % 2 ? step / 2 : 0); x <= x1; x += step) { let best = -1; let nx = 0; let ny = 0; for (const b of t.blobs) { const dx = (x - b.x) / b.r; const dy = (y - b.y) / b.r; const inside = 1 - dx * dx - dy * dy; if (inside > best) { best = inside; nx = dx; ny = dy; } } if (best <= 0) continue; const nz = Math.sqrt(best); const light = -0.55 * nx - 0.62 * ny + 0.56 * nz; let r = clamp((light - 0.12) / 0.88) * step * 0.52; if (rand() < 0.12) r += 0.45; if (r < 0.3) continue; front.moveTo(x + r, y); front.arc(x, y, r, 0, TAU); } } } front.fill(); front.globalCompositeOperation = "source-over"; } function drawLine() { if (!near) return; const ink = colors.ink; const paper = colors.paper; const deckH = Math.max(4, car * 0.42); const pillar = Math.max(5, car * 0.42); const spans = Math.max(1, Math.round(width / 150)); const span = width / spans; for (let x = span / 2; x < width + pillar; x += span) { near.fillStyle = paper; near.fillRect(x - pillar / 2, deck + deckH, pillar, ground - deck - deckH); near.strokeStyle = ink; near.lineWidth = 1; near.strokeRect(x - pillar / 2 + 0.5, deck + deckH, pillar - 1, ground - deck - deckH); near.fillStyle = ink; near.fillRect(x - pillar * 0.9, deck + deckH, pillar * 1.8, 2); } near.fillStyle = paper; near.fillRect(0, deck, width, deckH); near.fillStyle = ink; near.fillRect(0, deck, width, 1.25); near.fillRect(0, deck + deckH - 1.25, width, 1.25); for (let x = 2; x < width; x += 6) near.fillRect(x, deck + 2.5, 1, deckH - 5); } function drawNear(now: number) { if (!near) return; near.clearRect(0, 0, width, height); const data = isData(); let focus = 0; if (data) for (const t of towers) focus = Math.max(focus, t.glow); const snap = (v: number) => Math.round(v * dpr) / dpr; for (const t of order) { const h = t.now; if (h < 0.5) continue; const line = t.line && !lit; const path = outline(t, ground, h); near.lineJoin = "miter"; near.strokeStyle = colors.paper; near.lineWidth = CITY.gap * 2; near.stroke(path); near.fillStyle = line ? colors.paper : colors.ink; near.fill(path); if (line) { near.strokeStyle = colors.ink; near.lineWidth = 1.25; near.stroke(path); } if (t.crown === "antenna") { const mast = Math.min(30, h * 0.16); const cx = snap(t.x + t.w / 2); near.fillRect(cx - 0.75, ground - h - mast, 1.5, mast); near.fillRect(cx - 3, ground - h - mast * 0.55, 6, 1.25); } const limit = h - crownDepth(t, h); const floors = Math.max(1, Math.floor((limit - 5) / CITY.floor)); const dimmed = data && t.glow === 0 ? 1 - SKYLINE.dim * focus : 1; let fill = ""; for (let i = 0; i < t.wins.length; i++) { const w = t.wins[i]; if (w.y + w.h > limit) break; let color: string; if (t.glow > 0 && (w.floor + 0.5) / floors <= t.glow) { color = colors.glow; near.globalAlpha = 1; } else { let on = w.r < threshold.value; if (flips.has(t.index * 4096 + i)) on = !on; if (!on || now < t.land + w.on * TIMING.switchOn) continue; // On a paper tower a lit window is drawn in ink: it reads as a pane, not a hole. color = line ? colors.ink : colors.light; near.globalAlpha = dimmed; } if (color !== fill) { near.fillStyle = color; fill = color; } near.fillRect(snap(t.x + w.x), snap(ground - w.y - w.h), snap(w.w) || 1 / dpr, snap(w.h) || 1 / dpr); } near.globalAlpha = 1; } if (opts.train) drawLine(); if (data) { near.fillStyle = colors.ink; near.fillRect(0, snap(ground), width, 1); } } /* ── The loop ── */ /** Advance every tween; true while anything still moves. */ function step(now: number) { let moving = false; for (const t of towers) { if (t.now !== t.h) { const p = t.dur > 0 ? clamp((now - t.start) / t.dur) : 1; t.now = p >= 1 ? t.h : t.from + (t.h - t.from) * EASE_OUT(p); if (now < t.start) t.now = t.from; moving = true; } if (t.glow !== t.glowTo) { const p = t.glowDur > 0 ? clamp((now - t.glowStart) / t.glowDur) : 1; t.glow = p >= 1 ? t.glowTo : t.glowFrom + (t.glowTo - t.glowFrom) * EASE_OUT(p); moving = true; } } if (threshold.value !== threshold.to) { const p = clamp((now - threshold.start) / TIMING.lights); threshold.value = p >= 1 ? threshold.to : threshold.from + (threshold.to - threshold.from) * p; moving = true; } if (now < pending) moving = true; return moving; } function tick() { raf = 0; const now = clock(); const moving = step(now); drawNear(now); if (moving) raf = requestAnimationFrame(tick); else if (entered && !risen) { risen = true; notify(); } } function wake() { if (!raf && parts) raf = requestAnimationFrame(tick); } /** Draw everything at rest, as it stands now. */ function repaint() { drawBack(); drawFront(); drawNear(clock()); } /* ── First view ── */ function enter() { if (entered || !parts) return; entered = true; const now = clock(); const animate = opts.intro && !opts.reduced; if (!animate) { for (const t of towers) { t.now = t.h; t.land = -Infinity; } for (const el of [parts.back, parts.front, parts.sun]) el.style.opacity = "1"; risen = true; notify(); repaint(); schedule(TIMING.firstTrain); syncIdle(); return; } const byX = [...towers].sort((a, b) => a.x - b.x); byX.forEach((t, i) => { t.from = 0; t.now = 0; t.start = now + Math.min(i, SKYLINE.spread) * opts.stagger; t.dur = opts.rise; t.land = t.start + opts.rise * TIMING.land; }); pending = now + SKYLINE.spread * opts.stagger + opts.rise * TIMING.land + TIMING.switchOn; const fade = [{ opacity: 0 }, { opacity: 1 }]; for (const el of [parts.back, parts.front]) { el.animate(fade, { duration: TIMING.appear, easing: TIMING.cssEase }); el.style.opacity = "1"; } parts.sun.style.opacity = "1"; parts.sun.animate( [ { opacity: 0, transform: `translateY(${SKYLINE.sunRise}px)` }, { opacity: 1, transform: "translateY(0px)" }, ], { duration: TIMING.sun, easing: TIMING.cssEase }, ); wake(); schedule(TIMING.firstTrain); syncIdle(); } /* ── Trains ── */ function speed() { const [lo, hi, share] = TIMING.trainPace; return clamp(width * share, lo, hi) * opts.trainSpeed; } function depart() { if (!parts || !opts.train || !width) return; const el = parts.trains.find((t) => !running.has(t)); if (!el) return; const long = (car * TRAIN_WIDTH) / TRAIN.height; let animation: Animation; if (opts.reduced) { if (running.size) return; el.style.transform = `translateX(${(width - long) / 2}px)`; const total = TIMING.park * 1000 + TIMING.parkFade * 2; const edge = TIMING.parkFade / total; animation = el.animate( [{ opacity: 0 }, { opacity: 1, offset: edge }, { opacity: 1, offset: 1 - edge }, { opacity: 0 }], { duration: total }, ); } else { // Leave room behind a train that's still pulling out of the left edge. const v = speed(); const now = clock(); const clear = (long + SKYLINE.spacing) / v; const wait = Math.max(0, lastDeparture + clear - now); lastDeparture = now + wait; animation = el.animate( [ { transform: `translateX(${-long}px)`, opacity: 1 }, { transform: `translateX(${width}px)`, opacity: 1 }, ], { duration: ((width + long) / v) * 1000, delay: wait * 1000, easing: TIMING.trainEase, fill: "backwards" }, ); } running.set(el, animation); const done = () => { if (running.get(el) === animation) running.delete(el); }; animation.onfinish = done; animation.oncancel = done; } function schedule(after: number) { clearTimeout(headTimer); headTimer = 0; if (!opts.train || opts.headway <= 0 || opts.reduced || !entered || !visible || document.hidden) return; headTimer = window.setTimeout(() => { depart(); schedule(opts.headway); }, after * 1000); } /* ── Idle life ── */ function flicker() { const now = clock(); for (const [k, until] of flips) if (until <= now) flips.delete(k); const t = towers[Math.floor(jitter() * towers.length)]; if (t && t.now > 0) { const limit = t.now - crownDepth(t, t.now); let count = 0; while (count < t.wins.length && t.wins[count].y + t.wins[count].h <= limit) count++; if (count) flips.set(t.index * 4096 + Math.floor(jitter() * count), now + between(...TIMING.flickerHold)); } wake(); } function twinkle() { const now = clock(); for (const [i, until] of out) { if (until > now) continue; out.delete(i); paintDot(i, true); } for (let k = 0; k < 3 && twinkles.length; k++) { const i = twinkles[Math.floor(jitter() * twinkles.length)]; if (out.has(i)) continue; out.set(i, now + between(...TIMING.twinkleHold)); paintDot(i, false); } } function syncIdle() { const live = opts.idle && !opts.reduced && entered && visible && !document.hidden; if (live && !flickerTimer) { flickerTimer = window.setInterval(flicker, opts.flicker * 1000); twinkleTimer = window.setInterval(twinkle, TIMING.twinkle); } else if (!live && flickerTimer) { clearInterval(flickerTimer); clearInterval(twinkleTimer); flickerTimer = 0; twinkleTimer = 0; if (flips.size || out.size) { flips.clear(); out.clear(); if (parts) repaint(); } } } /** Light one tower (by index), or none: its windows fill with the glow color, bottom floor to top. */ function light(index: number | null) { if (index === active) return; active = index; const now = clock(); for (const t of towers) { const to = t.index === index ? 1 : 0; if (t.glowTo === to) continue; t.glowFrom = t.glow; t.glowTo = to; t.glowStart = now; t.glowDur = opts.reduced ? 0 : to ? TIMING.glow : TIMING.unglow; } wake(); } /** Rebuild for a new size or new options and draw it. */ function layout(keep: boolean) { if (!parts) return; size(); build(keep); if (entered && (!keep || opts.reduced)) { for (const t of towers) { t.now = t.h; t.land = -Infinity; } } placeTrains(); notify(); drawBack(); drawFront(); if (entered) wake(); drawNear(clock()); } return { connect(el: Parts) { parts = el; back = el.back.getContext("2d"); near = el.near.getContext("2d"); front = el.front.getContext("2d"); readColors(); layout(false); if (!opts.intro || opts.reduced) { for (const node of [el.back, el.front, el.sun]) node.style.opacity = "1"; } let w = width; let h = height; const resize = new ResizeObserver(() => { if (!parts || (parts.root.clientWidth === w && parts.root.clientHeight === h)) return; w = parts.root.clientWidth; h = parts.root.clientHeight; layout(false); }); resize.observe(el.root); const io = new IntersectionObserver( ([entry]) => { visible = entry.isIntersecting; // The first view schedules its own first train; later ones pick the timetable back up. if (entered) schedule(opts.headway * TIMING.resume); else if (entry.intersectionRatio >= 0.25) enter(); syncIdle(); }, { threshold: [0, 0.25] }, ); io.observe(el.root); const scheme = window.matchMedia("(prefers-color-scheme: dark)"); const recolor = () => { readColors(); repaint(); }; scheme.addEventListener("change", recolor); const theme = new MutationObserver(recolor); theme.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "data-theme", "style"] }); const page = () => { schedule(opts.headway * TIMING.resume); syncIdle(); }; document.addEventListener("visibilitychange", page); // Without `towers` the scene has no buttons; a mouse over a tower lights it. const point = (e: PointerEvent) => { if (e.pointerType !== "mouse" || isData() || !parts || !risen) return; const r = parts.root.getBoundingClientRect(); const x = e.clientX - r.left; const y = e.clientY - r.top; let hit: number | null = null; for (let i = order.length - 1; i >= 0; i--) { const t = order[i]; if (x >= t.x && x <= t.x + t.w && y >= ground - t.now && y <= ground) { hit = t.index; break; } } light(hit); }; const leave = () => { if (!isData()) light(null); }; el.root.addEventListener("pointermove", point); el.root.addEventListener("pointerleave", leave); cleanups.push( () => resize.disconnect(), () => io.disconnect(), () => scheme.removeEventListener("change", recolor), () => theme.disconnect(), () => document.removeEventListener("visibilitychange", page), () => { el.root.removeEventListener("pointermove", point); el.root.removeEventListener("pointerleave", leave); }, ); return () => { cancelAnimationFrame(raf); raf = 0; clearTimeout(headTimer); clearInterval(flickerTimer); clearInterval(twinkleTimer); flickerTimer = 0; twinkleTimer = 0; for (const a of running.values()) a.cancel(); running.clear(); cleanups.splice(0).forEach((fn) => fn()); parts = null; back = near = front = null; // A remount (Strict Mode, or a new key) starts from the top. entered = false; risen = false; }; }, setOptions(next: Options) { const prev = opts; opts = next; if (!parts) { threshold = { from: next.lights, to: next.lights, start: 0, value: next.lights }; return; } if (next.lights !== threshold.to) { threshold = next.reduced ? { from: next.lights, to: next.lights, start: 0, value: next.lights } : { from: threshold.value, to: next.lights, start: clock(), value: threshold.value }; wake(); } const reshape = prev.towers !== next.towers; const rebuild = prev.seed !== next.seed || prev.trees !== next.trees || prev.train !== next.train || prev.reduced !== next.reduced || prev.sun !== next.sun || prev.sunX !== next.sunX; if (rebuild || reshape) { const sameCount = (prev.towers?.length ?? -1) === (next.towers?.length ?? -2); layout(reshape && !rebuild && sameCount); } if (prev.reduced !== next.reduced && next.reduced) { for (const a of running.values()) a.cancel(); } schedule(next.headway); if (prev.flicker !== next.flicker && flickerTimer) { clearInterval(flickerTimer); flickerTimer = window.setInterval(flicker, next.flicker * 1000); } syncIdle(); }, listen(fn: (layout: SkylineLayout) => void) { listener = fn; notify(); return () => { if (listener === fn) listener = null; }; }, /** Light one tower (by index), or none. */ setActive(index: number | null) { light(index); }, depart, }; } type City = ReturnType<typeof createCity>; /* ── Train ──────────────────────────────────────────────── */ // Four cars, nose to the right: paper bodies cut from the scene's background, // an ink outline, a row of windows and a stripe in the sun's color. With a // light color set, the windows are lit, so a train still shows at night. function TrainArt() { return ( <svg viewBox={`0 0 ${TRAIN_WIDTH} ${TRAIN.height}`} className="block size-full overflow-visible" fill="none"> {Array.from({ length: TRAIN.cars }, (_, i) => { const x = i * (TRAIN.car + TRAIN.gap); const lead = i === TRAIN.cars - 1; const body = lead ? `M${x + 3} 2H${x + 64}C${x + 72} 2 ${x + 78} 8 ${x + 78} 14V17a3 3 0 0 1-3 3H${x + 3}a3 3 0 0 1-3-3V5a3 3 0 0 1 3-3Z` : `M${x + 3} 2H${x + 75}a3 3 0 0 1 3 3V17a3 3 0 0 1-3 3H${x + 3}a3 3 0 0 1-3-3V5a3 3 0 0 1 3-3Z`; return ( <g key={i}> {i > 0 && <rect x={x - TRAIN.gap} y={13} width={TRAIN.gap} height={1.5} fill="currentColor" />} <path d={body} fill="var(--skyline-paper-now, #fff)" stroke="currentColor" strokeWidth={1.25} /> {Array.from({ length: lead ? 6 : 7 }, (_, k) => ( <rect key={k} x={x + 6 + k * 9.6} y={6} width={6} height={4.5} rx={1} fill="var(--skyline-light, currentColor)" /> ))} {lead && <path d={`M${x + 64} 5.5h2.5c3 0 6 2.6 7.2 5H${x + 64}Z`} fill="currentColor" />} <rect x={x + 0.6} y={12.6} width={lead ? 76.5 : 76.8} height={2.2} fill={`var(--skyline-sun, ${SUN_DEFAULT})`} /> <rect x={x + 8} y={20} width={12} height={2.6} rx={1.2} fill="currentColor" /> <rect x={x + 58} y={20} width={12} height={2.6} rx={1.2} fill="currentColor" /> </g> ); })} </svg> ); } /* ── Skyline ────────────────────────────────────────────── */ export function Skyline({ towers, seed = 7, lights = 0.6, sun = true, train = false, headway = TIMING.headway, departures = 0, trees = true, signs = "hover", idle = true, intro = true, riseDuration = TIMING.riseDuration, stagger = TIMING.stagger, trainSpeed = TIMING.trainSpeed, flicker = TIMING.flicker, label, className, style, ...props }: SkylineProps) { const rootRef = React.useRef<HTMLDivElement>(null); const backRef = React.useRef<HTMLCanvasElement>(null); const nearRef = React.useRef<HTMLCanvasElement>(null); const frontRef = React.useRef<HTMLCanvasElement>(null); const sunRef = React.useRef<HTMLDivElement>(null); const trainARef = React.useRef<HTMLDivElement>(null); const trainBRef = React.useRef<HTMLDivElement>(null); const tintRef = React.useRef<HTMLSpanElement>(null); const glowRef = React.useRef<HTMLSpanElement>(null); const [city] = React.useState<City>(createCity); const reduced = useReducedMotion() ?? false; const [layout, setLayout] = React.useState<SkylineLayout | null>(null); const [hover, setHover] = React.useState<number | null>(null); const [focused, setFocused] = React.useState<number | null>(null); const [pinned, setPinned] = React.useState<number | null>(null); const [cursor, setCursor] = React.useState(0); const pointerType = React.useRef("mouse"); const sent = React.useRef(departures); // A new array with the same numbers isn't a change. const key = towers?.map((t) => `${t.label}\u0000${t.value}\u0000${t.display ?? ""}`).join("\u0001") ?? ""; const [data, setData] = React.useState<SkylineTower[] | null>(towers?.length ? towers : null); const [dataKey, setDataKey] = React.useState(key); if (key !== dataKey) { setDataKey(key); setData(towers?.length ? towers : null); } // Options first, so the first layout already has them. React.useLayoutEffect(() => { city.setOptions({ towers: data, seed, lights: clamp(lights), sun: sun !== false, sunX: typeof sun === "number" ? clamp(sun) : 0.62, train, headway: Math.max(0, headway), trees, idle, intro, rise: Math.max(0, riseDuration), stagger: Math.max(0, stagger), trainSpeed: Math.max(0.05, trainSpeed), flicker: Math.max(0.05, flicker), reduced, }); }, [city, data, seed, lights, sun, train, headway, trees, idle, intro, riseDuration, stagger, trainSpeed, flicker, reduced]); React.useLayoutEffect(() => { const root = rootRef.current; const back = backRef.current; const near = nearRef.current; const front = frontRef.current; const sunEl = sunRef.current; const a = trainARef.current; const b = trainBRef.current; const tint = tintRef.current; const glow = glowRef.current; if (!root || !back || !near || !front || !sunEl || !a || !b || !tint || !glow) return; return city.connect({ root, back, near, front, sun: sunEl, trains: [a, b], tint, glow }); }, [city]); React.useLayoutEffect(() => city.listen(setLayout), [city]); React.useEffect(() => { if (departures === sent.current) return; sent.current = departures; city.depart(); }, [city, departures]); const active = hover ?? focused ?? pinned; React.useEffect(() => { if (data) city.setActive(active); }, [city, data, active]); const onKey = (e: React.KeyboardEvent<HTMLButtonElement>) => { if (!data) return; const n = data.length; const from = Number(e.currentTarget.dataset.tower); const to = e.key === "ArrowRight" || e.key === "ArrowDown" ? (from + 1) % n : e.key === "ArrowLeft" || e.key === "ArrowUp" ? (from - 1 + n) % n : e.key === "Home" ? 0 : e.key === "End" ? n - 1 : -1; if (to < 0) return; e.preventDefault(); setCursor(to); e.currentTarget.parentElement?.querySelector<HTMLButtonElement>(`[data-tower="${to}"]`)?.focus(); }; const sunX = typeof sun === "number" ? clamp(sun) : 0.62; const hidden = intro ? { opacity: 0 } : undefined; const decorative = !data && !label; return ( <div ref={rootRef} role={data ? "group" : decorative ? undefined : "img"} aria-label={data ? (label ?? "Chart") : label} aria-hidden={decorative || undefined} className={cn("relative isolate h-80 w-full touch-pan-y overflow-hidden select-none", className)} style={style} {...props} > <div ref={sunRef} aria-hidden className={cn( "pointer-events-none absolute aspect-square h-[54%] -translate-x-1/2 rounded-full [background-color:var(--skyline-sun,#ff5a1f)]", sun === false && "hidden", )} style={{ left: `${sunX * 100}%`, bottom: data ? `calc(${CITY.labels}px + 24%)` : "24%", ...hidden }} /> <canvas ref={backRef} aria-hidden className="pointer-events-none absolute inset-0 size-full" style={hidden} /> <canvas ref={nearRef} aria-hidden className="pointer-events-none absolute inset-0 size-full" /> {/* Two trains, so a second can follow the first onto the line. */} <div ref={trainARef} aria-hidden className={TRAIN_CLASS}> <TrainArt /> </div> <div ref={trainBRef} aria-hidden className={TRAIN_CLASS}> <TrainArt /> </div> <canvas ref={frontRef} aria-hidden className="pointer-events-none absolute inset-0 size-full" style={hidden} /> <span ref={tintRef} aria-hidden className="hidden [color:var(--skyline-tint,#4562ff)]" /> <span ref={glowRef} aria-hidden className="hidden [color:var(--skyline-glow,var(--skyline-sun,#ff5a1f))]" /> {data && layout && data.map((t, i) => { const g = layout.towers[i]; if (!g) return null; const text = t.display ?? t.value.toLocaleString("en-US"); const shown = layout.risen && (signs === "always" || active === i); const center = clamp(g.x + g.w / 2, 28, layout.width - 28); return ( <React.Fragment key={i}> <button type="button" data-tower={i} tabIndex={i === cursor ? 0 : -1} aria-label={`${t.label}: ${text}`} className="group/tower absolute top-0 cursor-default outline-none" style={{ left: g.slot, width: g.slotWidth, height: layout.ground }} onPointerDown={(e) => { pointerType.current = e.pointerType; }} onPointerEnter={(e) => e.pointerType === "mouse" && setHover(i)} onPointerLeave={(e) => e.pointerType === "mouse" && setHover((h) => (h === i ? null : h))} onFocus={() => { setFocused(i); setCursor(i); }} onBlur={() => setFocused((f) => (f === i ? null : f))} onClick={() => { if (pointerType.current !== "mouse") setPinned((p) => (p === i ? null : i)); }} onKeyDown={onKey} > <span aria-hidden className="absolute bottom-0 rounded-[2px] outline-2 outline-offset-3 outline-current opacity-0 group-focus-visible/tower:opacity-100" style={{ left: g.x - g.slot, width: g.w, height: Math.max(g.h, 2) }} /> </button> <span aria-hidden className={`pointer-events-none absolute top-0 left-0 transition-transform ${TIMING.signRide} motion-reduce:transition-none`} style={{ transform: `translate(${center}px, ${layout.ground - g.h - 4}px)` }} > <span className={cn( `absolute bottom-0 left-0 flex -translate-x-1/2 flex-col items-center transition-[opacity,translate] ${TIMING.sign} motion-reduce:translate-y-0`, shown ? "translate-y-0 opacity-100" : "translate-y-1.5 opacity-0", )} > <span className="flex items-baseline gap-1.5 rounded-[3px] bg-current px-1.5 py-1 font-mono text-[10px] leading-none whitespace-nowrap tabular-nums"> <span className="[color:var(--skyline-paper-now,#fff)] opacity-70">{t.label}</span> <span className="font-semibold [color:var(--skyline-paper-now,#fff)]">{text}</span> </span> <span className="h-1.5 w-px bg-current" /> </span> </span> <span aria-hidden className={cn( `pointer-events-none absolute bottom-0 flex items-center justify-center truncate px-0.5 font-mono text-[10px] tracking-[0.08em] uppercase transition-opacity ${TIMING.label}`, active === i ? "opacity-100" : "opacity-55", )} style={{ left: g.slot, width: g.slotWidth, height: CITY.labels }} > {t.label} </span> </React.Fragment> ); })} </div> ); }Update the import paths
The file imports
cnfrom@/lib/utils. Change the path if your project uses a different alias.
Examples
Finished sections built with Skyline. Each one installs whole, with the component included.
City Stats
Studio hours booked per city as towers, each with its share on a rooftop sign. Switch the year and the towers grow or shrink to it.
Hours booked on Roomtone in 2026, 17,830 h in all. Hover a city for its share.
"use client";
import * as React from "react";
import { Skyline, type SkylineTower } from "@/components/ui/skyline";
// Hours of studio time booked through Roomtone, by city.
const YEARS = {
"2025": [
{ label: "Lisbon", value: 1420 },
{ label: "Berlin", value: 2980 },
{ label: "Lagos", value: 860 },
{ label: "Seoul", value: 3310 },
{ label: "Mexico City", value: 1890 },
{ label: "Toronto", value: 2240 },
{ label: "Melbourne", value: 1310 },
],
"2026": [
{ label: "Lisbon", value: 2160 },
{ label: "Berlin", value: 3120 },
{ label: "Lagos", value: 1940 },
{ label: "Seoul", value: 4050 },
{ label: "Mexico City", value: 2460 },
{ label: "Toronto", value: 2380 },
{ label: "Melbourne", value: 1720 },
],
} satisfies Record<string, { label: string; value: number }[]>;
type Year = keyof typeof YEARS;
export function SkylineStatsDemo() {
const [year, setYear] = React.useState<Year>("2026");
const total = YEARS[year].reduce((sum, c) => sum + c.value, 0);
const towers: SkylineTower[] = YEARS[year].map((c) => ({
...c,
display: `${c.value.toLocaleString("en-US")} h · ${Math.round((c.value / total) * 100)}%`,
}));
return (
<div className="w-full max-w-3xl rounded-2xl border border-zinc-950/10 bg-white p-5 text-zinc-900 shadow-sm sm:p-7 dark:border-white/10 dark:bg-zinc-950 dark:text-zinc-100">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<h3 className="font-serif text-2xl tracking-[-0.015em] sm:text-[1.75rem]">
Where the studios were <em className="text-[#2747ff] dark:text-[#8292ff]">busiest</em>
</h3>
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
Hours booked on Roomtone in {year},{" "}
<span className="font-medium text-zinc-900 tabular-nums dark:text-zinc-100">
{total.toLocaleString("en-US")} h
</span>{" "}
in all. Hover a city for its share.
</p>
</div>
<div
role="radiogroup"
aria-label="Year"
className="flex rounded-full border border-zinc-950/10 p-0.5 text-sm dark:border-white/10"
>
{(Object.keys(YEARS) as Year[]).map((y) => (
<button
key={y}
type="button"
role="radio"
aria-checked={year === y}
onClick={() => setYear(y)}
className="h-8 rounded-full px-3.5 font-medium tabular-nums text-zinc-500 transition-colors duration-150 hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-900 aria-checked:bg-zinc-900 aria-checked:text-white dark:text-zinc-400 dark:hover:text-zinc-100 dark:focus-visible:outline-zinc-100 dark:aria-checked:bg-zinc-100 dark:aria-checked:text-zinc-900"
>
{y}
</button>
))}
</div>
</div>
<Skyline
towers={towers}
seed={4}
sun={0.86}
trees={false}
label={`Studio hours booked by city in ${year}`}
className="mt-6 h-72 text-zinc-900 [--skyline-sun:#ff5a1f] [--skyline-tint:#bcc7ff] sm:h-80 dark:text-zinc-100 dark:[--skyline-sun:#ff6a2b] dark:[--skyline-tint:#202c78]"
/>
</div>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content. Skyline: A printed city along the foot of a section: halftone towers in the distance, ink towers in front with their windows knocked out, a flat sun and stippled trees. The first time it shows, the towers rise out of the ground and their windows switch on one by one. Give it your numbers and each one becomes a tower with a sign on its roof. Hover a tower and it lights up floor by floor. An elevated line can run across the front, with a train you can send from a button. This demo, City Stats: Studio hours booked per city as towers, each with its share on a rooftop sign. Switch the year and the towers grow or shrink to it. 1. Install it: npx shadcn@latest add @keyframeui/skyline-stats-demo (If the CLI can't find @keyframeui, use https://keyframeui.com/r/skyline-stats-demo.json instead.) It adds the demo, components/skyline-stats-demo.tsx, and the component, components/ui/skyline.tsx. 2. Make it ours: our copy, data, images and colors. Keep the structure the motion depends on. If we only need the effect, use <Skyline> on its own. 3. These props set its feel. Change them before you change the file: - seed (default 7): Reshapes the generated city, the far print, the clouds, the trees and which windows are lit. Any whole number. - lights (default 0.6): Share of the windows that are lit, from 0 to 1. A new value switches them on or off one at a time. - train (default false): An elevated line across the front, with trains crossing on it, behind the trees. - headway (default 9): Seconds between trains, counted from each departure. `0` runs them only when sent with `departures`. - trees (default true): Stippled trees along the foot of the scene, in front of everything. - idle (default true): Let the city live while it's on screen: a window switches on or off every so often and far dots blink. - intro (default true): Raise the towers and switch their lights on the first time the scene shows. - riseDuration (default 0.55): Seconds each tower takes to rise out of the ground the first time the scene shows. - stagger (default 0.03): Seconds between towers starting to rise, left to right. The spread stops growing after twelve towers. - trainSpeed (default 1): How fast trains cross, as a multiple of the default pace (about two seconds across 1280px). `2` is twice as fast. - flicker (default 0.34): Seconds between windows switching on or off while the city idles. 4. Don't change, in components/ui/skyline.tsx: - Towers, windows and glow are painted on the `near` canvas by one rAF loop that stops once nothing moves; React only re-renders for layout, signs and focus, never per frame. - Trains are two DOM layers moved by WAAPI `transform` alone, stacked between the `near` and `front` canvases, so they cross in front of the towers and behind the trees. - Windows switch on and off whole, one at a time in random order: lights, not fades. A far dot's blink repaints only its own cell. - Idle flicker, twinkles and the train timetable run only while the scene is on screen and the tab is visible. - Reduced motion draws the city lit at once, with nothing rising, blinking or crossing; hover still lights a tower and shows its sign, and a departure fades a train in standing still. Its timing is all in the TIMING block at the top of that file. Docs: https://keyframeui.com/components/skyline.md
Lights On
A late-night coworking club where every lit window is a member at a desk. Windows switch on and off as people come and go.
148of 240 desks lit
Lantern Hall stays open until four. Every window up there is a member working tonight.
"use client";
import * as React from "react";
import { Skyline } from "@/components/ui/skyline";
const DESKS = 240;
const START = 148;
// A night-owl coworking club: every lit window is a member at a desk right now.
export function SkylineLightsDemo() {
const [here, setHere] = React.useState(START);
const [joined, setJoined] = React.useState(false);
// People come and go every few seconds.
React.useEffect(() => {
const id = window.setInterval(() => {
setHere((n) => Math.min(DESKS - 12, Math.max(90, n + Math.round(Math.random() * 14 - 6))));
}, 2600);
return () => window.clearInterval(id);
}, []);
const count = here + (joined ? 1 : 0);
return (
<div className="w-full max-w-3xl overflow-hidden rounded-2xl border border-zinc-950/10 bg-[#fafaf9] text-zinc-950 dark:border-white/10 dark:bg-[#07080c] dark:text-zinc-100">
<div className="flex flex-wrap items-end justify-between gap-5 px-6 pt-7 sm:px-8 sm:pt-8">
<div>
<p className="font-serif text-5xl leading-none tracking-[-0.02em] tabular-nums sm:text-6xl" aria-live="polite">
{count}
<span className="ml-2 align-middle font-sans text-sm tracking-normal text-zinc-500 dark:text-zinc-400">
of {DESKS} desks lit
</span>
</p>
<p className="mt-3 max-w-sm text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">
Lantern Hall stays open until four. Every window up there is a member working tonight.
</p>
</div>
<button
type="button"
aria-pressed={joined}
onClick={() => setJoined((j) => !j)}
className="inline-flex h-10 items-center gap-2 rounded-full bg-zinc-950 px-5 text-sm font-medium text-[#fafaf9] transition-[background-color,scale] duration-150 hover:bg-zinc-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-950 active:scale-[0.97] aria-pressed:bg-[#ffb020] aria-pressed:text-zinc-950 dark:bg-zinc-100 dark:text-zinc-950 dark:hover:bg-white dark:focus-visible:outline-zinc-100 dark:aria-pressed:bg-[#ffd27a]"
>
<span
aria-hidden
className="size-2 rounded-[1px] bg-current opacity-40 transition-opacity duration-150 [[aria-pressed=true]_&]:opacity-100"
/>
{joined ? "You're at a desk" : "Take a desk"}
</button>
</div>
<Skyline
seed={21}
lights={count / DESKS}
sun={false}
className="mt-6 h-64 text-zinc-950 [--skyline-light:#ffbf3f] [--skyline-tint:#c3cdff] sm:h-72 dark:text-[#1b1f2e] dark:[--skyline-light:#ffd27a] dark:[--skyline-tint:#202c78]"
/>
</div>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content. Skyline: A printed city along the foot of a section: halftone towers in the distance, ink towers in front with their windows knocked out, a flat sun and stippled trees. The first time it shows, the towers rise out of the ground and their windows switch on one by one. Give it your numbers and each one becomes a tower with a sign on its roof. Hover a tower and it lights up floor by floor. An elevated line can run across the front, with a train you can send from a button. This demo, Lights On: A late-night coworking club where every lit window is a member at a desk. Windows switch on and off as people come and go. 1. Install it: npx shadcn@latest add @keyframeui/skyline-lights-demo (If the CLI can't find @keyframeui, use https://keyframeui.com/r/skyline-lights-demo.json instead.) It adds the demo, components/skyline-lights-demo.tsx, and the component, components/ui/skyline.tsx. 2. Make it ours: our copy, data, images and colors. Keep the structure the motion depends on. If we only need the effect, use <Skyline> on its own. 3. These props set its feel. Change them before you change the file: - seed (default 7): Reshapes the generated city, the far print, the clouds, the trees and which windows are lit. Any whole number. - lights (default 0.6): Share of the windows that are lit, from 0 to 1. A new value switches them on or off one at a time. - train (default false): An elevated line across the front, with trains crossing on it, behind the trees. - headway (default 9): Seconds between trains, counted from each departure. `0` runs them only when sent with `departures`. - trees (default true): Stippled trees along the foot of the scene, in front of everything. - idle (default true): Let the city live while it's on screen: a window switches on or off every so often and far dots blink. - intro (default true): Raise the towers and switch their lights on the first time the scene shows. - riseDuration (default 0.55): Seconds each tower takes to rise out of the ground the first time the scene shows. - stagger (default 0.03): Seconds between towers starting to rise, left to right. The spread stops growing after twelve towers. - trainSpeed (default 1): How fast trains cross, as a multiple of the default pace (about two seconds across 1280px). `2` is twice as fast. - flicker (default 0.34): Seconds between windows switching on or off while the city idles. 4. Don't change, in components/ui/skyline.tsx: - Towers, windows and glow are painted on the `near` canvas by one rAF loop that stops once nothing moves; React only re-renders for layout, signs and focus, never per frame. - Trains are two DOM layers moved by WAAPI `transform` alone, stacked between the `near` and `front` canvases, so they cross in front of the towers and behind the trees. - Windows switch on and off whole, one at a time in random order: lights, not fades. A far dot's blink repaints only its own cell. - Idle flicker, twinkles and the train timetable run only while the scene is on screen and the tab is visible. - Reduced motion draws the city lit at once, with nothing rising, blinking or crossing; hover still lights a tower and shows its sign, and a departure fades a train in standing still. Its timing is all in the TIMING block at the top of that file. Docs: https://keyframeui.com/components/skyline.md
Props
| Prop | Type | Default | Description |
|---|---|---|---|
towers | SkylineTower[] | None | One tower per number, left to right, labelled under it: { label, value, display? }. The tallest stands nearly as tall as the scene, and display is what its sign says (the value with thousands separators by default). Leave it out for a city grown from seed. |
seed | number | 7 | Reshapes the generated city, the far print, the clouds, the trees and which windows are lit. Any whole number. |
lights | number | 0.6 | Share of the windows that are lit, from 0 to 1. A new value switches them on or off one at a time. |
sun | boolean | number | true | The sun disc behind the city. A number from 0 to 1 sets where it stands across the scene; the towers stand shorter around it. |
train | boolean | false | An elevated line across the front, with trains crossing on it, behind the trees. |
headway | number | 9 | Seconds between trains, counted from each departure. 0 runs them only when sent with departures. |
departures | number | 0 | Bump this number to send a train at once, from a button for instance. |
trees | boolean | true | Stippled trees along the foot of the scene, in front of everything. |
signs | "hover" | "always" | "hover" | With towers: show each tower's sign only while it's hovered, focused or tapped, or all the time. |
idle | boolean | true | Let the city live while it's on screen: a window switches on or off every so often and far dots blink. |
intro | boolean | true | Raise the towers and switch their lights on the first time the scene shows. |
riseDuration | number | 0.55 | Seconds each tower takes to rise out of the ground the first time the scene shows. |
stagger | number | 0.03 | Seconds between towers starting to rise, left to right. The spread stops growing after twelve towers. |
trainSpeed | number | 1 | How fast trains cross, as a multiple of the default pace (about two seconds across 1280px). 2 is twice as fast. |
flicker | number | 0.34 | Seconds between windows switching on or off while the city idles. |
label | string | None | What the scene shows, for screen readers. Without towers and without a label, the scene is decorative. |
className | string | None | Size and colors. Height comes from a class (h-80 by default). The text color is the ink; set --skyline-tint (the far print), --skyline-sun, --skyline-glow (a lit-up tower) and --skyline-light (lit windows) as CSS variables. Windows and the train are cut from the background behind the scene, or from --skyline-paper. |
towersType
SkylineTower[]One tower per number, left to right, labelled under it:
{ label, value, display? }. The tallest stands nearly as tall as the scene, anddisplayis what its sign says (the value with thousands separators by default). Leave it out for a city grown fromseed.seedType
numberDefault7Reshapes the generated city, the far print, the clouds, the trees and which windows are lit. Any whole number.
lightsType
numberDefault0.6Share of the windows that are lit, from 0 to 1. A new value switches them on or off one at a time.
sunType
boolean | numberDefaulttrueThe sun disc behind the city. A number from 0 to 1 sets where it stands across the scene; the towers stand shorter around it.
trainType
booleanDefaultfalseAn elevated line across the front, with trains crossing on it, behind the trees.
headwayType
numberDefault9Seconds between trains, counted from each departure.
0runs them only when sent withdepartures.departuresType
numberDefault0Bump this number to send a train at once, from a button for instance.
treesType
booleanDefaulttrueStippled trees along the foot of the scene, in front of everything.
signsType
"hover" | "always"Default"hover"With
towers: show each tower's sign only while it's hovered, focused or tapped, or all the time.idleType
booleanDefaulttrueLet the city live while it's on screen: a window switches on or off every so often and far dots blink.
introType
booleanDefaulttrueRaise the towers and switch their lights on the first time the scene shows.
riseDurationType
numberDefault0.55Seconds each tower takes to rise out of the ground the first time the scene shows.
staggerType
numberDefault0.03Seconds between towers starting to rise, left to right. The spread stops growing after twelve towers.
trainSpeedType
numberDefault1How fast trains cross, as a multiple of the default pace (about two seconds across 1280px).
2is twice as fast.flickerType
numberDefault0.34Seconds between windows switching on or off while the city idles.
labelType
stringWhat the scene shows, for screen readers. Without
towersand without a label, the scene is decorative.classNameType
stringSize and colors. Height comes from a class (
h-80by default). The text color is the ink; set--skyline-tint(the far print),--skyline-sun,--skyline-glow(a lit-up tower) and--skyline-light(lit windows) as CSS variables. Windows and the train are cut from the background behind the scene, or from--skyline-paper.