01Butterfly basics — three differential equations, one Euler step at a time
Turn three velocity equations into small position steps each frame and the two-lobed butterfly accumulates by itself — the base screen every chaos visualization starts from.
Best forMath-content diagrams, hero backgrounds
CanvasRenderingContext2DrequestAnimationFramelineTo

<main class="stage">
<div class="lz is-demo" id="lzRoot">
<p class="lz__hint">
<span>Move the position a little each frame from three velocity equations, connect the dots, and the two-lobed butterfly orbit piles up by itself</span>
</p>
<div class="lz__field" role="application" aria-label="Butterfly basics playground" tabindex="0">
<canvas class="lz__cv" id="lzCv"></canvas>
</div>
<div class="lz__bar">
<span class="lz__meta">sigma 10 · rho 28 · beta 8/3 · Euler dt 0.01</span>
<span class="lz__meta lz__meta--dim">canvas · 3,000 steps · 2s cycle</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 나비 기본(01) — @keyframes 를 두지 않는다.
// 움직임은 전부 캔버스 rAF 루프(로렌츠 세 식을 오일러로 적분해 궤적을 순서대로 드러낸다)이 만든다.
// 무대·팔레트는 편 공통 심해 관측 규격 — 밝은 계열 파스텔과 낮은 채도로 유지한다.
$lz-stage: #16202b; // 심해 남색 — 저명도 무대
@include stage($lz-stage);
$lz-panel: #0d1622; // 관측창 바닥 — 무대보다 한 단계 어두운 남검
$lz-text: #ccd8e6; // 라벨 글자 — 밝은 청회
$lz-c0: #cfe3ff; // 얼음 파스텔 — 궤적 꼬리·기본 강조
$lz-c1: #ffd9c9; // 복숭아 파스텔 — 궤적 머리
$lz-c2: #d8f0e3; // 민트 크림 — 두 번째 궤적
$lz-c3: #e6d9ff; // 라일락 화이트 — 세 번째 궤적
$lz-mid: #5f87b8; // 밀도 램프 중간 — 저채도 청색
$duration: $dur-loop;
$easing: $ease-out;
$color: $lz-c0;
.lz {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --lz-* 를 받아 쓴다
--lz-panel: #{$lz-panel};
--lz-c0: #{$lz-c0};
--lz-c1: #{$lz-c1};
--lz-c2: #{$lz-c2};
--lz-c3: #{$lz-c3};
--lz-mid: #{$lz-mid};
--lz-head: #{$subject-cream};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.lz__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $lz-text;
}
.lz__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $lz-panel;
box-shadow: inset 0 0 0 1px rgba($lz-text, .18);
}
.lz__field:focus-visible { outline: 2px solid $lz-c0; outline-offset: 2px; }
.lz__cv { display: block; width: 100%; height: 100%; }
.lz__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.lz__meta { font-size: 10px; font-weight: 500; color: rgba($lz-text, .72); }
.lz__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.lz { gap: $sp-1; }
.lz__field { height: 100px; }
.lz__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 드러난 궤적 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.lz.is-still .lz__field { 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: #16202b;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lz {
--lz-panel: #0d1622;
--lz-c0: #cfe3ff;
--lz-c1: #ffd9c9;
--lz-c2: #d8f0e3;
--lz-c3: #e6d9ff;
--lz-mid: #5f87b8;
--lz-head: #fff7e6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.lz__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #ccd8e6;
}
.lz__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #0d1622;
box-shadow: inset 0 0 0 1px rgba(204, 216, 230, 0.18);
}
.lz__field:focus-visible {
outline: 2px solid #cfe3ff;
outline-offset: 2px;
}
.lz__cv {
display: block;
width: 100%;
height: 100%;
}
.lz__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.lz__meta {
font-size: 10px;
font-weight: 500;
color: rgba(204, 216, 230, 0.72);
}
.lz__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.lz {
gap: 4px;
}
.lz__field {
height: 100px;
}
.lz__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.lz.is-still .lz__field {
cursor: default !important;
}
}// 나비 기본(01) — 로렌츠 어트랙터의 첫 장면.
// dx = σ(y−x), dy = x(ρ−z)−y, dz = xy−βz 세 속도를 오일러로 한 걸음씩 적분해
// 위치를 옮기고 선을 잇는다. 미리 계산한 3,000걸음을 주기 안에 순서대로 드러내고
// 마지막 18% 구간에서 서서히 지워 루프가 이어진다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var DT = 0.01; // 오일러 걸음 — 0.005 로 줄이면 같은 거리에 두 배 걸음(변형 1)
var STEPS = 3000; // 사전 계산 걸음 수 — 공개 구간에 전부 드러난다
var CYCLE = 2.0; // 공개 주기(초) — 설정 초(2s)와 같아 한 바퀴가 한 루프
// ────────────────────────────────────────────────────
var SIG = 10, RHO = 28, BETA = 8 / 3;
var root = document.getElementById('lzRoot');
var cvEl = document.getElementById('lzCv');
if (!root || !cvEl) return;
var fieldEl = cvEl.parentElement;
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 C0 = tokRGB('--lz-c0'); // 꼬리 색 — 얼음 파스텔
var C1 = tokRGB('--lz-c1'); // 머리 색 — 복숭아 파스텔
var HEAD = tokRGB('--lz-head'); // 머리 점 — 크림(토큰)
var PANEL = tokRGB('--lz-panel'); // 관측창 바닥
var GUIDE = tokRGB('--lz-mid'); // 안내선 — 저채도 청색
function rgba(c, a) { return 'rgba(' + (c[0] | 0) + ',' + (c[1] | 0) + ',' + (c[2] | 0) + ',' + a + ')'; }
function mix(a, b, t) { return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; }
// 로렌츠 속도 — 반환 순서 dx, dy, dz (단위: 무차원)
function deriv(x, y, z) {
return [SIG * (y - x), x * (RHO - z) - y, x * y - BETA * z];
}
// 궤적 사전 계산 — 과도기 200걸음을 건너뛰어 어트랙터 위에서 시작한다
var xs = new Float32Array(STEPS), ys = new Float32Array(STEPS), zs = new Float32Array(STEPS);
var x = 0.6, y = 0.7, z = 21, i;
for (i = 0; i < 200; i++) {
var d0 = deriv(x, y, z);
x += d0[0] * DT; y += d0[1] * DT; z += d0[2] * DT;
}
for (i = 0; i < STEPS; i++) {
xs[i] = x; ys[i] = y; zs[i] = z;
var d1 = deriv(x, y, z);
x += d1[0] * DT; y += d1[1] * DT; z += d1[2] * DT;
}
// 조각별 평균 깊이 — y 는 관찰 방향이라 멀/가까이가 색 농도와 두께가 된다
var CH = 24;
var chunkNy = new Float32Array(CH);
(function () {
var k, a, b, sum, cnt, ci, v;
for (k = 0; k < CH; k++) {
a = Math.floor(k * STEPS / CH); b = Math.floor((k + 1) * STEPS / CH);
sum = 0; cnt = 0;
for (ci = a; ci < b; ci++) { sum += ys[ci]; cnt++; }
v = cnt ? (sum / cnt + 27) / 57 : 0.5;
chunkNy[k] = Math.max(0, Math.min(1, v));
}
})();
var ctx = cvEl.getContext('2d');
var W = 0, H = 0;
function fit() {
var r = fieldEl.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cvEl.width = W; cvEl.height = H;
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
}
var REVEAL_END = 0.82; // 걸음 공개가 끝나는 위상 — 이후 지우기
function draw(phase) {
ctx.fillStyle = rgba(PANEL, 1);
ctx.fillRect(0, 0, W, H);
var s = Math.min(W / 44, H / 52);
var ox = W / 2, oy = H / 2;
// 안내선 — 날개 경계 x = 0
ctx.strokeStyle = rgba(GUIDE, 0.22);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(ox, 0); ctx.lineTo(ox, H);
ctx.stroke();
var upto = Math.min(1, phase / REVEAL_END);
var n = Math.floor(upto * STEPS);
if (n < 2) return;
var fade = phase > REVEAL_END ? Math.max(0, 1 - (phase - REVEAL_END) / (1 - REVEAL_END)) : 1;
// 두 번의 stroke — 뒤네루게(넓고 옅게) + 알맹이(가늘고 진하게)
var passes = [[3.2, 0.14], [1.4, 0.85]];
var pass, k, a, b, ci, t, col, ny;
for (pass = 0; pass < 2; pass++) {
var wMul = passes[pass][0], aMul = passes[pass][1];
for (k = 0; k < CH; k++) {
a = Math.floor(k * STEPS / CH);
b = Math.min(n, Math.floor((k + 1) * STEPS / CH));
if (b - a < 2) continue;
t = (k + 0.5) / CH;
ny = chunkNy[k];
col = mix(C0, C1, t);
ctx.strokeStyle = rgba(col, aMul * (0.45 + 0.55 * ny) * fade);
ctx.lineWidth = wMul * (0.8 + 0.9 * ny);
ctx.beginPath();
ctx.moveTo(ox + xs[a] * s, oy - (zs[a] - 25) * s);
for (ci = a + 1; ci < b; ci++) ctx.lineTo(ox + xs[ci] * s, oy - (zs[ci] - 25) * s);
ctx.stroke();
}
}
// 머리 — 현재 걸음 위치에 크림 점과 옅은 광원
var hx = ox + xs[n - 1] * s, hy = oy - (zs[n - 1] - 25) * s;
ctx.fillStyle = rgba(HEAD, 0.22 * fade);
ctx.beginPath(); ctx.arc(hx, hy, 6, 0, 6.2832); ctx.fill();
ctx.fillStyle = rgba(HEAD, fade);
ctx.beginPath(); ctx.arc(hx, hy, 2.2, 0, 6.2832); ctx.fill();
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var t0 = -1;
function frame(now) {
if (t0 < 0) t0 = now;
var phase = (((now - t0) / 1000) % CYCLE) / CYCLE;
draw(phase);
requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) draw(0.62);
});
fit();
if (reduce) {
draw(0.62); // reduce — 드러난 궤적 한 장만 고정
root.classList.add('is-still');
} else {
requestAnimationFrame(frame);
}
})();// LorenzButterflyBasic — vanilla 판(run/324/demo/01-lorenz-butterfly-basic/index.html) 의 React 포트.
// dx = σ(y−x), dy = x(ρ−z)−y, dz = xy−βz 세 속도를 오일러로 한 걸음씩 적분해
// 위치를 옮기고 선을 잇는다. 미리 계산한 3,000걸음을 주기 안에 순서대로 드러내고
// 마지막 18% 구간에서 서서히 지워 루프가 이어진다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LorenzButterflyBasic.module.scss";
export type LorenzButterflyBasicProps = {
duration?: string;
easing?: string;
color?: string;
dt?: number; // 오일러 걸음 — 0.005 로 줄이면 같은 거리에 두 배 걸음(변형 1)
steps?: number; // 사전 계산 걸음 수 — 공개 구간에 전부 드러난다
};
// ── 변형 상수(기본값) — vanilla 판과 동일 ──
const DT = 0.01; // 오일러 걸음
const STEPS = 3000; // 사전 계산 걸음 수
const CYCLE = 2.0; // 공개 주기(초) — 설정 초(2s)와 같아 한 바퀴가 한 루프
const REVEAL_END = 0.82; // 걸음 공개가 끝나는 위상 — 이후 지우기
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
const FIELD_LABEL = "나비 기본 실험판";
const META_LEFT = "σ 10 · ρ 28 · β 8/3 · 오일러 dt 0.01";
const META_RIGHT = "canvas · 3,000걸음 · 주기 2초";
const HINT = "세 개의 속도 식에서 위치를 매 프레임 조금씩 옮겨 선을 잇으면 두 날개를 오가는 나비 궤적이 저절로 쌓입니다";
const SIG = 10, RHO = 28, BETA = 8 / 3;
// 로렌츠 속도 — 반환 순서 dx, dy, dz (단위: 무차원)
function deriv(x: number, y: number, z: number): [number, number, number] {
return [SIG * (y - x), x * (RHO - z) - y, x * y - BETA * z];
}
function rgba(c: RGB, a: number): string {
return "rgba(" + (c[0] | 0) + "," + (c[1] | 0) + "," + (c[2] | 0) + "," + a + ")";
}
function mix(a: RGB, b: RGB, t: number): RGB {
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
}
export default function LorenzButterflyBasic({
duration = "2s",
easing = "ease-out",
color = "#cfe3ff",
dt = DT,
steps = STEPS
}: LorenzButterflyBasicProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const c0Ref = useRef<RGB | null>(null);
const c1Ref = useRef<RGB | null>(null);
const headRef = useRef<RGB | null>(null);
const panelRef = useRef<RGB | null>(null);
const guideRef = useRef<RGB | null>(null);
const trajRef = useRef<{ xs: Float32Array; ys: Float32Array; zs: Float32Array; chunkNy: Float32Array } | null>(null);
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 field = fieldRef.current;
const cv = cvRef.current;
const ctx = ctxRef.current;
if (!field || !cv || !ctx) return;
const r = field.getBoundingClientRect();
wRef.current = Math.round(r.width);
hRef.current = Math.round(r.height);
cv.width = wRef.current;
cv.height = hRef.current;
ctx.lineCap = "round";
ctx.lineJoin = "round";
}
// 궤적 사전 계산 — 과도기 200걸음을 건너뛰어 어트랙터 위에서 시작한다
function precompute(n: number, step: number): void {
const xs = new Float32Array(n), ys = new Float32Array(n), zs = new Float32Array(n);
let x = 0.6, y = 0.7, z = 21;
for (let i = 0; i < 200; i++) {
const d0 = deriv(x, y, z);
x += d0[0] * step; y += d0[1] * step; z += d0[2] * step;
}
for (let i = 0; i < n; i++) {
xs[i] = x; ys[i] = y; zs[i] = z;
const d1 = deriv(x, y, z);
x += d1[0] * step; y += d1[1] * step; z += d1[2] * step;
}
// 조각별 평균 깊이 — y 는 관찰 방향이라 멀/가까이가 색 농도와 두께가 된다
const CH = 24;
const chunkNy = new Float32Array(CH);
for (let k = 0; k < CH; k++) {
const a = Math.floor(k * n / CH), b = Math.floor((k + 1) * n / CH);
let sum = 0, cnt = 0;
for (let ci = a; ci < b; ci++) { sum += ys[ci]; cnt++; }
const v = cnt ? (sum / cnt + 27) / 57 : 0.5;
chunkNy[k] = Math.max(0, Math.min(1, v));
}
trajRef.current = { xs, ys, zs, chunkNy };
}
function draw(phase: number): void {
const ctx = ctxRef.current;
const traj = trajRef.current;
const PANEL = panelRef.current;
const GUIDE = guideRef.current;
const C0 = c0Ref.current;
const C1 = c1Ref.current;
const HEAD = headRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !traj || !PANEL || !GUIDE || !C0 || !C1 || !HEAD) return;
const { xs, zs, chunkNy } = traj;
const STEPSN = xs.length;
const CH = chunkNy.length;
ctx.fillStyle = rgba(PANEL, 1);
ctx.fillRect(0, 0, W, H);
const s = Math.min(W / 44, H / 52);
const ox = W / 2, oy = H / 2;
// 안내선 — 날개 경계 x = 0
ctx.strokeStyle = rgba(GUIDE, 0.22);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(ox, 0);
ctx.lineTo(ox, H);
ctx.stroke();
const upto = Math.min(1, phase / REVEAL_END);
const n = Math.floor(upto * STEPSN);
if (n < 2) return;
const fade = phase > REVEAL_END ? Math.max(0, 1 - (phase - REVEAL_END) / (1 - REVEAL_END)) : 1;
// 두 번의 stroke — 뒤네루게(넓고 옅게) + 알맹이(가늘고 진하게)
const passes: [number, number][] = [[3.2, 0.14], [1.4, 0.85]];
for (let pass = 0; pass < 2; pass++) {
const wMul = passes[pass][0], aMul = passes[pass][1];
for (let k = 0; k < CH; k++) {
const a = Math.floor(k * STEPSN / CH);
const b = Math.min(n, Math.floor((k + 1) * STEPSN / CH));
if (b - a < 2) continue;
const t = (k + 0.5) / CH;
const ny = chunkNy[k];
const col = mix(C0, C1, t);
ctx.strokeStyle = rgba(col, aMul * (0.45 + 0.55 * ny) * fade);
ctx.lineWidth = wMul * (0.8 + 0.9 * ny);
ctx.beginPath();
ctx.moveTo(ox + xs[a] * s, oy - (zs[a] - 25) * s);
for (let ci = a + 1; ci < b; ci++) ctx.lineTo(ox + xs[ci] * s, oy - (zs[ci] - 25) * s);
ctx.stroke();
}
}
// 머리 — 현재 걸음 위치에 크림 점과 옅은 광원
const hx = ox + xs[n - 1] * s, hy = oy - (zs[n - 1] - 25) * s;
ctx.fillStyle = rgba(HEAD, 0.22 * fade);
ctx.beginPath(); ctx.arc(hx, hy, 6, 0, 6.2832); ctx.fill();
ctx.fillStyle = rgba(HEAD, fade);
ctx.beginPath(); ctx.arc(hx, hy, 2.2, 0, 6.2832); ctx.fill();
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const phase = (((now - t0) / 1000) % CYCLE) / CYCLE;
draw(phase);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
c0Ref.current = tokRGB(root, "--lz-c0"); // 꼬리 색 — 얼음 파스텔
c1Ref.current = tokRGB(root, "--lz-c1"); // 머리 색 — 복숭아 파스텔
headRef.current = tokRGB(root, "--lz-head"); // 머리 점 — 크림(토큰)
panelRef.current = tokRGB(root, "--lz-panel");// 관측창 바닥
guideRef.current = tokRGB(root, "--lz-mid"); // 안내선 — 저채도 청색
ctxRef.current = cv.getContext("2d");
precompute(steps, dt);
fit();
function onResize(): void {
fit();
if (reduced()) draw(0.62);
}
window.addEventListener("resize", onResize);
if (reduced()) {
draw(0.62); // reduce — 드러난 궤적 한 장만 고정
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
}, [steps, dt]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>{HINT}</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>{META_LEFT}</span>
<span className={styles.metaDim}>{META_RIGHT}</span>
</div>
</div>
</main>
);
}01 Butterfly basics — three differential equations, one Euler step at a time — Measured area 4.882% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Lorenz Attractor: 9 Butterfly Chaos Demos
The post this demo lives in
Lorenz Attractor: 9 Butterfly Chaos DemosIntegrate three simple equations step by step and a butterfly-shaped orbit draws itself. Copy-paste chaos, zero librarie See all demos in this post
If you like this effect - similar demos
Creative UI03 Drawing a star — coefficients sorted by size, one stroke of the penFourier Epicycles, 9 Ways (No Library)
Creative UI06 Tidal tail close-up — the fling seen through one star's trailN Body Simulation, 9 Ways (Galaxy, No Library)
Creative UI01 Escape time basics — iterate z = z² + c per pixel and color by escape countMandelbrot Zoom, 9 Ways (Escape-Time, No Library)