06Galaxy swirl
Six hundred stars get a radius and a starting angle once, and each position comes from multiplying in an angular speed that rises toward the center. Nothing stores velocity or lifetime, so the spiral still holds its shape minutes later.
Best forCalm background for a dark-mode dashboard
globalCompositeOperationdestination-outMath.cos

<main class="stage">
<div class="gs" id="gs">
<p class="gs__hint">
<svg class="gs__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M245.11 60.68c-7.65-13.19-27.84-16.16-58.5-8.66A95.93 95.93 0 0 0 32 128a98 98 0 0 0 .78 12.31C5.09 169 5.49 186 10.9 195.32C16 204.16 26.64 208 40.64 208a124 124 0 0 0 28.79-4A95.93 95.93 0 0 0 224 128a97 97 0 0 0-.77-12.25c12.5-13 20.82-25.35 23.65-35.92c1.95-7.32 1.36-13.76-1.77-19.15M128 48a80.11 80.11 0 0 1 78 62.2c-17.06 16.06-40.15 32.53-62.07 45.13c-27.55 15.81-51.45 25.67-70.51 31.07A79.94 79.94 0 0 1 128 48M24.74 187.29c-1.46-2.51-.65-7.24 2.22-13a79 79 0 0 1 10.29-15.05a96 96 0 0 0 18 31.32c-17.25 2.9-28.01 1.05-30.51-3.27M128 208a79.45 79.45 0 0 1-38.56-9.94a370 370 0 0 0 62.43-28.86c21.58-12.39 40.68-25.82 56.07-39.08A80.07 80.07 0 0 1 128 208M231.42 75.69c-1.7 6.31-6.19 13.53-12.63 21.13a95.7 95.7 0 0 0-18-31.35c14.21-2.35 27.37-2.17 30.5 3.24c.9 1.57.95 3.92.13 6.98"/></g></svg>
<span>Hover and that spot becomes the density hub for a moment</span>
</p>
<div class="gs__field" id="field" role="application" aria-label="Galaxy test board" tabindex="0">
<canvas class="gs__cv" id="cv"></canvas>
<b class="gs__badge" aria-hidden="true"><span class="gs__badgeNum">600</span><span class="gs__badgeUnit">stars</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 은하 회오리 — 움직임은 전부 캔버스 rAF 루프 안에 있다. CSS 는 무대·판만 맡는다.
// @keyframes 를 두지 않는다 — CSS 애니메이션이 0개라야 렌더러가 실시간으로 24장을 찍는다(01 항목과 같은 방식).
// 별 빛깔은 반지름에 따라 파랑(바깥) 에서 주황(허브) 으로 — 색 닻 다섯을 커스텀 프로퍼티로 내려보낸다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.gs {
// 허브 빛 — 중심의 따뜻한 코어. 낮은 알파로 매 프레임 얹어 equilibrium 이 맞는다
--hub-0: #{rgba($stage-yellow, .9)};
--hub-1: #{rgba($stage-orange, 0)};
// 반지름 순서대로: 허브(주황) 따뜻한 노랑 크림 시원한 파랑 가장자리 파랑
--tone-hub: #{$stage-orange};
--tone-warm: #{$stage-yellow};
--tone-mid: #{$subject-cream};
--tone-cool: #{$subject-blue-light};
--tone-edge: #{mix($stage-ink, $subject-blue, 30%)};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.gs__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: $subject-cream;
}
.gs__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $stage-yellow; }
.gs__field {
position: relative; height: 208px;
border-radius: $r-card; overflow: hidden;
background: $stage-ink;
box-shadow: inset 0 0 0 1px rgba($subject-cream, .14);
cursor: crosshair; touch-action: none;
}
.gs__field:focus-visible { outline: 2px solid $stage-yellow; outline-offset: 2px; }
.gs__cv { display: block; width: 100%; height: 100%; }
// 코드의 COUNT 상수를 화면에도 적어 둔다 — 별 육백 개
.gs__badge {
position: absolute; right: $sp-2; top: $sp-2;
display: flex; align-items: baseline; gap: $sp-1;
padding: $sp-1 $sp-2; border-radius: $r-pill;
background: rgba($subject-ink, .78);
color: $subject-cream; font-weight: 700; font-size: 11px;
}
.gs__badgeNum { font-size: 13px; color: $stage-yellow; }
.gs__badgeUnit { font-weight: 700; }
@media (max-width: 340px) {
.gs { gap: $sp-1; }
.gs__field { height: 124px; }
.gs__badge { font-size: 10px; padding: 0 $sp-1; }
.gs__badgeNum { font-size: 11px; }
.gs__hint { font-size: 10px; }
}
// reduce — 루프가 아예 안 돈다. 나선이 감긴 정적 한 장이 남으므로 커서 신호만 거둔다.
@media (prefers-reduced-motion: reduce) {
.gs.is-still .gs__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);
}
.gs {
--hub-0: rgba(255, 210, 63, 0.9);
--hub-1: rgba(255, 77, 31, 0);
--tone-hub: #ff4d1f;
--tone-warm: #ffd23f;
--tone-mid: #fff7e6;
--tone-cool: #5b8cf8;
--tone-edge: rgb(15.6078431373%, 32.2745098039%, 70.5882352941%);
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.gs__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: #fff7e6;
}
.gs__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #ffd23f;
}
.gs__field {
position: relative;
height: 208px;
border-radius: 16px;
overflow: hidden;
background: #17141a;
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.14);
cursor: crosshair;
touch-action: none;
}
.gs__field:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.gs__cv {
display: block;
width: 100%;
height: 100%;
}
.gs__badge {
position: absolute;
right: 8px;
top: 8px;
display: flex;
align-items: baseline;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.78);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
.gs__badgeNum {
font-size: 13px;
color: #ffd23f;
}
.gs__badgeUnit {
font-weight: 700;
}
@media (max-width: 340px) {
.gs {
gap: 4px;
}
.gs__field {
height: 124px;
}
.gs__badge {
font-size: 10px;
padding: 0 4px;
}
.gs__badgeNum {
font-size: 11px;
}
.gs__hint {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.gs.is-still .gs__field {
cursor: default !important;
}
}// 은하 회오리 — stars 육백 개의 반지름 r 과 시작각 a0 은 초기화 때 한 번만 정한다.
// 매 프레임 위치는 식으로 계산한다: a = a0 + t * K / (r^0.8 + 20).
// 안쪽일수록 분모가 작아 각속도가 커진다(시더) — 속도나 수명을 저장하는 배열이 없어도
// 같은 시각 t 에서는 언제나 같은 그림이 나오고, 차등 회전이 쌓여 나선팔이 저절로 생긴다.
// 꼬리는 프레임을 완전히 지우지 않아서 생긴다 — destination-out 합성으로 낮은 알파를 발라
// 옛별이 서서히 지워지게 하면 별이 지나온 궤적이 남는다.
var root = document.getElementById('gs');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d');
var css = getComputedStyle(root);
function v(name) { return css.getPropertyValue(name).trim(); }
var HUB0 = v('--hub-0'), HUB1 = v('--hub-1'); // 허브 빛(중심의 따뜻한 코어)
var EDGE = [v('--tone-hub'), v('--tone-warm'), v('--tone-mid'), v('--tone-cool'), v('--tone-edge')];
var COUNT = 600;
var K = 13; // 각속도 계수 — r 이 0 에 가까우면 약 0.65rad/s, 바깥은 약 0.17rad/s
var FLAT = 0.58; // 세로 눌림 — 납작한 회전 은하로 읽히는 값
var TILT = -0.35; // 전체 기울기(라디안)
var cosT = Math.cos(TILT), sinT = Math.sin(TILT);
var FADE = 0.16; // 꼬리 길이 — 지우는 알파가 낮을수록 궤적이 길게 남는다
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1, raf = 0;
var cx = 0, cy = 0, rmax = 0;
var px = 0, py = 0, inf = 0, aimInf = 0; // 포인터 항 — (px, py) 를 향해 전체가 살짝 기운다
// stars — 반지름·시작각·크기·빛깔만 받는다. 속도·수명 배열은 없다(식이 전부 계산한다)
var stars = [];
for (var i = 0; i < COUNT; i++) {
var rr = Math.pow(Math.random(), 1.25); // 안쪽 밀도를 살짝 높인다(지수 1 이상이 중심으로 몬다)
stars.push({
r: rr,
a0: Math.random() * Math.PI * 2,
s: 0.7 + Math.random() * 1.5 + (Math.random() < 0.03 ? 0.9 : 0),
tone: EDGE[Math.min(EDGE.length - 1, Math.round(rr * (EDGE.length - 1)))],
tw: i % 9 === 0 ? Math.random() * Math.PI * 2 : -1 // 아홉 개 중 하나는 반짝인다
});
}
function fit() {
var r = field.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width; H = r.height;
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
cx = W * 0.5; cy = H * 0.52;
rmax = Math.min(W * 0.47, H * 0.82);
if (reduce) draw(42);
}
function draw(t) {
// 꼬리 — 완전히 지우지 않고 낮은 알파로 바른다
ctx.globalCompositeOperation = 'destination-out';
ctx.fillStyle = 'rgba(0, 0, 0, ' + FADE + ')';
ctx.fillRect(0, 0, W, H);
// 별과 허브 빛은 겹치면 밝아지는 lighter 로 얹는다
ctx.globalCompositeOperation = 'lighter';
var hub = ctx.createRadialGradient(cx, cy, 0, cx, cy, rmax * 0.34);
hub.addColorStop(0, HUB0);
hub.addColorStop(1, HUB1);
ctx.globalAlpha = 0.08;
ctx.fillStyle = hub;
ctx.beginPath();
ctx.arc(cx, cy, rmax * 0.34, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
for (var i = 0; i < COUNT; i++) {
var st = stars[i];
var R = st.r * rmax + 8; // 아주 중심으로 모두 몰리지 않게 바닥 반지름 8
var a = st.a0 + t * K / (Math.pow(R, 0.8) + 20);
var ex = Math.cos(a) * R;
var ey = Math.sin(a) * R * FLAT;
// 계산식의 포인터 항 — (px, py) 쪽으로 좌표 전체가 inf 만큼 기운다(밀도 중심이 잠깐 그 자리로)
var x = cx + ex * cosT - ey * sinT + (px - cx) * inf;
var y = cy + ex * sinT + ey * cosT + (py - cy) * inf;
ctx.globalAlpha = st.tw >= 0 ? 0.45 + 0.55 * (0.5 + 0.5 * Math.sin(t * 2.2 + st.tw)) : 0.9;
ctx.fillStyle = st.tone;
ctx.beginPath();
ctx.arc(x, y, st.s, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
}
function frame(now) {
inf += (aimInf - inf) * 0.08;
draw(now / 1000);
raf = requestAnimationFrame(frame);
}
field.addEventListener('pointermove', function (e) {
var r = field.getBoundingClientRect();
px = Math.max(0, Math.min(W, e.clientX - r.left));
py = Math.max(0, Math.min(H, e.clientY - r.top));
aimInf = 0.32;
if (reduce) { inf = aimInf; draw(42); }
});
field.addEventListener('pointerleave', function () { aimInf = 0; });
field.addEventListener('keydown', function (e) {
var d = { ArrowLeft: [-16, 0], ArrowRight: [16, 0], ArrowUp: [0, -16], ArrowDown: [0, 16] }[e.key];
if (!d) { if (e.key === 'Escape') aimInf = 0; return; }
e.preventDefault();
aimInf = 0.32;
px = Math.max(0, Math.min(W, px + d[0]));
py = Math.max(0, Math.min(H, py + d[1]));
if (reduce) { inf = aimInf; draw(42); }
});
window.addEventListener('resize', function () { fit(); });
fit();
if (reduce) {
// reduce — 루프를 시작하지 않고 나선이 감긴 한 장(t = 42 초)만 그린다(빈 화면이 아니다)
root.classList.add('is-still');
draw(42);
} else {
px = W * 0.62; py = H * 0.4;
raf = requestAnimationFrame(frame);
}// GalaxySwirl — vanilla 판(run/077/demo/06-fx-galaxy-swirl/index.html) 의 React 포트.
// 별 육백 개의 반지름과 시작각만 초기화 때 정하고 매 프레임 위치는 식으로 계산한다:
// a = a0 + t * K / (r^0.8 + 20). 꼬리는 destination-out 낮은 알파 지우기로 남긴다.
// 움직임은 캔버스 rAF 루프가 전부 그리므로 @keyframes 가 없다 — props 는 CSS 변수로 내려간다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./GalaxySwirl.module.scss";
export type GalaxySwirlProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
// React 이벤트 제네릭이 없는 환경이라 필요한 것만 로컬로 선언한다
type KeyEvt = { key: string; preventDefault(): void };
type PtrEvt = { clientX: number; clientY: number };
type Star = { r: number; a0: number; s: number; tone: string; tw: number };
const COUNT = 600;
const K = 13; // 각속도 계수 — 안쪽은 약 0.65rad/s, 바깥은 약 0.17rad/s
const FLAT = 0.58; // 세로 눌림 — 납작한 회전 은하로 읽히는 값
const TILT = -0.35; // 전체 기울기(라디안)
const COS_T = Math.cos(TILT), SIN_T = Math.sin(TILT);
const FADE = 0.16; // 꼬리 길이 — 지우는 알파가 낮을수록 궤적이 길게 남는다
const STILL_T = 42; // reduce 정지판의 시각(초) — 나선이 감긴 뒤의 모습
const FIELD_LABEL = "은하 실험판";
export default function GalaxySwirl({
duration = "2s", // vanilla $duration: $dur-loop
easing = "cubic-bezier(.2, .8, .2, 1)", // vanilla $easing: $ease-spring
color = "#2f6df6", // vanilla $color: $subject-blue
}: GalaxySwirlProps) {
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 sizeRef = useRef({ w: 0, h: 0, dpr: 1, cx: 0, cy: 0, rmax: 0 }) as Box<{ w: number; h: number; dpr: number; cx: number; cy: number; rmax: number }>;
const starsRef = useRef([]) as Box<Star[]>;
const aimRef = useRef({ px: 0, py: 0, inf: 0, aimInf: 0 }) as Box<{ px: number; py: number; inf: number; aimInf: number }>;
const rafRef = useRef(0) as Box<number>;
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const s = sizeRef.current;
s.w = r.width; s.h = r.height; s.dpr = dpr;
cv.width = Math.round(r.width * dpr);
cv.height = Math.round(r.height * dpr);
ctxRef.current!.setTransform(dpr, 0, 0, dpr, 0, 0);
s.cx = r.width * 0.5;
s.cy = r.height * 0.52;
s.rmax = Math.min(r.width * 0.47, r.height * 0.82);
if (reduced()) draw(STILL_T);
}
function draw(t: number): void {
const ctx = ctxRef.current;
const root = rootRef.current;
const stars = starsRef.current;
if (!ctx || !root) return;
const cssv = (n: string) => getComputedStyle(root).getPropertyValue(n).trim();
const s = sizeRef.current;
const a = aimRef.current;
// 꼬리 — 완전히 지우지 않고 낮은 알파로 바른다
ctx.globalCompositeOperation = "destination-out";
ctx.fillStyle = "rgba(0, 0, 0, " + FADE + ")";
ctx.fillRect(0, 0, s.w, s.h);
// 별과 허브 빛은 겹치면 밝아지는 lighter 로 얹는다
ctx.globalCompositeOperation = "lighter";
const hub = ctx.createRadialGradient(s.cx, s.cy, 0, s.cx, s.cy, s.rmax * 0.34);
hub.addColorStop(0, cssv("--hub-0"));
hub.addColorStop(1, cssv("--hub-1"));
ctx.globalAlpha = 0.08;
ctx.fillStyle = hub;
ctx.beginPath();
ctx.arc(s.cx, s.cy, s.rmax * 0.34, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
for (let i = 0; i < stars.length; i++) {
const st = stars[i];
const R = st.r * s.rmax + 8; // 중심으로 모두 몰리지 않게 바닥 반지름 8
const ang = st.a0 + t * K / (Math.pow(R, 0.8) + 20);
const ex = Math.cos(ang) * R;
const ey = Math.sin(ang) * R * FLAT;
// 계산식의 포인터 항 — (px, py) 쪽으로 좌표 전체가 inf 만큼 기운다(밀도 중심이 잠깐 그 자리로)
const x = s.cx + ex * COS_T - ey * SIN_T + (a.px - s.cx) * a.inf;
const y = s.cy + ex * SIN_T + ey * COS_T + (a.py - s.cy) * a.inf;
ctx.globalAlpha = st.tw >= 0 ? 0.45 + 0.55 * (0.5 + 0.5 * Math.sin(t * 2.2 + st.tw)) : 0.9;
ctx.fillStyle = st.tone;
ctx.beginPath();
ctx.arc(x, y, st.s, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = "source-over";
}
function frame(now: number): void {
const a = aimRef.current;
a.inf += (a.aimInf - a.inf) * 0.08;
draw(now / 1000);
rafRef.current = requestAnimationFrame(frame);
}
function onMove(e: PtrEvt): void {
const field = fieldRef.current;
const s = sizeRef.current;
const a = aimRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
a.px = Math.max(0, Math.min(s.w, e.clientX - r.left));
a.py = Math.max(0, Math.min(s.h, e.clientY - r.top));
a.aimInf = 0.32;
if (reduced()) { a.inf = a.aimInf; draw(STILL_T); }
}
function onLeave(): void {
aimRef.current.aimInf = 0;
}
function onKey(e: KeyEvt): void {
const s = sizeRef.current;
const a = aimRef.current;
const map: Record<string, number[]> = {
ArrowLeft: [-16, 0], ArrowRight: [16, 0], ArrowUp: [0, -16], ArrowDown: [0, 16],
};
const d = map[e.key];
if (!d) { if (e.key === "Escape") a.aimInf = 0; return; }
e.preventDefault();
a.aimInf = 0.32;
a.px = Math.max(0, Math.min(s.w, a.px + d[0]));
a.py = Math.max(0, Math.min(s.h, a.py + d[1]));
if (reduced()) { a.inf = a.aimInf; draw(STILL_T); }
}
function onResize(): void {
fit();
}
useEffect(() => {
const cv = cvRef.current;
const root = rootRef.current;
if (!cv || !root) return;
ctxRef.current = cv.getContext("2d");
if (!ctxRef.current) return;
// 별 — 반지름·시작각·크기·빛깔만 받는다. 속도·수명 배열은 없다(식이 전부 계산한다)
const cssv = (n: string) => getComputedStyle(root).getPropertyValue(n).trim();
const tones = ["--tone-hub", "--tone-warm", "--tone-mid", "--tone-cool", "--tone-edge"].map(cssv);
const stars: Star[] = [];
for (let i = 0; i < COUNT; i++) {
const rr = Math.pow(Math.random(), 1.25); // 지수 1 이상이 중심으로 모는 쪽이다
stars.push({
r: rr,
a0: Math.random() * Math.PI * 2,
s: 0.7 + Math.random() * 1.5 + (Math.random() < 0.03 ? 0.9 : 0),
tone: tones[Math.min(tones.length - 1, Math.round(rr * (tones.length - 1)))],
tw: i % 9 === 0 ? Math.random() * Math.PI * 2 : -1, // 아홉 개 중 하나는 반짝인다
});
}
starsRef.current = stars;
fit();
window.addEventListener("resize", onResize);
if (reduced()) {
// reduce — 루프를 시작하지 않고 나선이 감긴 한 장만 그린다
root.classList.add("is-still");
draw(STILL_T);
} else {
const a = aimRef.current;
a.px = sizeRef.current.w * 0.62;
a.py = sizeRef.current.h * 0.4;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.root} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M245.11 60.68c-7.65-13.19-27.84-16.16-58.5-8.66A95.93 95.93 0 0 0 32 128a98 98 0 0 0 .78 12.31C5.09 169 5.49 186 10.9 195.32C16 204.16 26.64 208 40.64 208a124 124 0 0 0 28.79-4A95.93 95.93 0 0 0 224 128a97 97 0 0 0-.77-12.25c12.5-13 20.82-25.35 23.65-35.92c1.95-7.32 1.36-13.76-1.77-19.15M128 48a80.11 80.11 0 0 1 78 62.2c-17.06 16.06-40.15 32.53-62.07 45.13c-27.55 15.81-51.45 25.67-70.51 31.07A79.94 79.94 0 0 1 128 48M24.74 187.29c-1.46-2.51-.65-7.24 2.22-13a79 79 0 0 1 10.29-15.05a96 96 0 0 0 18 31.32c-17.25 2.9-28.01 1.05-30.51-3.27M128 208a79.45 79.45 0 0 1-38.56-9.94a370 370 0 0 0 62.43-28.86c21.58-12.39 40.68-25.82 56.07-39.08A80.07 80.07 0 0 1 128 208M231.42 75.69c-1.7 6.31-6.19 13.53-12.63 21.13a95.7 95.7 0 0 0-18-31.35c14.21-2.35 27.37-2.17 30.5 3.24c.9 1.57.95 3.92.13 6.98"/></g></svg>
<span>포인터를 올리면 그 자리가 잠깐 밀도 중심이 됩니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onPointerMove={onMove}
onPointerLeave={onLeave}
onKeyDown={onKey}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
<b className={styles.badge} aria-hidden="true"><span className={styles.badgeNum}>600</span><span className={styles.badgeUnit}>별</span></b>
</div>
</div>
</main>
);
}06 Galaxy swirl — Measured area 30.838% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · HTML5 Canvas Animation: 9 Warp & Droplet Effects
The post this demo lives in
HTML5 Canvas Animation: 9 Warp & Droplet EffectsHTML5 canvas animation bends pixels — a droplet swelling the text under it, a black hole pulling the screen in — no reto See all demos in this post


