01The base kaleidoscope brush — one stroke reflected 2n times
Each pointermove segment is drawn into one wedge with setTransform; every other wedge replays the same path under a rotation by 2π/n and a y-flip. One brush splits into sixteen strokes.
Best forDrawing apps, playful canvases
setTransformlineTo

<main class="stage">
<div class="kb" id="kb">
<p class="kb__hint">Draw one stroke and reflection matrices fill every remaining wedge</p>
<div class="kb__row">
<div class="kb__field" id="field" role="application" aria-label="Kaleidoscope brush lab" tabindex="0">
<canvas class="kb__cv" id="cv"></canvas>
</div>
<div class="kb__side">
<b class="kb__num" id="parts">16</b>
<span class="kb__unit">reflected strokes 2n</span>
<p class="kb__meta">Order n = 8</p>
<p class="kb__meta"><b class="kb__count" id="cnt">0</b><span class="kb__metaUnit">strokes</span></p>
<p class="kb__meta">Draw it yourself with mouse or finger</p>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 만화경 붓(01) — 움직임은 전부 캔버스 rAF 루프 안에 있다(077 방식). CSS @keyframes 를
// 두지 않는다 — 애니메이션이 0개여야 렌더러가 실시간으로 24장을 찍는다.
// 선빛깔 세 개는 파스텔 팔레트(채도 .34) — 면 위 명도 .74 / 어두운 보조 .46.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-sky;
@include stage($stage-ink);
.kb {
--c1: #a6b4d3; // 파랑빛 파스텔 — 붓 첫째
--c2: #d3a6c8; // 연분홍 파스텔 — 붓 둘째
--c3: #a6d3b1; // 연두 파스텔 — 붓 셋째
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 420px;
}
.kb__hint {
margin: 0; font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-cream;
}
.kb__row { display: flex; gap: $sp-2; align-items: stretch; }
.kb__field {
position: relative; overflow: hidden;
flex: 0 0 auto;
width: clamp(176px, 46vw, 232px);
height: clamp(176px, 46vw, 232px);
border-radius: $r-card;
background: rgba($subject-cream, .04);
box-shadow: inset 0 0 0 1px rgba($subject-cream, .14);
cursor: crosshair; touch-action: none;
}
.kb__field:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.kb__cv { display: block; width: 100%; height: 100%; }
.kb__side {
flex: 1; min-width: 0;
display: flex; flex-direction: column; gap: $sp-1;
padding-top: $sp-1;
}
.kb__num {
font-weight: 800; font-size: clamp(20px, 6vw, 30px); line-height: 1;
color: $stage-yellow; font-variant-numeric: tabular-nums;
}
.kb__unit { font-weight: 600; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.4; color: rgba($subject-cream, .82); }
.kb__meta { margin: $sp-1 0 0; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.5; color: rgba($subject-cream, .82); }
.kb__meta b { font-weight: 700; color: $stage-yellow; font-variant-numeric: tabular-nums; margin-right: $sp-1; }
.kb__metaUnit { color: rgba($subject-cream, .72); }
@media (max-width: 340px) {
.kb { gap: $sp-1; }
.kb__field { width: 150px; height: 150px; }
.kb__num { font-size: 16px; }
.kb__meta { margin-top: 0; font-size: 8px; }
}
// reduce — 루프가 안 돈다. 미리 그려 둔 정적 무늬 한 장이 남는다.
@media (prefers-reduced-motion: reduce) {
.kb.is-still .kb__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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.kb {
--c1: #a6b4d3;
--c2: #d3a6c8;
--c3: #a6d3b1;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 420px;
}
.kb__hint {
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #fff7e6;
}
.kb__row {
display: flex;
gap: 8px;
align-items: stretch;
}
.kb__field {
position: relative;
overflow: hidden;
flex: 0 0 auto;
width: clamp(176px, 46vw, 232px);
height: clamp(176px, 46vw, 232px);
border-radius: 16px;
background: rgba(255, 247, 230, 0.04);
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.14);
cursor: crosshair;
touch-action: none;
}
.kb__field:focus-visible {
outline: 2px solid #6ec8ff;
outline-offset: 2px;
}
.kb__cv {
display: block;
width: 100%;
height: 100%;
}
.kb__side {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 4px;
}
.kb__num {
font-weight: 800;
font-size: clamp(20px, 6vw, 30px);
line-height: 1;
color: #ffd23f;
font-variant-numeric: tabular-nums;
}
.kb__unit {
font-weight: 600;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.4;
color: rgba(255, 247, 230, 0.82);
}
.kb__meta {
margin: 4px 0 0;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.5;
color: rgba(255, 247, 230, 0.82);
}
.kb__meta b {
font-weight: 700;
color: #ffd23f;
font-variant-numeric: tabular-nums;
margin-right: 4px;
}
.kb__metaUnit {
color: rgba(255, 247, 230, 0.72);
}
@media (max-width: 340px) {
.kb {
gap: 4px;
}
.kb__field {
width: 150px;
height: 150px;
}
.kb__num {
font-size: 16px;
}
.kb__meta {
margin-top: 0;
font-size: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.kb.is-still .kb__field {
cursor: default !important;
}
}// 만화경 붓 — 반사 행렬이 핵심이다. k번째 부채꼴의 변환은 회전 R(a) 와 y축 반사의 곱
// [[cos a, sin a], [sin a, -cos a]] (a = k · 2π/n). 선분 하나를 이 행렬로 n번 찍으면
// 좌우 반사까지 합쳐 2n 갈래의 대칭 무늬가 그린 순간에 생긴다. 경로는 파라미터 함수라
// 시드 난수 없이도 항상 같은 데모가 반복된다.
(function () {
var root = document.getElementById('kb');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var parts = document.getElementById('parts');
var cnt = document.getElementById('cnt');
if (!root || !field || !cv || !parts || !cnt) return;
var ctx = cv.getContext('2d');
var css = getComputedStyle(root);
var COLS = [css.getPropertyValue('--c1').trim(), css.getPropertyValue('--c2').trim(), css.getPropertyValue('--c3').trim()];
var N = 8; // 차수 n — 반사까지 합치면 2N = 16 갈래
parts.textContent = String(2 * N);
var strokes = 0;
var W = 0, H = 0, dpr = 1, cx = 0, cy = 0, R = 0;
function fit() {
W = field.clientWidth; H = field.clientHeight;
dpr = Math.min(2, window.devicePixelRatio || 1);
cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr);
cx = W / 2; cy = H / 2; R = Math.min(W, H) / 2 - 6;
}
// 반사 행렬 스트로크 — 선분 하나를 2N 위치에 찍는다. setTransform 의 여섯 인수는
// (a, b, c, d, e, f) = 행렬 2×2 + 이동. 여기 곱한 행렬이 곧 회전×반사다
function mirroredSeg(x0, y0, x1, y1, ci) {
for (var k = 0; k < N; k++) {
var a = k * 2 * Math.PI / N, c = Math.cos(a), s = Math.sin(a);
ctx.setTransform(dpr * c, dpr * s, dpr * s, -dpr * c, cx * dpr, cy * dpr);
ctx.strokeStyle = COLS[ci % 3];
ctx.lineWidth = 6;
ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.stroke();
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
strokes += 1;
cnt.textContent = String(strokes);
}
// 시드 고정 자동 strokes — 붓 세 자루가 위상을 달리해 함께 돈다
function pathAt(t, ph) {
var ang = t * 1.55 + ph + 0.9 * Math.sin(t * 0.31 + ph);
var r = R * (0.30 + 0.34 * Math.sin(t * 0.47 + ph * 2) + 0.20 * Math.sin(t * 0.13 + ph));
if (r < R * 0.07) r = R * 0.07;
return [cx + r * Math.cos(ang), cy + r * Math.sin(ang)];
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var t = 0, lastInput = -9, pen = false, penPt = null;
var PH = [0, 2.09, 4.19]; // 붓 세 자루의 위상 (2π/3 간격)
var prev = [null, null, null];
function toLocal(e) {
var b = field.getBoundingClientRect();
return [e.clientX - b.left, e.clientY - b.top];
}
field.addEventListener('pointerdown', function (e) {
pen = true; penPt = toLocal(e);
if (field.setPointerCapture) { try { field.setPointerCapture(e.pointerId); } catch (err) { /* 캡처 실패는 그리기에 영향 없다 */ }
}
});
field.addEventListener('pointermove', function (e) {
if (!pen) return;
var p = toLocal(e);
mirroredSeg(penPt[0], penPt[1], p[0], p[1], strokes);
penPt = p; lastInput = t;
});
field.addEventListener('pointerup', function () { pen = false; lastInput = t; });
field.addEventListener('pointercancel', function () { pen = false; lastInput = t; });
var last = 0;
function step(now) {
var dt = Math.min(64, now - last) / 1000; last = now;
if (!pen && t - lastInput > 1.6) {
t += dt;
for (var i = 0; i < PH.length; i++) {
var p = pathAt(t, PH[i]);
if (prev[i]) mirroredSeg(prev[i][0], prev[i][1], p[0], p[1], Math.floor(t * 0.9 + i));
prev[i] = p;
}
}
requestAnimationFrame(step);
}
fit();
window.addEventListener('resize', fit);
if (reduce) {
// 정지 한 장 — 시간 4초 분의 붓질을 미리 그려 둔다. 루프는 돌지 않는다
for (var j = 1; j <= 200; j++) {
var tt = j * 0.02;
for (var i2 = 0; i2 < PH.length; i2++) {
var a0 = pathAt(tt - 0.02, PH[i2]), a1 = pathAt(tt, PH[i2]);
mirroredSeg(a0[0], a0[1], a1[0], a1[1], Math.floor(tt * 0.9 + i2));
}
}
root.classList.add('is-still');
} else {
last = performance.now();
requestAnimationFrame(step);
}
})();// KaleidoMirrorDrawCanvas — vanilla 판(run/331/demo/01-kaleido-mirror-draw-canvas/index.html) 의 React 포트.
// 반사 행렬이 핵심이다. k번째 부채꼴의 변환은 회전 R(a) 와 y축 반사의 곱
// [[cos a, sin a], [sin a, -cos a]] (a = k · 2π/n). 선분 하나를 이 행렬로 n번 찍으면
// 좌우 반사까지 합쳐 2n 갈래의 대칭 무늬가 그린 순간에 생긴다. 경로는 파라미터 함수라
// 시드 난수 없이도 항상 같은 데모가 반복된다. 값·수치는 vanilla 와 같다.
// 움직임은 전부 캔버스 rAF 루프가 그리므로 CSS 애니메이션이 없다.
// reduce 에서는 루프를 돌리지 않고 시간 4초 분의 붓질을 미리 그린 한 장만 남긴다(.isStill).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./KaleidoMirrorDrawCanvas.module.scss";
export type KaleidoMirrorDrawCanvasProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
const N = 8; // 차수 n — 반사까지 합치면 2N = 16 갈래
const PH = [0, 2.09, 4.19]; // 붓 세 자루의 위상 (2π/3 간격)
const COLS = ["#a6b4d3", "#d3a6c8", "#a6d3b1"]; // 파랑빛·연분홍·연두 파스텔 — 붓 셋
const HINT = "선 하나를 그리면 반사 행렬이 나머지 칸을 전부 채웁니다";
const ARIA_FIELD = "만화경 붓 실험판";
const PARTS_UNIT = "반사 갈래 2n";
const ORDER_TEXT = "차수 n = 8";
const STROKE_UNIT = "붓질";
const DRAW_TEXT = "마우스나 손가락으로 직접 그려 보세요";
export default function KaleidoMirrorDrawCanvas({
duration = "2s",
easing = "ease-out",
color = "#6ec8ff"
}: KaleidoMirrorDrawCanvasProps) {
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const cntRef = useRef<HTMLSpanElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const stRef = useRef({
W: 0, H: 0, dpr: 1, cx: 0, cy: 0, R: 0,
t: 0, strokes: 0, lastInput: -9,
pen: false, penPt: null as [number, number] | null,
prev: [null, null, null] as Array<[number, number] | null>
}) as Box<{
W: number; H: number; dpr: number; cx: number; cy: number; R: number;
strokes: number; t: number; lastInput: number;
pen: boolean; penPt: [number, number] | null;
prev: Array<[number, number] | null>
}>;
const lastRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function fit(): void {
const st = stRef.current;
const field = fieldRef.current, cv = cvRef.current;
if (!field || !cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
st.W = field.clientWidth; st.H = field.clientHeight;
st.dpr = Math.min(2, window.devicePixelRatio || 1);
cv.width = Math.round(st.W * st.dpr); cv.height = Math.round(st.H * st.dpr);
st.cx = st.W / 2; st.cy = st.H / 2; st.R = Math.min(st.W, st.H) / 2 - 6;
ctxRef.current = ctx;
}
// 반사 행렬 스트로크 — 선분 하나를 2N 위치에 찍는다. setTransform 의 여섯 인수는
// (a, b, c, d, e, f) = 행렬 2×2 + 이동. 여기 곱한 행렬이 곧 회전×반사다
function mirroredSeg(x0: number, y0: number, x1: number, y1: number, ci: number): void {
const st = stRef.current;
const ctx = ctxRef.current;
if (!ctx) return;
for (let k = 0; k < N; k++) {
const a = k * 2 * Math.PI / N, c = Math.cos(a), s = Math.sin(a);
ctx.setTransform(st.dpr * c, st.dpr * s, st.dpr * s, -st.dpr * c, st.cx * st.dpr, st.cy * st.dpr);
ctx.strokeStyle = COLS[ci % 3];
ctx.lineWidth = 6;
ctx.lineCap = "round";
ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.stroke();
}
ctx.setTransform(st.dpr, 0, 0, st.dpr, 0, 0);
st.strokes += 1;
const out = cntRef.current;
if (out) out.textContent = String(st.strokes);
}
// 시드 고정 자동 붓질 — 붓 세 자루가 위상을 달리해 함께 돈다
function pathAt(t: number, ph: number): [number, number] {
const st = stRef.current;
const ang = t * 1.55 + ph + 0.9 * Math.sin(t * 0.31 + ph);
let r = st.R * (0.30 + 0.34 * Math.sin(t * 0.47 + ph * 2) + 0.20 * Math.sin(t * 0.13 + ph));
if (r < st.R * 0.07) r = st.R * 0.07;
return [st.cx + r * Math.cos(ang), st.cy + r * Math.sin(ang)];
}
function step(now: number): void {
const st = stRef.current;
const dt = Math.min(64, now - lastRef.current) / 1000; lastRef.current = now;
if (!st.pen && st.t - st.lastInput > 1.6) {
st.t += dt;
for (let i = 0; i < PH.length; i++) {
const p = pathAt(st.t, PH[i]);
const pv = st.prev[i];
if (pv) mirroredSeg(pv[0], pv[1], p[0], p[1], Math.floor(st.t * 0.9 + i));
st.prev[i] = p;
}
}
rafRef.current = requestAnimationFrame(step);
}
function toLocal(e: PointerEvent<HTMLDivElement>): [number, number] {
const b = e.currentTarget.getBoundingClientRect();
return [e.clientX - b.left, e.clientY - b.top];
}
function onPointerDown(e: PointerEvent<HTMLDivElement>): void {
const st = stRef.current;
st.pen = true; st.penPt = toLocal(e);
try { e.currentTarget.setPointerCapture(e.pointerId); } catch { /* 캡처 실패는 그리기에 영향 없다 */ }
}
function onPointerMove(e: PointerEvent<HTMLDivElement>): void {
const st = stRef.current;
if (!st.pen || !st.penPt) return;
const p = toLocal(e);
mirroredSeg(st.penPt[0], st.penPt[1], p[0], p[1], st.strokes);
st.penPt = p; st.lastInput = st.t;
}
function onPointerUp(): void {
const st = stRef.current;
st.pen = false; st.lastInput = st.t;
}
useEffect(() => {
fit();
function onResize(): void { fit(); }
window.addEventListener("resize", onResize);
if (reduced()) {
// 정지 한 장 — 시간 4초 분의 붓질을 미리 그려 둔다. 루프는 돌지 않는다
for (let j = 1; j <= 200; j++) {
const tt = j * 0.02;
for (let i = 0; i < PH.length; i++) {
const a0 = pathAt(tt - 0.02, PH[i]), a1 = pathAt(tt, PH[i]);
mirroredSeg(a0[0], a0[1], a1[0], a1[1], Math.floor(tt * 0.9 + i));
}
}
setStill(true);
} else {
lastRef.current = performance.now();
rafRef.current = requestAnimationFrame(step);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const rootCls = still ? styles.kb + " " + styles.isStill : styles.kb;
return (
<main className={styles.stage}>
<div className={rootCls} style={{ "--duration": duration, "--easing": easing, "--color": color } as Vars}>
<p className={styles.hint}>{HINT}</p>
<div className={styles.row}>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={ARIA_FIELD}
tabIndex={0}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.side}>
<b className={styles.num}>{2 * N}</b>
<span className={styles.unit}>{PARTS_UNIT}</span>
<p className={styles.meta}>{ORDER_TEXT}</p>
<p className={styles.meta}><b ref={cntRef}>0</b><span className={styles.metaUnit}>{STROKE_UNIT}</span></p>
<p className={styles.meta}>{DRAW_TEXT}</p>
</div>
</div>
</div>
</main>
);
}01 The base kaleidoscope brush — one stroke reflected 2n times — Measured area 5.295% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Kaleidoscope Drawing: 9 Canvas Symmetry Tools
The post this demo lives in
Kaleidoscope Drawing: 9 Canvas Symmetry ToolsDraw one stroke and reflection matrices fill every remaining wedge. Mirror angle, symmetry order and hue cycling are you 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 UI01 The basic strike — one layer of midpoint displacement halving a segment9 Lightning Effect Background Scenes, No WebGL
Creative UI06 Tidal tail close-up — the fling seen through one star's trailN Body Simulation, 9 Ways (Galaxy, No Library)