08City block — a scene where each building's height comes from the layout math
Each grid cell rolls a random floor count, stacking that many cubes via translateZ into a skyline. A random subset of windows picks a lit color and flickers on @keyframes, reading as a night scene.
Best forEvent pages, brand story sections
CSS custom properties@keyframesrandom

<main class="stage">
<div class="cb" id="cb">
<p class="cb__hint">Building heights come from a seed-42 random number, and window-flicker delays from the same seeded random — so the same city appears every time</p>
<div class="cb__field" role="img" aria-label="Scene with nine buildings of different heights, each window flickering to its own rhythm">
<div class="cb__world" id="world"></div>
</div>
<p class="cb__meta">Seed <b id="seed">42</b> · buildings <b id="n">9</b>buildings</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 도시 블록 씬(08) — 무대는 페이퍼. 건물 높이(--h)·창문 개수·깜빡임 지연(--delay) 은
// mulberry32(시드 42) 로 한 번만 뽑아 고정한다. 창문 깜빡임만 진짜 @keyframes 무한
// 애니메이션으로 계속 돈다 — 나머지(위치·높이)는 정적 값이다.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-paper);
.cb {
display: flex; flex-direction: column; align-items: center; gap: $sp-2;
width: 100%; max-width: 420px;
}
.cb__hint {
margin: 0; font-weight: 700; font-size: clamp(10px, 2.6vw, 12px); line-height: 1.35;
color: $subject-ink; text-align: center;
}
.cb__field {
position: relative;
width: clamp(180px, 42vw, 220px); height: clamp(140px, 34vw, 168px);
display: grid; place-items: center;
}
.cb__world { position: relative; transform-style: preserve-3d; width: 1px; height: 1px; }
.cb__bld {
position: absolute; left: 0; top: 0;
width: 26px; height: var(--h);
transform: translate3d(var(--tx), var(--ty), 0) translate(-50%, 0) translateY(calc(var(--h) * -1));
background: $subject-blue;
border: 1px solid rgba($subject-ink, .3);
border-radius: $r-xs $r-xs 0 0;
box-shadow: $shadow-press;
}
.cb__roof {
position: absolute; left: 50%; top: 0;
width: 26px; height: 10px;
transform: translate3d(-50%, -70%, 0);
background: $subject-orange-deep;
border-radius: $r-xs;
}
.cb__win {
position: absolute; left: 50%; width: 9px; height: 9px;
transform: translateX(-50%);
background: $subject-ink;
border-radius: $r-xs;
animation: cbTwinkle 3s ease-in-out infinite;
animation-delay: var(--delay, 0s);
}
@keyframes cbTwinkle {
0%, 100% { background: $subject-ink; box-shadow: none; }
50% { background: $stage-yellow; box-shadow: 0 0 9px rgba($stage-yellow, .9); }
}
.cb__meta { margin: 0; font-size: clamp(9px, 2.4vw, 11px); color: rgba($subject-ink, .78); }
.cb__meta b { font-weight: 800; color: $subject-blue; font-variant-numeric: tabular-nums; }
@media (max-width: 340px) {
.cb { gap: $sp-1; }
.cb__field { width: 156px; height: 112px; }
.cb__hint { font-size: 8px; }
.cb__meta { font-size: 7px; }
}
// reduce — 창문 깜빡임이 멎는다. 중간 밝기(.6)로 고정된 정지 도시만 남긴다.
// (건물 배치는 JS 가 한 번만 그리는 정적 값이라 여기엔 자바스크립트 분기가 필요 없다)
@media (prefers-reduced-motion: reduce) {
.cb__win { animation: none; background: $stage-yellow; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cb {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 100%;
max-width: 420px;
}
.cb__hint {
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.6vw, 12px);
line-height: 1.35;
color: #17141a;
text-align: center;
}
.cb__field {
position: relative;
width: clamp(180px, 42vw, 220px);
height: clamp(140px, 34vw, 168px);
display: grid;
place-items: center;
}
.cb__world {
position: relative;
transform-style: preserve-3d;
width: 1px;
height: 1px;
}
.cb__bld {
position: absolute;
left: 0;
top: 0;
width: 26px;
height: var(--h);
transform: translate3d(var(--tx), var(--ty), 0) translate(-50%, 0) translateY(calc(var(--h) * -1));
background: #2f6df6;
border: 1px solid rgba(23, 20, 26, 0.3);
border-radius: 4px 4px 0 0;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.cb__roof {
position: absolute;
left: 50%;
top: 0;
width: 26px;
height: 10px;
transform: translate3d(-50%, -70%, 0);
background: #c93000;
border-radius: 4px;
}
.cb__win {
position: absolute;
left: 50%;
width: 9px;
height: 9px;
transform: translateX(-50%);
background: #17141a;
border-radius: 4px;
animation: cbTwinkle 3s ease-in-out infinite;
animation-delay: var(--delay, 0s);
}
@keyframes cbTwinkle {
0%, 100% {
background: #17141a;
box-shadow: none;
}
50% {
background: #ffd23f;
box-shadow: 0 0 9px rgba(255, 210, 63, 0.9);
}
}
.cb__meta {
margin: 0;
font-size: clamp(9px, 2.4vw, 11px);
color: rgba(23, 20, 26, 0.78);
}
.cb__meta b {
font-weight: 800;
color: #2f6df6;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.cb {
gap: 4px;
}
.cb__field {
width: 156px;
height: 112px;
}
.cb__hint {
font-size: 8px;
}
.cb__meta {
font-size: 7px;
}
}
@media (prefers-reduced-motion: reduce) {
.cb__win {
animation: none;
background: #ffd23f;
}
}// 도시 블록 씬(08) — mulberry32(Seed 고정) 로 건물 높이와 창문 깜빡임 지연을 뽑는다.
// 같은 시드는 항상 같은 도시를 만든다(재현성). 창문 깜빡임은 진짜 @keyframes 무한
// 애니메이션이라 렌더가 결정론 모드(Animation.currentTime 스크럽)로 잡아 정확히 찍는다.
(function () {
var root = document.getElementById('cb');
var world = document.getElementById('world');
var nEl = document.getElementById('n');
if (!root || !world || !nEl) return;
function mulberry32(seed) {
return function () {
seed |= 0; seed = (seed + 0x6D2B79F5) | 0;
var t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
var SEED = 42;
var rand = mulberry32(SEED);
function mat4RotateX(deg) {
var r = deg * Math.PI / 180, c = Math.cos(r), s = Math.sin(r);
return [1, 0, 0, 0, 0, c, s, 0, 0, -s, c, 0, 0, 0, 0, 1];
}
function mat4RotateZ(deg) {
var r = deg * Math.PI / 180, c = Math.cos(r), s = Math.sin(r);
return [c, s, 0, 0, -s, c, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
}
function mat4Mul(a, b) {
var out = new Array(16);
for (var col = 0; col < 4; col++) {
for (var row = 0; row < 4; row++) {
var sum = 0;
for (var k = 0; k < 4; k++) sum += a[k * 4 + row] * b[col * 4 + k];
out[col * 4 + row] = sum;
}
}
return out;
}
function mat4ToCss(m) {
var s = '';
for (var i = 0; i < m.length; i++) s += (i ? ',' : '') + m[i].toFixed(6);
return 'matrix3d(' + s + ')';
}
world.style.transform = mat4ToCss(mat4Mul(mat4RotateX(-35.264), mat4RotateZ(45)));
var mm = window.matchMedia('(max-width: 340px)').matches;
var N = 3;
var TILE_W = mm ? 30 : 38, TILE_H = mm ? 15 : 19;
var HMIN = mm ? 24 : 34, HRANGE = mm ? 30 : 44;
nEl.textContent = String(N * N);
document.getElementById('seed').textContent = String(SEED);
for (var row = 0; row < N; row++) {
for (var col = 0; col < N; col++) {
var h = HMIN + rand() * HRANGE; // 건물 높이 — Seed 난수
var x = (col - row) * TILE_W / 2;
var y = (col + row) * TILE_H / 2;
var bld = document.createElement('div');
bld.className = 'cb__bld';
bld.style.setProperty('--tx', x.toFixed(2) + 'px');
bld.style.setProperty('--ty', y.toFixed(2) + 'px');
bld.style.setProperty('--h', h.toFixed(1) + 'px');
bld.style.zIndex = String(Math.round((col + row) * 10));
world.appendChild(bld);
var roof = document.createElement('div');
roof.className = 'cb__roof';
bld.appendChild(roof);
var winCount = 2 + Math.floor(rand() * 2); // 2~3개 창문 — Seed 난수
for (var w = 0; w < winCount; w++) {
var win = document.createElement('div');
win.className = 'cb__win';
var delay = -(rand() * 3).toFixed(2); // 시작 위상도 Seed 난수 — 창마다 다르게
win.style.setProperty('--delay', delay + 's');
win.style.bottom = (10 + w * 13) + 'px';
bld.appendChild(win);
}
}
}
})();// IsometricCityBlockScene — vanilla 판(run/333/demo/08-isometric-city-block-scene/index.html)
// 의 React 포트. 건물 높이·창문 개수·깜빡임 지연은 mulberry32(시드 42) 로 한 번만 뽑아
// 고정한다 — 배치는 정적, 창문 깜빡임만 진짜 @keyframes 무한 애니메이션으로 계속 돈다.
import { useEffect, useRef } from "react";
import styles from "./IsometricCityBlockScene.module.scss";
export type IsometricCityBlockSceneProps = {
duration?: string;
easing?: string;
color?: string;
};
const HINT = "건물 높이는 시드 42 난수로, 창문 깜빡임 지연도 같은 시드 난수로 정해 매번 같은 도시가 나옵니다";
const ARIA_FIELD = "높이가 저마다 다른 건물 아홉 채가 늘어서고 창문이 각자 다른 박자로 깜빡이는 도시 블록 장면";
const LABEL_SEED = "시드 ";
const LABEL_BUILDINGS = " · 건물 ";
const LABEL_UNIT = "채";
const SEED = 42;
const N = 3;
const TILE_W = 40, TILE_H = 20;
function mulberry32(seed: number): () => number {
let a = seed;
return function (): number {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
type Building = { x: number; y: number; h: number; windows: number[] };
export default function IsometricCityBlockScene({
duration = "3s",
easing = "ease-in-out",
color = "#2f6df6"
}: IsometricCityBlockSceneProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const worldRef = useRef<HTMLDivElement | null>(null);
const countRef = useRef<HTMLElement | null>(null);
useEffect(() => {
const world = worldRef.current;
const root = rootRef.current;
if (!world || !root) return;
const rand = mulberry32(SEED);
const buildings: Building[] = [];
let minX = Infinity, maxX = -Infinity, maxY = -Infinity;
for (let row = 0; row < N; row++) {
for (let col = 0; col < N; col++) {
const x = ((col - row) * TILE_W) / 2;
const y = ((col + row) * TILE_H) / 2;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y > maxY) maxY = y;
const h = 40 + Math.round(rand() * 60);
const winCount = 2 + Math.round(rand() * 3);
const windows: number[] = [];
for (let i = 0; i < winCount; i++) windows.push(rand() * 3);
buildings.push({ x, y, h, windows });
}
}
const shiftX = -(minX + maxX) / 2;
const shiftY = -maxY / 2;
while (world.firstChild) world.removeChild(world.firstChild);
for (const b of buildings) {
const bld = document.createElement("div");
bld.className = styles.bld;
bld.style.setProperty("--tx", (b.x + shiftX).toFixed(2) + "px");
bld.style.setProperty("--ty", (b.y + shiftY).toFixed(2) + "px");
bld.style.setProperty("--h", b.h + "px");
const roof = document.createElement("div");
roof.className = styles.roof;
bld.appendChild(roof);
const winRows = Math.max(1, Math.floor(b.h / 22));
let wi = 0;
for (let r = 0; r < winRows && wi < b.windows.length; r++) {
const win = document.createElement("div");
win.className = styles.win;
win.style.top = 8 + r * 16 + "px";
win.style.setProperty("--delay", b.windows[wi].toFixed(2) + "s");
bld.appendChild(win);
wi++;
}
world.appendChild(bld);
}
if (countRef.current) countRef.current.textContent = String(buildings.length);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) root.classList.add(styles.isStill);
return () => {
while (world.firstChild) world.removeChild(world.firstChild);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div className={styles.cb} ref={rootRef} data-duration={duration} data-easing={easing} data-color={color}>
<p className={styles.hint}>{HINT}</p>
<div className={styles.field} role="img" aria-label={ARIA_FIELD}>
<div className={styles.world} ref={worldRef}></div>
</div>
<p className={styles.meta}>
<span>{LABEL_SEED}</span>
<b>{SEED}</b>
<span>{LABEL_BUILDINGS}</span>
<b ref={countRef}>9</b>
<span>{LABEL_UNIT}</span>
</p>
</div>
</main>
);
}08 City block — a scene where each building's height comes from the layout math — Measured area 1.497% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Isometric CSS: 9 True-Projection 3D Dioramas
The post this demo lives in
Isometric CSS: 9 True-Projection 3D DioramasIsometric css done right: Each cube face shades by a light-angle calc, grid coords map to screen via the 2:1 isometric f See all demos in this post
If you like this effect - similar demos
Interactions09 A Meadow Scene Where Fourteen Bubbles Ride the Wind, Each Computing Its Own Color and ReflectionBubble Animation CSS: 9 Soap-Film Scenes That Pop
Interactions04 Dice That Repeat the Same Sequence from a SeedDice Roll Animation: 9 Scenes with Top-Face Math
Interactions08 A Volley Where the Sky Tints at Each Burst and the Skyline Lights Up by DistanceCSS Fireworks: 9 Bursts, Trails and Reflections