04Polar tile stamp — stamps snapping to a radial grid
Converting the cursor to radius and angle lets stamps snap to the nearest grid node — instant rangoli and dot mandala. Stamp shapes are defined as circle, line and dot arrays.
Best forPattern design, decorative elements
arclineTo

<main class="stage">
<div class="kp" id="kp">
<p class="kp__hint">Your cursor snaps to ring-spoke grid points and stamps there</p>
<div class="kp__row">
<div class="kp__field" id="field" role="application" aria-label="Polar stamp lab" tabindex="0">
<canvas class="kp__cv" id="cv"></canvas>
</div>
<div class="kp__side">
<label class="kp__lab" for="stamp">Stamp shape</label>
<select class="kp__sel" id="stamp" aria-label="Stamp shape">
<option value="0">Five petals</option>
<option value="1">Eight-point star</option>
<option value="2">Three dots</option>
</select>
<b class="kp__num" id="marks">0</b>
<span class="kp__unit">stamps placed</span>
<p class="kp__meta">Click or tap any grid point to stamp it</p>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 극좌표 도장(04) — rAF 캔버스 데모. 커서를 (링, 스포크) 격자점에 스냅해 도장을 찍는다.
// 도장 빛깔은 종이 무대 위 짙은 파스텔 세 가지 — 링과 스포크 번갈아 순환.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-paper);
.kp {
--c1: #4d949d; // 청록 파스텔 짙음 — 도장 첫째
--c2: #9d4d54; // 붉은기 파스텔 짙음 — 도장 둘째
--c3: #854d9d; // 보라 파스텔 짙음 — 도장 셋째
--grid: #{rgba($subject-ink, .12)};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 420px;
}
.kp__hint {
margin: 0; font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-ink;
}
.kp__row { display: flex; gap: $sp-2; align-items: stretch; }
.kp__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-ink, .05);
box-shadow: inset 0 0 0 1px rgba($subject-ink, .16);
cursor: crosshair; touch-action: none;
}
.kp__field:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.kp__cv { display: block; width: 100%; height: 100%; }
.kp__side {
flex: 1; min-width: 0;
display: flex; flex-direction: column; gap: $sp-1;
padding-top: $sp-1;
}
.kp__lab { font-weight: 600; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.4; color: rgba($subject-ink, .82); }
.kp__sel {
width: 100%; margin: 0; padding: $sp-1;
font-family: inherit; font-size: clamp(10px, 2.6vw, 12px); line-height: 1.4;
color: $subject-ink;
background: transparent;
border: 1px solid rgba($subject-ink, .32); border-radius: $r-control;
}
.kp__sel:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.kp__num {
font-weight: 800; font-size: clamp(20px, 6vw, 30px); line-height: 1;
color: $subject-ink; font-variant-numeric: tabular-nums;
}
.kp__unit { font-weight: 600; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.4; color: rgba($subject-ink, .82); }
.kp__meta { margin: $sp-1 0 0; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.5; color: rgba($subject-ink, .82); }
@media (max-width: 340px) {
.kp { gap: $sp-1; }
.kp__field { width: 150px; height: 150px; }
.kp__num { font-size: 16px; }
.kp__meta { margin-top: 0; font-size: 8px; }
}
// reduce — 루프가 안 돈다. 32칸 전부 찍힌 정적 한 장이 남는다.
@media (prefers-reduced-motion: reduce) {
.kp.is-still .kp__field { cursor: default !important; }
.kp.is-still .kp__sel { pointer-events: none !important; }
}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);
}
.kp {
--c1: #4d949d;
--c2: #9d4d54;
--c3: #854d9d;
--grid: rgba(23, 20, 26, 0.12);
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 420px;
}
.kp__hint {
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #17141a;
}
.kp__row {
display: flex;
gap: 8px;
align-items: stretch;
}
.kp__field {
position: relative;
overflow: hidden;
flex: 0 0 auto;
width: clamp(176px, 46vw, 232px);
height: clamp(176px, 46vw, 232px);
border-radius: 16px;
background: rgba(23, 20, 26, 0.05);
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.16);
cursor: crosshair;
touch-action: none;
}
.kp__field:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.kp__cv {
display: block;
width: 100%;
height: 100%;
}
.kp__side {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 4px;
}
.kp__lab {
font-weight: 600;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.4;
color: rgba(23, 20, 26, 0.82);
}
.kp__sel {
width: 100%;
margin: 0;
padding: 4px;
font-family: inherit;
font-size: clamp(10px, 2.6vw, 12px);
line-height: 1.4;
color: #17141a;
background: transparent;
border: 1px solid rgba(23, 20, 26, 0.32);
border-radius: 12px;
}
.kp__sel:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.kp__num {
font-weight: 800;
font-size: clamp(20px, 6vw, 30px);
line-height: 1;
color: #17141a;
font-variant-numeric: tabular-nums;
}
.kp__unit {
font-weight: 600;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.4;
color: rgba(23, 20, 26, 0.82);
}
.kp__meta {
margin: 4px 0 0;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.5;
color: rgba(23, 20, 26, 0.82);
}
@media (max-width: 340px) {
.kp {
gap: 4px;
}
.kp__field {
width: 150px;
height: 150px;
}
.kp__num {
font-size: 16px;
}
.kp__meta {
margin-top: 0;
font-size: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.kp.is-still .kp__field {
cursor: default !important;
}
.kp.is-still .kp__sel {
pointer-events: none !important;
}
}// 극좌표 도장 — 커서 위치를 (반경 링, 각도 스포크) 로 양자화한다. 극좌표 격자는
// 균일 격자와 달리 중심이 촘촘하고 바깥이 성기다 — 랑골·만다라 배치의 뼈대다.
// 도장 하나를 찍으면 k번째 스포크의 격자점을 반사 행렬로 N 갈래에 복제한다.
(function () {
var root = document.getElementById('kp');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var stamp = document.getElementById('stamp');
var marks = document.getElementById('marks');
if (!root || !field || !cv || !stamp || !marks) 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 GRID = css.getPropertyValue('--grid').trim();
var N = 12; // 스포크 수 — 격자이자 Symmetry order
var RINGS = 4; // 반경 링 수
var W = 0, H = 0, dpr = 1, cx = 0, cy = 0, R = 0;
var count = 0;
var shape = 0; // 0 꽃잎 · 1 별 · 2 Three dots
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 - 8;
drawGrid();
}
// 격자 — 링 원 N/2 개와 스포크 반직선 N 개. 찍힌 도장은 지우지 않는다
function drawGrid() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, W, H);
ctx.strokeStyle = GRID; ctx.lineWidth = 1;
for (var g = 1; g <= RINGS; g++) {
var r = R * g / RINGS;
ctx.beginPath(); ctx.arc(cx, cy, r, 0, 2 * Math.PI); ctx.stroke();
}
for (var k = 0; k < N; k++) {
var a = k * 2 * Math.PI / N;
ctx.beginPath(); ctx.moveTo(cx, cy);
ctx.lineTo(cx + R * Math.cos(a), cy + R * Math.sin(a)); ctx.stroke();
}
}
// Stamp shape — 셋 다 arc·lineTo 조합. 칩과 1:1
function drawStamp(x, y, s, ci, rot) {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.translate(x, y); ctx.rotate(rot);
ctx.fillStyle = COLS[ci % 3];
ctx.strokeStyle = COLS[ci % 3];
if (shape === 0) {
// Five petals — 원 5개가 한 점을 공유
for (var p = 0; p < 5; p++) {
var a = p * 2 * Math.PI / 5;
ctx.beginPath();
ctx.arc(s * 0.62 * Math.cos(a), s * 0.62 * Math.sin(a), s * 0.42, 0, 2 * Math.PI);
ctx.fill();
}
} else if (shape === 1) {
// Eight-point star — lineTo 로 뾰족별
ctx.beginPath();
for (var v = 0; v < 16; v++) {
var rad = (v % 2 === 0) ? s : s * 0.44;
var an = v * Math.PI / 8;
var px = rad * Math.cos(an), py = rad * Math.sin(an);
if (v === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
}
ctx.closePath(); ctx.fill();
} else {
// Three dots — arc 세 개를 lineTo 로 잇는 얼룩
ctx.beginPath();
ctx.arc(-s * 0.55, -s * 0.2, s * 0.34, 0, 2 * Math.PI); ctx.fill();
ctx.beginPath();
ctx.arc(s * 0.5, -s * 0.35, s * 0.28, 0, 2 * Math.PI); ctx.fill();
ctx.beginPath();
ctx.moveTo(-s * 0.3, s * 0.1); ctx.lineTo(s * 0.28, s * 0.05); ctx.lineTo(s * 0.1, s * 0.62); ctx.closePath(); ctx.fill();
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 스냅 — 극좌표를 링·스포크 번호로 반올림한 뒤 다시 좌표로
function snap(px, py) {
var dx = px - cx, dy = py - cy;
var r = Math.min(RINGS, Math.max(1, Math.round(Math.hypot(dx, dy) / (R / RINGS))));
var k = Math.round(Math.atan2(dy, dx) / (2 * Math.PI / N));
k = ((k % N) + N) % N;
var a = k * 2 * Math.PI / N;
return [cx + r * (R / RINGS) * Math.cos(a), cy + r * (R / RINGS) * Math.sin(a), r, k];
}
// 격자점 하나에 도장 — 스포크 전체에 반사 복제. 링은 이미 대칭이라 r 하나만
function stampAt(px, py, ci) {
var p = snap(px, py);
var aBase = p[3] * 2 * Math.PI / N;
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);
var lx = (p[0] - cx), ly = (p[1] - cy);
var mx = lx * c + ly * s, my = lx * s - ly * c;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
drawStamp(cx + mx, cy + my, 9 + p[2] * 1.6, ci, aBase + k * 0.2);
}
count += 1; marks.textContent = String(count);
}
// 시드 고정 자동 시연 — 링·스포크·모양을 순회하며 도장 찍기
function autoStamp(i) {
var ring = (i % RINGS) + 1;
var k = Math.floor(i / RINGS) % N;
var a = k * 2 * Math.PI / N + (ring % 2) * (Math.PI / N);
var rr = R * ring / RINGS;
stampAt(cx + rr * Math.cos(a), cy + rr * Math.sin(a), Math.floor(i / 3));
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var t = 0, last = 0, fired = 0;
stamp.addEventListener('change', function () { shape = parseInt(stamp.value, 10) || 0; });
function toLocal(e) {
var b = field.getBoundingClientRect();
return [e.clientX - b.left, e.clientY - b.top];
}
field.addEventListener('pointerdown', function (e) {
var p = toLocal(e);
stampAt(p[0], p[1], count);
});
function step(now) {
var dt = Math.min(64, now - last) / 1000; last = now;
t += dt;
var want = Math.floor(t / 0.12); // 0.12초마다 도장 한 개
while (fired < want) { autoStamp(fired); fired += 1; }
if (fired >= RINGS * N * 3) { // 3바퀴 그리면 새로 시작
t = 0; fired = 0; count = 0; drawGrid();
}
requestAnimationFrame(step);
}
fit();
window.addEventListener('resize', fit);
if (reduce) {
// 정지 한 장 — 32개 격자점 전부 찍힌 완성 무늬
for (var i = 0; i < RINGS * N; i++) autoStamp(i);
root.classList.add('is-still');
} else {
last = performance.now();
requestAnimationFrame(step);
}
})();// KaleidoPolarTileStamp — vanilla 판(run/331/demo/04-kaleido-polar-tile-stamp/index.html) 의 React 포트.
// 커서 위치를 (반경 링, 각도 스포크) 로 양자화한다. 극좌표 격자는 균일 격자와 달리
// 중심이 촘촘하고 바깥이 성기다 — 랑골·만다라 배치의 뼈대다. 도장 하나를 찍으면
// k번째 스포크의 격자점을 반사 행렬로 N 갈래에 복제한다. 도장 간격 0.12초 —
// 시간 표기와 소스 상수가 같다. 값·수치는 vanilla 와 같다.
// 움직임은 전부 캔버스 rAF 루프가 그리므로 CSS 애니메이션이 없다.
// reduce 에서는 루프를 돌리지 않고 32개 격자점 전부 찍힌 한 장만 남긴다(.isStill).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./KaleidoPolarTileStamp.module.scss";
export type KaleidoPolarTileStampProps = {
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 = 12; // 스포크 수 — 격자이자 대칭 차수
const RINGS = 4; // 반경 링 수
const COLS = ["#4d949d", "#9d4d54", "#854d9d"]; // 청록·붉은기·보라 파스텔 짙음 — 도장 셋
const GRID = "rgba(23, 20, 26, 0.12)"; // 격자선
const HINT = "커서를 링과 스포크가 만나는 격자점으로 바꿔 스냅 도장을 찍습니다";
const ARIA_FIELD = "극좌표 도장 실험판";
const STAMP_LABEL = "도장 모양";
const SHAPES = ["꽃잎 다섯", "별 여덟", "점 세 개"];
const STAMP_ARIA = "도장 모양";
const MARKS_UNIT = "찍힌 도장";
const DRAW_TEXT = "클릭·탭으로 원하는 격자점에 찍어 보세요";
export default function KaleidoPolarTileStamp({
duration = "2s",
easing = "ease-out",
color = "#17141a"
}: KaleidoPolarTileStampProps) {
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const selRef = useRef<HTMLSelectElement | null>(null);
const marksRef = 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,
count: 0, shape: 0, t: 0, fired: 0
}) as Box<{
W: number; H: number; dpr: number; cx: number; cy: number; R: number;
count: number; shape: number; t: number; fired: number;
}>;
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 - 8;
ctxRef.current = ctx;
drawGrid();
}
// 격자 — 링 원 N/2 개와 스포크 반직선 N 개. 찍힌 도장은 지우지 않는다
function drawGrid(): void {
const st = stRef.current;
const ctx = ctxRef.current;
if (!ctx) return;
ctx.setTransform(st.dpr, 0, 0, st.dpr, 0, 0);
ctx.clearRect(0, 0, st.W, st.H);
ctx.strokeStyle = GRID; ctx.lineWidth = 1;
for (let g = 1; g <= RINGS; g++) {
const r = st.R * g / RINGS;
ctx.beginPath(); ctx.arc(st.cx, st.cy, r, 0, 2 * Math.PI); ctx.stroke();
}
for (let k = 0; k < N; k++) {
const a = k * 2 * Math.PI / N;
ctx.beginPath(); ctx.moveTo(st.cx, st.cy);
ctx.lineTo(st.cx + st.R * Math.cos(a), st.cy + st.R * Math.sin(a)); ctx.stroke();
}
}
// 도장 모양 — 셋 다 arc·lineTo 조합. 칩과 1:1
function drawStamp(x: number, y: number, s: number, ci: number, rot: number): void {
const st = stRef.current;
const ctx = ctxRef.current;
if (!ctx) return;
ctx.setTransform(st.dpr, 0, 0, st.dpr, 0, 0);
ctx.translate(x, y); ctx.rotate(rot);
ctx.fillStyle = COLS[ci % 3];
ctx.strokeStyle = COLS[ci % 3];
if (st.shape === 0) {
// 꽃잎 다섯 — 원 5개가 한 점을 공유
for (let p = 0; p < 5; p++) {
const a = p * 2 * Math.PI / 5;
ctx.beginPath();
ctx.arc(s * 0.62 * Math.cos(a), s * 0.62 * Math.sin(a), s * 0.42, 0, 2 * Math.PI);
ctx.fill();
}
} else if (st.shape === 1) {
// 별 여덟 — lineTo 로 뾰족별
ctx.beginPath();
for (let v = 0; v < 16; v++) {
const rad = (v % 2 === 0) ? s : s * 0.44;
const an = v * Math.PI / 8;
const px = rad * Math.cos(an), py = rad * Math.sin(an);
if (v === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
}
ctx.closePath(); ctx.fill();
} else {
// 점 세 개 — arc 세 개를 lineTo 로 잇는 얼룩
ctx.beginPath();
ctx.arc(-s * 0.55, -s * 0.2, s * 0.34, 0, 2 * Math.PI); ctx.fill();
ctx.beginPath();
ctx.arc(s * 0.5, -s * 0.35, s * 0.28, 0, 2 * Math.PI); ctx.fill();
ctx.beginPath();
ctx.moveTo(-s * 0.3, s * 0.1); ctx.lineTo(s * 0.28, s * 0.05); ctx.lineTo(s * 0.1, s * 0.62); ctx.closePath(); ctx.fill();
}
ctx.setTransform(st.dpr, 0, 0, st.dpr, 0, 0);
}
// 스냅 — 극좌표를 링·스포크 번호로 반올림한 뒤 다시 좌표로
function snap(px: number, py: number): [number, number, number, number] {
const st = stRef.current;
const dx = px - st.cx, dy = py - st.cy;
const r = Math.min(RINGS, Math.max(1, Math.round(Math.hypot(dx, dy) / (st.R / RINGS))));
let k = Math.round(Math.atan2(dy, dx) / (2 * Math.PI / N));
k = ((k % N) + N) % N;
const a = k * 2 * Math.PI / N;
return [st.cx + r * (st.R / RINGS) * Math.cos(a), st.cy + r * (st.R / RINGS) * Math.sin(a), r, k];
}
// 격자점 하나에 도장 — 스포크 전체에 반사 복제. 링은 이미 대칭이라 r 하나만
function stampAt(px: number, py: number, ci: number): void {
const st = stRef.current;
const ctx = ctxRef.current;
if (!ctx) return;
const p = snap(px, py);
const aBase = p[3] * 2 * Math.PI / N;
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);
const lx = (p[0] - st.cx), ly = (p[1] - st.cy);
const mx = lx * c + ly * s, my = lx * s - ly * c;
ctx.setTransform(st.dpr, 0, 0, st.dpr, 0, 0);
drawStamp(st.cx + mx, st.cy + my, 9 + p[2] * 1.6, ci, aBase + k * 0.2);
}
st.count += 1;
const out = marksRef.current;
if (out) out.textContent = String(st.count);
}
// 시드 고정 자동 시연 — 링·스포크·모양을 순회하며 도장 찍기
function autoStamp(i: number): void {
const st = stRef.current;
const ring = (i % RINGS) + 1;
const k = Math.floor(i / RINGS) % N;
const a = k * 2 * Math.PI / N + (ring % 2) * (Math.PI / N);
const rr = st.R * ring / RINGS;
stampAt(st.cx + rr * Math.cos(a), st.cy + rr * Math.sin(a), Math.floor(i / 3));
}
function step(now: number): void {
const st = stRef.current;
const dt = Math.min(64, now - lastRef.current) / 1000; lastRef.current = now;
st.t += dt;
const want = Math.floor(st.t / 0.12); // 0.12초마다 도장 한 개
while (st.fired < want) { autoStamp(st.fired); st.fired += 1; }
if (st.fired >= RINGS * N * 3) { // 3바퀴 그리면 새로 시작
st.t = 0; st.fired = 0; st.count = 0; drawGrid();
}
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;
const p = toLocal(e);
stampAt(p[0], p[1], st.count);
}
function onSelectChange(): void {
const st = stRef.current;
const sel = selRef.current;
if (sel) st.shape = parseInt(sel.value, 10) || 0;
}
useEffect(() => {
fit();
function onResize(): void { fit(); }
window.addEventListener("resize", onResize);
if (reduced()) {
// 정지 한 장 — 32개 격자점 전부 찍힌 완성 무늬
for (let i = 0; i < RINGS * N; i++) autoStamp(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.kp + " " + styles.isStill : styles.kp;
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}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.side}>
<label className={styles.lab} htmlFor="stamp">{STAMP_LABEL}</label>
<select className={styles.sel} id="stamp" ref={selRef} aria-label={STAMP_ARIA} defaultValue="0" onChange={onSelectChange}>
<option value="0">{SHAPES[0]}</option>
<option value="1">{SHAPES[1]}</option>
<option value="2">{SHAPES[2]}</option>
</select>
<b className={styles.num}><span ref={marksRef}>0</span></b>
<span className={styles.unit}>{MARKS_UNIT}</span>
<p className={styles.meta}>{DRAW_TEXT}</p>
</div>
</div>
</div>
</main>
);
}04 Polar tile stamp — stamps snapping to a radial grid — Measured area 19.003% · moving frames 22/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 UI01 Basic force layout — repulsion and springs alone tidy scattered dotsForce Directed Graph: 9 Network Layout Demos
Creative UI02 Grid dot halftone — letters made of sample spacing9 Text Particle Effect CSS Ideas From Pixels
Creative UI08 Loom weave — grid patterns born where threads crossString Art: 9 Thread-Geometry Demos