05블랙홀 터널
직전 프레임을 중심 기준으로 살짝 키워 자기 위에 다시 그리는 되먹임 확대 하나로 끝없이 빨려드는 터널이 생깁니다. 가운데 검은 원과 그 둘레를 도는 링이 방향을 알려 줍니다.
쓰임페이지 전환 직전의 몰입 배경
drawImageglobalCompositeOperationcreateRadialGradient

<main class="stage">
<div class="bh" id="bh">
<p class="bh__hint">
<svg class="bh__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M232 128a104 104 0 0 1-208 0c0-41 23.81-78.36 60.66-95.27a8 8 0 0 1 6.68 14.54C60.15 61.59 40 93.27 40 128a88 88 0 0 0 176 0c0-34.73-20.15-66.41-51.34-80.73a8 8 0 0 1 6.68-14.54C208.19 49.64 232 87 232 128"/></g></svg>
<span>확대율을 낮추면 터널이 더 깊고 느리게 빨려 듭니다</span>
</p>
<div class="bh__field">
<canvas class="bh__cv" id="cv"></canvas>
</div>
<div class="bh__bar">
<label class="bh__dial">확대율
<input type="range" id="zoom" min="102" max="110" step="1" value="106" aria-label="확대율">
<b class="bh__val" id="zoomVal">×1.06</b>
</label>
</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);
.bh {
--bg-0: #{mix($subject-blue, $stage-ink, 14%)};
--bg-1: #{$stage-ink};
// 터널 벽 얼룩 — 크림·하늘빛·노랑·주황이 섞여 돌아간다
--speck-cream: #{$subject-cream};
--speck-blue: #{$subject-blue-light};
--speck-yellow: #{$stage-yellow};
--speck-orange: #{$stage-orange};
// 강착 빛(지평선 바깥의 따뜻한 안개)과 광자 구
--glow-0: #{rgba($stage-orange, .5)};
--glow-1: #{rgba($stage-orange, 0)};
--ring: #{$stage-yellow};
--hole: #000; // 사건의 지평선 — 잉크보다 더 깊은 검정
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.bh__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;
}
.bh__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $stage-yellow; }
.bh__field {
position: relative; height: 184px;
border-radius: $r-card; overflow: hidden;
background: $stage-ink;
box-shadow: inset 0 0 0 1px rgba($subject-cream, .14);
}
.bh__cv { display: block; width: 100%; height: 100%; }
.bh__bar { display: flex; align-items: center; gap: $sp-3; }
.bh__dial {
display: flex; align-items: center; gap: $sp-2;
font-weight: 700; font-size: 11px; color: $subject-cream;
}
.bh__dial input { width: 116px; margin: 0; accent-color: $subject-blue-light; }
.bh__val { color: $stage-yellow; font-size: 11px; font-weight: 700; }
@media (max-width: 340px) {
.bh { gap: $sp-1; }
.bh__field { height: 104px; }
.bh__dial { gap: $sp-1; }
.bh__dial input { width: 68px; }
.bh__hint { font-size: 10px; }
}
// reduce — 루프가 아예 안 돈다. 완성된 터널의 정적 한 장이 남는다.
@media (prefers-reduced-motion: reduce) {
.bh.is-still .bh__val { opacity: .92 !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);
}
.bh {
--bg-0: rgb(10.337254902%, 12.7294117647%, 22.2745098039%);
--bg-1: #17141a;
--speck-cream: #fff7e6;
--speck-blue: #5b8cf8;
--speck-yellow: #ffd23f;
--speck-orange: #ff4d1f;
--glow-0: rgba(255, 77, 31, 0.5);
--glow-1: rgba(255, 77, 31, 0);
--ring: #ffd23f;
--hole: #000;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.bh__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;
}
.bh__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #ffd23f;
}
.bh__field {
position: relative;
height: 184px;
border-radius: 16px;
overflow: hidden;
background: #17141a;
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.14);
}
.bh__cv {
display: block;
width: 100%;
height: 100%;
}
.bh__bar {
display: flex;
align-items: center;
gap: 12px;
}
.bh__dial {
display: flex;
align-items: center;
gap: 8px;
font-weight: 700;
font-size: 11px;
color: #fff7e6;
}
.bh__dial input {
width: 116px;
margin: 0;
accent-color: #5b8cf8;
}
.bh__val {
color: #ffd23f;
font-size: 11px;
font-weight: 700;
}
@media (max-width: 340px) {
.bh {
gap: 4px;
}
.bh__field {
height: 104px;
}
.bh__dial {
gap: 4px;
}
.bh__dial input {
width: 68px;
}
.bh__hint {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.bh.is-still .bh__val {
opacity: 0.92 !important;
}
}// 블랙홀 터널 — 되먹임 확대 하나로 만든다. 매 프레임 캔버스 자기 자신(직전 프레임)을
// 보조 캔버스에 복사한 뒤 중심 기준 scale 배로 키워 자기 위에 다시 drawImage 한다.
// 자기 위에 겹쳐 그리면 읽는 도중 내용이 바뀌므로 반드시 보조 캔버스를 거친다.
// 키워진 지난 그림 위에 매 프레임 새 얼룩(밝은 점·호)을 lighter 합성으로 조금씩 뿌리면
// 얼룩이 바깥으로 흘러 나가며 끝없이 빨려 드는 터널 벽이 된다.
var root = document.getElementById('bh');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d');
var zoomIn = document.getElementById('zoom');
var zoomOut = document.getElementById('zoomVal');
var css = getComputedStyle(root);
function v(name) { return css.getPropertyValue(name).trim(); }
var BG0 = v('--bg-0'), BG1 = v('--bg-1');
var SPECKS = [v('--speck-cream'), v('--speck-blue'), v('--speck-yellow'), v('--speck-orange')];
var GLOW0 = v('--glow-0'), GLOW1 = v('--glow-1');
var RING = v('--ring');
var HOLE = v('--hole');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var zoom = 1.06; // 되먹임 배율 — 편 변형 1번(1.06 에서 1.02 로)과 같은 축
var SEEDS = 4; // 프레임마다 뿌리는 얼룩 수
var raf = 0;
var aux = document.createElement('canvas');
var actx = aux.getContext('2d');
function fit() {
var r = cv.parentElement.getBoundingClientRect();
var dpr = window.devicePixelRatio || 1;
// 이 데모는 되먹임 drawImage 이 좌표계가 흐트러지지 않게 장치 픽셀로만 그린다(변환 없음)
cv.width = Math.round(r.width * dpr);
cv.height = Math.round(r.height * dpr);
aux.width = cv.width; aux.height = cv.height;
}
// 바닥 — 짙은 방사 배경과 옅은 동심 원 몇 개. 첫 프레임이 빈 화면이 되지 않게 한다
function base() {
var w = cv.width, h = cv.height;
var g = ctx.createRadialGradient(w * 0.5, h * 0.5, 4, w * 0.5, h * 0.5, Math.max(w, h) * 0.72);
g.addColorStop(0, BG0);
g.addColorStop(1, BG1);
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1;
ctx.fillStyle = g;
ctx.fillRect(0, 0, w, h);
ctx.strokeStyle = SPECKS[1];
for (var i = 1; i <= 4; i++) {
ctx.globalAlpha = 0.05 + i * 0.015;
ctx.beginPath();
ctx.arc(w * 0.5, h * 0.5, horizonR() * (1 + i * 0.85), 0, Math.PI * 2);
ctx.stroke();
}
ctx.globalAlpha = 1;
}
function horizonR() { return Math.max(12, Math.round(Math.min(cv.width, cv.height) * 0.15)); }
// 새 얼룩 — 터널 벽의 밥알. 지평선 바로 바깥에서 태어나 확대되며 바깥으로 흘러 나간다
function speck() {
var w = cv.width, h = cv.height, r = horizonR();
var ang = Math.random() * Math.PI * 2;
var dist = r * (1.12 + Math.random() * 1.7);
var x = w * 0.5 + Math.cos(ang) * dist;
var y = h * 0.5 + Math.sin(ang) * dist;
ctx.fillStyle = SPECKS[(Math.random() * SPECKS.length) | 0];
if (Math.random() < 0.3) {
// 호 얼룩 — 훑고 지나가는 빛의 자국
ctx.lineWidth = 1 + Math.random() * 1.6;
ctx.beginPath();
ctx.arc(w * 0.5, h * 0.5, dist, ang - 0.18, ang + 0.18);
ctx.stroke();
} else {
ctx.globalAlpha = 0.5 + Math.random() * 0.5;
ctx.beginPath();
ctx.arc(x, y, 0.8 + Math.random() * 1.8, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
}
}
// 사건의 지평선 + 광자 구 — 매 프레임 마지막에 얹는다. 검은 원은 방사 그라디언트로
// 가장자리만 물러지게 지우고, 그 둘레를 도는 얇은 링이 흐르는 방향을 알려 준다.
function horizon(now) {
var w = cv.width, h = cv.height, r = horizonR();
var cx = w * 0.5, cy = h * 0.5;
// 강착 빛 — 지평선 바로 바깥의 따뜻한 안개
ctx.globalCompositeOperation = 'lighter';
var glow = ctx.createRadialGradient(cx, cy, r * 0.9, cx, cy, r * 2.1);
glow.addColorStop(0, GLOW0);
glow.addColorStop(1, GLOW1);
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(cx, cy, r * 2.1, 0, Math.PI * 2);
ctx.fill();
// 검은 원
ctx.globalCompositeOperation = 'source-over';
var disc = ctx.createRadialGradient(cx, cy, r * 0.6, cx, cy, r);
disc.addColorStop(0, HOLE);
disc.addColorStop(0.82, HOLE);
disc.addColorStop(1, 'rgba(0, 0, 0, 0)');
ctx.fillStyle = disc;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fill();
// 광자 구 — 링 전체를 옅게, 그 위에 대시 한토막을 돌려 보인다
ctx.strokeStyle = RING;
ctx.lineWidth = Math.max(1, r * 0.05);
ctx.globalAlpha = 0.55;
ctx.beginPath();
ctx.arc(cx, cy, r * 1.06, 0, Math.PI * 2);
ctx.stroke();
ctx.globalAlpha = 1;
var circ = 2 * Math.PI * r * 1.06;
ctx.setLineDash([circ * 0.22, circ * 0.78]);
ctx.lineDashOffset = -(now / 1000 % 1) * circ;
ctx.lineWidth = Math.max(1.4, r * 0.09);
ctx.beginPath();
ctx.arc(cx, cy, r * 1.06, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
}
function frame(now) {
var w = cv.width, h = cv.height;
// ① 직전 프레임을 보조 캔버스에 복사
actx.clearRect(0, 0, w, h);
actx.drawImage(cv, 0, 0);
// ② 중심 기준 확대해 다시 깐다 — 이 한 번의 drawImage 가 터널 전부다
var dw = w * zoom, dh = h * zoom;
ctx.globalCompositeOperation = 'source-over';
ctx.drawImage(aux, 0, 0, w, h, (w - dw) * 0.5, (h - dh) * 0.5, dw, dh);
// ③ 새 얼룩을 더한다 — 겹치는 곳이 밝아지는 lighter 합성
ctx.globalCompositeOperation = 'lighter';
for (var i = 0; i < SEEDS; i++) speck();
// ④ 지평선과 광자 구
horizon(now);
raf = requestAnimationFrame(frame);
}
function setZoom(val) {
zoom = Number(val) / 100;
zoomOut.textContent = '×' + zoom.toFixed(2);
}
zoomIn.addEventListener('input', function () { setZoom(zoomIn.value); });
window.addEventListener('resize', function () { fit(); if (reduce) still(); else base(); });
// 정지판 — 루프 없이 얼룩·확대를 몇십 번만 몰아 그려 이미 다 만들어진 터널 한 장을 얻는다
function still() {
base();
for (var i = 0; i < 60; i++) {
var w = cv.width, h = cv.height;
actx.clearRect(0, 0, w, h);
actx.drawImage(cv, 0, 0);
var dw = w * zoom, dh = h * zoom;
ctx.globalCompositeOperation = 'source-over';
ctx.drawImage(aux, 0, 0, w, h, (w - dw) * 0.5, (h - dh) * 0.5, dw, dh);
ctx.globalCompositeOperation = 'lighter';
for (var k = 0; k < SEEDS; k++) speck();
}
horizon(0);
}
fit();
setZoom(zoomIn.value);
if (reduce) {
// reduce — 루프를 시작하지 않고 완성된 터널의 정적 한 장만 그린다(빈 화면이 아니다)
root.classList.add('is-still');
still();
} else {
base();
raf = requestAnimationFrame(frame);
}// BlackholeTunnel — vanilla 판(run/077/demo/05-fx-blackhole-tunnel/index.html) 의 React 포트.
// 매 프레임 캔버스 자기 자신(직전 프레임)을 보조 캔버스에 복사한 뒤 중심 기준 확대해
// 다시 drawImage 하는 되먹임 하나로 끝없이 빨려 드는 터널을 만든다.
// 움직임은 캔버스 rAF 루프가 전부 그리므로 @keyframes 가 없다 — props 는 CSS 변수로 내려간다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./BlackholeTunnel.module.scss";
export type BlackholeTunnelProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
// React 이벤트 제네릭이 없는 환경이라 필요한 것만 로컬로 선언한다
type RngEvt = { target: { value: string } };
const ZOOM_LABEL = "확대율";
const SEEDS = 4; // 프레임마다 뿌리는 얼룩 수
const BASE_ZOOM = "106";
export default function BlackholeTunnel({
duration = "2s", // vanilla $duration: $dur-loop
easing = "cubic-bezier(.2, .8, .2, 1)", // vanilla $easing: $ease-spring
color = "#2f6df6", // vanilla $color: $subject-blue
}: BlackholeTunnelProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const zoomOutRef = useRef<HTMLElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const auxRef = useRef<HTMLCanvasElement | null>(null);
const actxRef = useRef<CanvasRenderingContext2D | null>(null);
const zoomRef = useRef(1.06) as Box<number>; // 되먹임 배율 — 편 변형 1번과 같은 축
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 horizonR(): number {
const cv = cvRef.current;
if (!cv) return 12;
return Math.max(12, Math.round(Math.min(cv.width, cv.height) * 0.15));
}
// 이 데모는 되먹임 drawImage 이 좌표계가 흐트러지지 않게 장치 픽셀로만 그린다(변환 없음)
function fit(): void {
const cv = cvRef.current;
const aux = auxRef.current;
if (!cv || !aux || !cv.parentElement) return;
const r = cv.parentElement.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
cv.width = Math.round(r.width * dpr);
cv.height = Math.round(r.height * dpr);
aux.width = cv.width;
aux.height = cv.height;
}
// 바닥 — 짙은 방사 배경과 옅은 동심 원 몇 개. 첫 프레임이 빈 화면이 되지 않게 한다
function base(): void {
const ctx = ctxRef.current;
const root = rootRef.current;
const cv = cvRef.current;
if (!ctx || !root || !cv) return;
const cssv = (n: string) => getComputedStyle(root).getPropertyValue(n).trim();
const w = cv.width, h = cv.height;
const g = ctx.createRadialGradient(w * 0.5, h * 0.5, 4, w * 0.5, h * 0.5, Math.max(w, h) * 0.72);
g.addColorStop(0, cssv("--bg-0"));
g.addColorStop(1, cssv("--bg-1"));
ctx.globalCompositeOperation = "source-over";
ctx.globalAlpha = 1;
ctx.fillStyle = g;
ctx.fillRect(0, 0, w, h);
ctx.strokeStyle = cssv("--speck-blue");
for (let i = 1; i <= 4; i++) {
ctx.globalAlpha = 0.05 + i * 0.015;
ctx.beginPath();
ctx.arc(w * 0.5, h * 0.5, horizonR() * (1 + i * 0.85), 0, Math.PI * 2);
ctx.stroke();
}
ctx.globalAlpha = 1;
}
// 새 얼룩 — 터널 벽의 밥알. 지평선 바로 바깥에서 태어나 확대되며 바깥으로 흘러 나간다
function speck(): void {
const ctx = ctxRef.current;
const root = rootRef.current;
const cv = cvRef.current;
if (!ctx || !root || !cv) return;
const cssv = (n: string) => getComputedStyle(root).getPropertyValue(n).trim();
const specks = ["--speck-cream", "--speck-blue", "--speck-yellow", "--speck-orange"].map(cssv);
const w = cv.width, h = cv.height, r = horizonR();
const ang = Math.random() * Math.PI * 2;
const dist = r * (1.12 + Math.random() * 1.7);
const x = w * 0.5 + Math.cos(ang) * dist;
const y = h * 0.5 + Math.sin(ang) * dist;
ctx.strokeStyle = specks[(Math.random() * specks.length) | 0];
ctx.fillStyle = ctx.strokeStyle;
if (Math.random() < 0.3) {
// 호 얼룩 — 훑고 지나가는 빛의 자국
ctx.lineWidth = 1 + Math.random() * 1.6;
ctx.beginPath();
ctx.arc(w * 0.5, h * 0.5, dist, ang - 0.18, ang + 0.18);
ctx.stroke();
} else {
ctx.globalAlpha = 0.5 + Math.random() * 0.5;
ctx.beginPath();
ctx.arc(x, y, 0.8 + Math.random() * 1.8, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
}
}
// 사건의 지평선 + 광자 구 — 매 프레임 마지막에 얹는다. 검은 원은 방사 그라디언트로
// 가장자리만 물러지게 지우고, 그 둘레를 도는 얇은 링이 흐르는 방향을 알려 준다.
function horizon(now: number): void {
const ctx = ctxRef.current;
const root = rootRef.current;
const cv = cvRef.current;
if (!ctx || !root || !cv) return;
const cssv = (n: string) => getComputedStyle(root).getPropertyValue(n).trim();
const w = cv.width, h = cv.height, r = horizonR();
const cx = w * 0.5, cy = h * 0.5;
const hole = cssv("--hole");
// 강착 빛 — 지평선 바로 바깥의 따뜻한 안개
ctx.globalCompositeOperation = "lighter";
const glow = ctx.createRadialGradient(cx, cy, r * 0.9, cx, cy, r * 2.1);
glow.addColorStop(0, cssv("--glow-0"));
glow.addColorStop(1, cssv("--glow-1"));
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(cx, cy, r * 2.1, 0, Math.PI * 2);
ctx.fill();
// 검은 원
ctx.globalCompositeOperation = "source-over";
const disc = ctx.createRadialGradient(cx, cy, r * 0.6, cx, cy, r);
disc.addColorStop(0, hole);
disc.addColorStop(0.82, hole);
disc.addColorStop(1, "rgba(0, 0, 0, 0)");
ctx.fillStyle = disc;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fill();
// 광자 구 — 링 전체를 옅게, 그 위에 대시 한토막을 돌려 보인다
ctx.strokeStyle = cssv("--ring");
ctx.lineWidth = Math.max(1, r * 0.05);
ctx.globalAlpha = 0.55;
ctx.beginPath();
ctx.arc(cx, cy, r * 1.06, 0, Math.PI * 2);
ctx.stroke();
ctx.globalAlpha = 1;
const circ = 2 * Math.PI * r * 1.06;
ctx.setLineDash([circ * 0.22, circ * 0.78]);
ctx.lineDashOffset = -(now / 1000 % 1) * circ;
ctx.lineWidth = Math.max(1.4, r * 0.09);
ctx.beginPath();
ctx.arc(cx, cy, r * 1.06, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
}
// 되먹임 한 걸음 — 직전 프레임을 확대해 다시 깐다. 이 drawImage 가 터널 전부다
function feedback(): void {
const ctx = ctxRef.current;
const actx = actxRef.current;
const cv = cvRef.current;
const aux = auxRef.current;
if (!ctx || !actx || !cv || !aux) return;
const w = cv.width, h = cv.height;
actx.clearRect(0, 0, w, h);
actx.drawImage(cv, 0, 0);
const dw = w * zoomRef.current, dh = h * zoomRef.current;
ctx.globalCompositeOperation = "source-over";
ctx.drawImage(aux, 0, 0, w, h, (w - dw) * 0.5, (h - dh) * 0.5, dw, dh);
}
function frame(now: number): void {
feedback();
const ctx = ctxRef.current;
if (ctx) {
// 새 얼룩을 더한다 — 겹치는 곳이 밝아지는 lighter 합성
ctx.globalCompositeOperation = "lighter";
for (let i = 0; i < SEEDS; i++) speck();
}
horizon(now);
rafRef.current = requestAnimationFrame(frame);
}
function setZoom(val: string): void {
zoomRef.current = Number(val) / 100;
if (zoomOutRef.current) zoomOutRef.current.textContent = "×" + zoomRef.current.toFixed(2);
if (reduced()) still();
}
function onRange(e: RngEvt): void {
setZoom(e.target.value);
}
// 정지판 — 루프 없이 얼룩·확대를 몇십 번만 몰아 그려 이미 다 만들어진 터널 한 장을 얻는다
function still(): void {
const ctx = ctxRef.current;
if (!ctx) return;
base();
for (let i = 0; i < 60; i++) {
feedback();
ctx.globalCompositeOperation = "lighter";
for (let k = 0; k < SEEDS; k++) speck();
}
horizon(0);
}
function onResize(): void {
fit();
if (reduced()) still();
else base();
}
useEffect(() => {
const cv = cvRef.current;
const aux = document.createElement("canvas");
auxRef.current = aux;
actxRef.current = aux.getContext("2d");
if (!cv || !actxRef.current) return;
ctxRef.current = cv.getContext("2d");
if (!ctxRef.current) return;
fit();
if (reduced()) {
// reduce — 루프를 시작하지 않고 완성된 터널의 정적 한 장만 그린다
rootRef.current?.classList.add("is-still");
still();
} else {
base();
rafRef.current = requestAnimationFrame(frame);
}
window.addEventListener("resize", onResize);
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="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M232 128a104 104 0 0 1-208 0c0-41 23.81-78.36 60.66-95.27a8 8 0 0 1 6.68 14.54C60.15 61.59 40 93.27 40 128a88 88 0 0 0 176 0c0-34.73-20.15-66.41-51.34-80.73a8 8 0 0 1 6.68-14.54C208.19 49.64 232 87 232 128"/></g></svg>
<span>확대율을 낮추면 터널이 더 깊고 느리게 빨려 듭니다</span>
</p>
<div className={styles.field}>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<label className={styles.dial}>{ZOOM_LABEL}
<input type="range" min="102" max="110" step="1" defaultValue={BASE_ZOOM} aria-label={ZOOM_LABEL} onChange={onRange}></input>
<b className={styles.val} ref={zoomOutRef}>×1.06</b>
</label>
</div>
</div>
</main>
);
}05 블랙홀 터널 — 측정 누적면적 52.193% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · html5 canvas 애니메이션 9종 — 물방울·블랙홀 특수효과
이 데모가 속한 글
html5 canvas 애니메이션 9종 — 물방울·블랙홀 특수효과html5 canvas 애니메이션은 물방울이 글자를 부풀려 보이게 하고 블랙홀이 화면을 빨아들이는 것처럼, 사진 보정 없이 픽셀을 직접 움직이는 특수효과입니다. 이 아홉 종을 Three.js 없이 캔버스 한 장으로 이 글의 데모 전부 보기


