06銀河の渦
六百個の星の半径と初期角度を一度だけ決め、内側ほど速い角速度を式で掛けて位置を求めます。速度も寿命も保存しないので、時間が経っても渦の形はそのまま残ります。
使いどころダークモードのダッシュボードの静かな背景
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>ポインタを乗せるとその場所がひととき密度の中心になります</span>
</p>
<div class="gs__field" id="field" role="application" aria-label="銀河の実験台" 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">星</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;
}
}// 은하 회오리 — 星 육백 개의 반지름 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) 를 향해 전체가 살짝 기운다
// 星 — 반지름·시작각·크기·빛깔만 받는다. 속도·수명 배열은 없다(식이 전부 계산한다)
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 銀河の渦 — 計測 累積面積 30.838% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · html5 canvas アニメーション9種 — 水滴とブラックホール
このデモが属する記事
html5 canvas アニメーション9種 — 水滴とブラックホールhtml5 canvas アニメーションとは、写真のレタッチなしで画素そのものを動かす特殊効果です。水滴が下の文字を膨らませ、ブラックホールが画面全体を飲み込む。この9種を Three.js なし、canvas ひとつで作りました。下の格子 この記事のデモをすべて見る


