09Hero grove — a fractal forest growing over the brand line
Let several trees grow from one seed by different rules until they reach the tagline, and the first visit feels alive — one computed scene becoming a product's first impression.
Best forFront-door heroes, splashes
Math.sinlineTocreateRadialGradientrequestAnimationFrame

<main class="stage">
<div class="ls is-demo" id="lsRoot">
<div class="ls__hero" role="application" aria-label="Hero grove scene" tabindex="0">
<canvas class="ls__cv" id="lsCv"></canvas>
<div class="ls__veil" aria-hidden="true"></div>
<div class="ls__copy">
<p class="ls__kicker">When one character grows</p>
<p class="ls__title">FRACTAL<br>FOREST</p>
<p class="ls__tag" id="lsTag">Rules gather into a forest</p>
</div>
</div>
<div class="ls__bar">
<span class="ls__meta">Trees <b id="lsCount">5</b> · each a different rule</span>
<span class="ls__meta ls__meta--dim">canvas · createRadialGradient</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 대문 숲(09). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(숲 성장+바람 + 방사 그라디언트 호흡)와 JS 가 매기는
// 태그 문구 투명도(캔버스 값과 같은 호흡 위상)가 만든다. 무대는 편 공통 숲 밤 이끼.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-mint;
$ls-stage: #1a2116;
@include stage($ls-stage);
$ls-ink: #12160e;
$ls-ink2: #0a0d07;
$ls-bark: #d8c4a0;
$ls-leaf: #bfe3c8;
$ls-mist: #dfe6d6;
.ls {
--ls-ink: #{$ls-ink};
--ls-ink2: #{$ls-ink2};
--ls-bark: #{$ls-bark};
--ls-leaf: #{$ls-leaf};
--ls-mist: #{$ls-mist};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.ls__hero {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $ls-ink;
box-shadow: inset 0 0 0 1px rgba($ls-mist, .18);
display: grid; place-items: center;
}
.ls__hero:focus-visible { outline: 2px solid $subject-mint; outline-offset: 2px; }
.ls__cv { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
.ls__veil { position: absolute; inset: 0; pointer-events: none; }
.ls__copy {
position: relative; z-index: 1;
display: flex; flex-direction: column; align-items: center; gap: $sp-1;
margin: 0; text-align: center; padding: 0 $sp-3;
}
.ls__kicker {
margin: 0;
font-weight: 500; font-size: clamp(9px, 2.4vw, 11px); line-height: 1;
letter-spacing: .28em;
color: rgba($ls-mist, .78);
}
.ls__title {
margin: 0;
font-weight: 700; font-size: clamp(18px, 6.4vw, 27px); line-height: 1.08;
letter-spacing: .06em;
color: $ls-mist;
text-shadow: 0 2px 12px rgba($ls-ink2, .8);
}
.ls__tag {
margin: 0;
font-weight: 500; font-size: 10px; line-height: 1.3;
color: rgba($ls-mist, .8);
}
.ls__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.ls__meta { font-size: 10px; font-weight: 500; color: rgba($ls-mist, .72); }
.ls__meta--dim { font-size: 10px; }
.ls__meta b { font-weight: 700; color: $ls-mist; }
@media (max-width: 340px) {
.ls { gap: $sp-1; }
.ls__hero { height: 100px; }
.ls__meta--dim { display: none; }
.ls__tag { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__hero { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #1a2116;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ls {
--ls-ink: #12160e;
--ls-ink2: #0a0d07;
--ls-bark: #d8c4a0;
--ls-leaf: #bfe3c8;
--ls-mist: #dfe6d6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.ls__hero {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #12160e;
box-shadow: inset 0 0 0 1px rgba(223, 230, 214, 0.18);
display: grid;
place-items: center;
}
.ls__hero:focus-visible {
outline: 2px solid #4cd4a6;
outline-offset: 2px;
}
.ls__cv {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.ls__veil {
position: absolute;
inset: 0;
pointer-events: none;
}
.ls__copy {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
margin: 0;
text-align: center;
padding: 0 12px;
}
.ls__kicker {
margin: 0;
font-weight: 500;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1;
letter-spacing: 0.28em;
color: rgba(223, 230, 214, 0.78);
}
.ls__title {
margin: 0;
font-weight: 700;
font-size: clamp(18px, 6.4vw, 27px);
line-height: 1.08;
letter-spacing: 0.06em;
color: #dfe6d6;
text-shadow: 0 2px 12px rgba(10, 13, 7, 0.8);
}
.ls__tag {
margin: 0;
font-weight: 500;
font-size: 10px;
line-height: 1.3;
color: rgba(223, 230, 214, 0.8);
}
.ls__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.ls__meta {
font-size: 10px;
font-weight: 500;
color: rgba(223, 230, 214, 0.72);
}
.ls__meta--dim {
font-size: 10px;
}
.ls__meta b {
font-weight: 700;
color: #dfe6d6;
}
@media (max-width: 340px) {
.ls {
gap: 4px;
}
.ls__hero {
height: 100px;
}
.ls__meta--dim {
display: none;
}
.ls__tag {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__hero {
cursor: default !important;
}
}// 대문 숲(09) — 계산 화면 하나가 서비스 첫 화면이 되는 조립. Trees 다섯 그루가
// 저마다 다른 문자 재쓰기 규칙(F→F[+F]F[-F] 류 세 가지를 섞어)으로 자라 문장 위에 이른다.
// 층 3개: ① 숲 캔버스(gen 성장 + 08 과 같은 깊이비례 바람) ② 방사 그라디언트 비네팅
// (createRadialGradient, 매 프레임 다시 그려 숨쉬듯 짙어졌다 옅어진다) ③ 타이포(kicker·title·tag).
(function () {
// ── 변형 상수 블록 ──
var MAXGEN = 4;
var CYCLE = 2.0;
var N_TREE = 5;
// ─────────────────
var root = document.getElementById('lsRoot');
var hero = document.getElementById('lsCv').parentElement;
var cv = document.getElementById('lsCv');
var tagEl = document.getElementById('lsTag');
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_BARK = tokRGB('--ls-bark');
var C_LEAF = tokRGB('--ls-leaf');
var C_INK = tokRGB('--ls-ink');
var C_INK2 = tokRGB('--ls-ink2');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0;
function fit() {
var r = hero.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cv.width = W; cv.height = H;
}
// 나무마다 다른 재쓰기 규칙 세 가지를 돌려쓴다 — "각기 다른 규칙으로 자란다"를 문자 그대로 구현
var RULES = [
{ 'F': 'F[+F]F[-F]' },
{ 'F': 'F[+F][-F]F' },
{ 'F': 'FF[+F][-F]' }
];
function expand(rule, gen) {
var s = 'F';
for (var g = 0; g < gen; g++) {
var out = '';
for (var i = 0; i < s.length; i++) {
var ch = s.charAt(i);
out += rule[ch] !== undefined ? rule[ch] : ch;
}
s = out;
}
return s;
}
var TREES = [];
(function seed() {
for (var i = 0; i < N_TREE; i++) {
var rule = RULES[i % RULES.length];
TREES.push({
xFrac: (i + 0.5) / N_TREE,
str: expand(rule, MAXGEN),
angle: 20 + (i % 3) * 5,
scale: 0.85 + (i % 2) * 0.22,
phase: i * 0.9,
genDelay: (i % N_TREE) * 0.06
});
}
})();
function rgb(c, a) { return 'rgba(' + (c[0] | 0) + ',' + (c[1] | 0) + ',' + (c[2] | 0) + ',' + a + ')'; }
function drawTree(ctx, tr, ox, oy, sc, loop) {
var str = tr.str;
var grPhase = Math.min(Math.max(0, (loop - tr.genDelay)) / 0.5, 1);
var drawUpto = Math.floor(str.length * grPhase);
var windPhase = loop * Math.PI * 2 * 1.4 + tr.phase;
var x = 0, y = 0, a = -Math.PI / 2;
var stack = [];
var depth = 0, maxDepth = MAXGEN + 1;
for (var i = 0; i < drawUpto; i++) {
var ch = str.charAt(i);
if (ch === 'F') {
var depthT = depth / maxDepth;
var sway = 10 * depthT * depthT * Math.sin(windPhase + depth * 0.8) * Math.PI / 180;
a += sway;
var segLen = tr.scale * 0.11 * Math.pow(0.72, depth);
var nx = x + Math.cos(a) * segLen;
var ny = y + Math.sin(a) * segLen;
var cr = C_BARK[0] + (C_LEAF[0] - C_BARK[0]) * depthT;
var cg = C_BARK[1] + (C_LEAF[1] - C_BARK[1]) * depthT;
var cb = C_BARK[2] + (C_LEAF[2] - C_BARK[2]) * depthT;
ctx.strokeStyle = 'rgb(' + (cr | 0) + ',' + (cg | 0) + ',' + (cb | 0) + ')';
ctx.lineWidth = Math.max(0.8, 3 * Math.pow(0.68, depth));
ctx.beginPath();
ctx.moveTo(ox + x * sc, oy + y * sc);
ctx.lineTo(ox + nx * sc, oy + ny * sc);
ctx.stroke();
x = nx; y = ny;
} else if (ch === '[') {
stack.push({ x: x, y: y, a: a, d: depth });
depth++;
a += tr.angle * Math.PI / 180;
} else if (ch === ']') {
var st = stack.pop();
if (st) { x = st.x; y = st.y; a = st.a; depth = st.d; }
a -= tr.angle * Math.PI / 180;
} else if (ch === '+') {
a += tr.angle * Math.PI / 180;
} else if (ch === '-') {
a -= tr.angle * Math.PI / 180;
}
}
}
function render(loop) {
var ctx = cv.getContext('2d');
ctx.fillStyle = rgb(C_INK, 1);
ctx.fillRect(0, 0, W, H);
var sc = H * 0.98;
for (var i = 0; i < TREES.length; i++) {
var tr = TREES[i];
drawTree(ctx, tr, W * tr.xFrac, H * 0.98, sc, loop);
}
// 방사 그라디언트 비네팅 — 문장이 잘 읽히도록 가운데를 밝히고 가장자리를 죽인다.
// 반지름을 호흡시켜 정지 구간에도 픽셀이 계속 갱신된다.
var breath = 0.5 + 0.5 * Math.sin(loop * Math.PI * 2 * 0.6);
var gx = W * 0.5, gy = H * 0.42;
var r0 = Math.min(W, H) * (0.08 + 0.02 * breath);
var r1 = Math.max(W, H) * 0.75;
var grad = ctx.createRadialGradient(gx, gy, r0, gx, gy, r1);
grad.addColorStop(0, rgb(C_INK, 0));
grad.addColorStop(0.55, rgb(C_INK, 0.18 + 0.06 * breath));
grad.addColorStop(1, rgb(C_INK2, 0.86));
ctx.fillStyle = grad;
ctx.fillRect(0, 0, W, H);
if (tagEl) tagEl.style.opacity = String((0.62 + 0.38 * breath).toFixed(2));
}
function frame(now) {
if (t0 < 0) t0 = now;
var loop = (((now - t0) / 1000) % CYCLE) / CYCLE;
render(loop);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
render(0.9);
});
fit();
if (reduce) {
render(0.9);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// LsysHeroScene — vanilla 판(run/328/demo/09-lsys-hero-scene/index.html) 의 React 포트.
// 계산 화면 하나가 서비스 첫 화면이 되는 조립. 나무 다섯 그루가 저마다 다른 문자 재쓰기 규칙
// (F→F[+F]F[-F] 류 세 가지를 섞어)으로 자라 문장 위에 이른다.
// 층 3개: ① 숲 캔버스(세대 성장 + 08 과 같은 깊이비례 바람) ② 방사 그라디언트 비네팅
// (createRadialGradient, 매 프레임 다시 그려 숨쉬듯 짙어졌다 옅어진다) ③ 타이포(kicker·title·tag).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LsysHeroScene.module.scss";
export type LsysHeroSceneProps = {
duration?: string;
easing?: string;
color?: string;
maxGen?: number;
cycle?: number;
nTree?: number;
};
const MAXGEN = 4;
const CYCLE = 2.0;
const N_TREE = 5;
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Stack = { x: number; y: number; a: number; d: number };
type Tree = { xFrac: number; str: string; angle: number; scale: number; phase: number; genDelay: number };
const KICKER_TEXT = "문자 하나가 자라면";
const TITLE_LINE1 = "FRACTAL";
const TITLE_LINE2 = "FOREST";
const TAG_TEXT = "규칙이 모여 숲이 됩니다";
const META_LEFT_PREFIX = "나무 ";
const META_LEFT_SUFFIX = "그루 · 규칙 각각 다름";
const META_RIGHT = "canvas · createRadialGradient";
const HERO_LABEL = "대문 숲 히어로 씬";
const RULES: Record<string, string>[] = [
{ F: "F[+F]F[-F]" },
{ F: "F[+F][-F]F" },
{ F: "FF[+F][-F]" }
];
function expand(rule: Record<string, string>, gen: number): string {
let s = "F";
for (let g = 0; g < gen; g++) {
let out = "";
for (let i = 0; i < s.length; i++) {
const ch = s.charAt(i);
out += rule[ch] !== undefined ? rule[ch] : ch;
}
s = out;
}
return s;
}
function seedTrees(nTree: number, maxGen: number): Tree[] {
const trees: Tree[] = [];
for (let i = 0; i < nTree; i++) {
const rule = RULES[i % RULES.length];
trees.push({
xFrac: (i + 0.5) / nTree,
str: expand(rule, maxGen),
angle: 20 + (i % 3) * 5,
scale: 0.85 + (i % 2) * 0.22,
phase: i * 0.9,
genDelay: (i % nTree) * 0.06
});
}
return trees;
}
function rgb(c: RGB, a: number): string {
return "rgba(" + (c[0] | 0) + "," + (c[1] | 0) + "," + (c[2] | 0) + "," + a + ")";
}
export default function LsysHeroScene({
duration = "2s",
easing = "ease-spring",
color = "#4cd4a6",
maxGen = MAXGEN,
cycle = CYCLE,
nTree = N_TREE
}: LsysHeroSceneProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const heroRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const tagRef = useRef<HTMLParagraphElement | null>(null);
const colRef = useRef<{ bark: RGB; leaf: RGB; ink: RGB; ink2: RGB } | null>(null);
const treesRef = useRef<Tree[]>([]) as Box<Tree[]>;
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function fit(): void {
const hero = heroRef.current;
const cv = cvRef.current;
if (!hero || !cv) return;
const r = hero.getBoundingClientRect();
wRef.current = Math.round(r.width);
hRef.current = Math.round(r.height);
cv.width = wRef.current;
cv.height = hRef.current;
}
function drawTree(ctx: CanvasRenderingContext2D, tr: Tree, ox: number, oy: number, sc: number, loop: number): void {
const col = colRef.current;
if (!col) return;
const str = tr.str;
const grPhase = Math.min(Math.max(0, loop - tr.genDelay) / 0.5, 1);
const drawUpto = Math.floor(str.length * grPhase);
const windPhase = loop * Math.PI * 2 * 1.4 + tr.phase;
let x = 0, y = 0, a = -Math.PI / 2;
const stack: Stack[] = [];
let depth = 0;
const maxDepth = maxGen + 1;
for (let i = 0; i < drawUpto; i++) {
const ch = str.charAt(i);
if (ch === "F") {
const depthT = depth / maxDepth;
const sway = 10 * depthT * depthT * Math.sin(windPhase + depth * 0.8) * Math.PI / 180;
a += sway;
const segLen = tr.scale * 0.11 * Math.pow(0.72, depth);
const nx = x + Math.cos(a) * segLen;
const ny = y + Math.sin(a) * segLen;
const cr = col.bark[0] + (col.leaf[0] - col.bark[0]) * depthT;
const cg = col.bark[1] + (col.leaf[1] - col.bark[1]) * depthT;
const cb = col.bark[2] + (col.leaf[2] - col.bark[2]) * depthT;
ctx.strokeStyle = "rgb(" + (cr | 0) + "," + (cg | 0) + "," + (cb | 0) + ")";
ctx.lineWidth = Math.max(0.8, 3 * Math.pow(0.68, depth));
ctx.beginPath();
ctx.moveTo(ox + x * sc, oy + y * sc);
ctx.lineTo(ox + nx * sc, oy + ny * sc);
ctx.stroke();
x = nx; y = ny;
} else if (ch === "[") {
stack.push({ x, y, a, d: depth });
depth++;
a += tr.angle * Math.PI / 180;
} else if (ch === "]") {
const st = stack.pop();
if (st) { x = st.x; y = st.y; a = st.a; depth = st.d; }
a -= tr.angle * Math.PI / 180;
} else if (ch === "+") {
a += tr.angle * Math.PI / 180;
} else if (ch === "-") {
a -= tr.angle * Math.PI / 180;
}
}
}
function render(loop: number): void {
const cv = cvRef.current;
const col = colRef.current;
const TREES = treesRef.current;
const W = wRef.current, H = hRef.current;
if (!cv || !col) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
ctx.fillStyle = rgb(col.ink, 1);
ctx.fillRect(0, 0, W, H);
const sc = H * 0.98;
for (let i = 0; i < TREES.length; i++) {
const tr = TREES[i];
drawTree(ctx, tr, W * tr.xFrac, H * 0.98, sc, loop);
}
const breath = 0.5 + 0.5 * Math.sin(loop * Math.PI * 2 * 0.6);
const gx = W * 0.5, gy = H * 0.42;
const r0 = Math.min(W, H) * (0.08 + 0.02 * breath);
const r1 = Math.max(W, H) * 0.75;
const grad = ctx.createRadialGradient(gx, gy, r0, gx, gy, r1);
grad.addColorStop(0, rgb(col.ink, 0));
grad.addColorStop(0.55, rgb(col.ink, 0.18 + 0.06 * breath));
grad.addColorStop(1, rgb(col.ink2, 0.86));
ctx.fillStyle = grad;
ctx.fillRect(0, 0, W, H);
const tagEl = tagRef.current;
if (tagEl) tagEl.style.opacity = (0.62 + 0.38 * breath).toFixed(2);
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const loop = (((now - t0) / 1000) % cycle) / cycle;
render(loop);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
colRef.current = {
bark: tokRGB(root, "--ls-bark"),
leaf: tokRGB(root, "--ls-leaf"),
ink: tokRGB(root, "--ls-ink"),
ink2: tokRGB(root, "--ls-ink2")
};
treesRef.current = seedTrees(nTree, maxGen);
fit();
const reduce = reduced();
function onResize(): void {
fit();
render(0.9);
}
window.addEventListener("resize", onResize);
if (reduce) {
render(0.9);
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [maxGen, cycle, nTree]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<div className={styles.hero} ref={heroRef} role="application" aria-label={HERO_LABEL} tabIndex={0}>
<canvas className={styles.cv} ref={cvRef}></canvas>
<div className={styles.veil} aria-hidden="true"></div>
<div className={styles.copy}>
<p className={styles.kicker}>{KICKER_TEXT}</p>
<p className={styles.title}>{TITLE_LINE1}<br />{TITLE_LINE2}</p>
<p className={styles.tag} ref={tagRef}>{TAG_TEXT}</p>
</div>
</div>
<div className={styles.bar}>
<span className={styles.meta}>{META_LEFT_PREFIX}<b>{nTree}</b>{META_LEFT_SUFFIX}</span>
<span className={`${styles.meta} ${styles.metaDim}`}>{META_RIGHT}</span>
</div>
</div>
</main>
);
}09 Hero grove — a fractal forest growing over the brand line — Measured area 11.5% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · L-System Fractal Tree Generator: 9 Demos
The post this demo lives in
L-System Fractal Tree Generator: 9 DemosOne F steps forward, one + turns. Reread those few letters enough times and branches grow on their own — the whole recur See all demos in this post
If you like this effect - similar demos
Creative UI09 Hero weave — a front door knitting the brand in threadString Art: 9 Thread-Geometry Demos
Creative UI09 Butterfly hero scene — a front-door stage layered with type and particlesLorenz Attractor: 9 Butterfly Chaos Demos
Creative UI09 Hero graph — a front door that knits a product's features together by forceForce Directed Graph: 9 Network Layout Demos