04흔들리는 3D 텍스트
글자를 보이지 않는 캔버스에 한 번만 그린 뒤 가로 한 줄씩 잘라 좌우로 밀어 붙입니다. 줄마다 밀리는 양이 사인파라서 글자가 깃발처럼 출렁이고, 아래로 깐 그림자가 두께를 만듭니다.
쓰임행사 페이지의 출렁이는 헤드라인
fillTextmeasureTextdrawImage

<main class="stage">
<div class="tw" id="tw">
<p class="tw__hint">
<svg class="tw__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M232 128c-52 110.85-78 55.43-104 0Zm-208 0h104c-26-55.43-52-110.85-104 0" opacity=".2"/><path d="M239.24 131.4c-22 46.8-41.4 68.6-61.2 68.6c-25.1 0-40.73-33.32-57.28-68.6C107.7 103.56 92.9 72 78 72c-16.4 0-36.31 37.21-46.72 59.4a8 8 0 0 1-14.48-6.8C38.71 77.8 58.16 56 78 56c25.1 0 40.73 33.32 57.28 68.6C148.3 152.44 163.1 184 178 184c16.4 0 36.31-37.21 46.72-59.4a8 8 0 0 1 14.48 6.8Z"/></g></svg>
<span>문구를 바꾸면 가로 줄마다 사인파 위상만큼 밀려 출렁입니다</span>
</p>
<div class="tw__field">
<canvas class="tw__cv" id="cv"></canvas>
<b class="tw__badge" aria-hidden="true"><span class="tw__badgeNum">2px</span><span class="tw__badgeUnit">스캔라인</span></b>
</div>
<div class="tw__bar">
<input class="tw__in" id="phraseIn" type="text" maxlength="12" value="깃발처럼 출렁" placeholder="문구를 입력하세요" aria-label="표시할 문구">
<button class="tw__btn" id="nextBtn" type="button">다음 문구</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 흔들리는 3D 텍스트 — 움직임은 전부 캔버스 rAF 루프 안에 있다. CSS 는 무대·판·입력칸만 맡는다.
// @keyframes 를 하나도 두지 않는다 — CSS 애니메이션이 0개라야 렌더러가 시계를 고정하지 않고
// 실시간으로 24장을 찍는다(01 항목과 같은 방식, 도구/demo.py 렌더()).
// 캔버스가 쓰는 색은 커스텀 프로퍼티로 내려보낸다 — 값은 토큰 한 곳에만 둔다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-yellow);
.tw {
--face: #{$subject-ink}; // 글자 앞면 — 노랑 무대 위 잉크
--depth: #{$color}; // 아래 복사층(가짜 3D 돌출) — 파랑 한 겹
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.tw__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-ink;
}
.tw__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $color; }
.tw__field {
position: relative; height: 184px;
border-radius: $r-card; overflow: hidden;
background: $stage-yellow;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .22);
}
.tw__cv { display: block; width: 100%; height: 100%; }
// 코드의 ROW 상수를 화면에도 적어 둔다 — 2px 스캔라인
.tw__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;
}
.tw__badgeNum { font-size: 13px; color: $stage-yellow; }
.tw__badgeUnit { font-weight: 700; }
.tw__bar { display: flex; align-items: center; gap: $sp-2; }
.tw__in {
flex: 1 1 auto; min-width: 0; margin: 0;
padding: $sp-1 $sp-2; border: 0; border-radius: $r-sm;
background: $stage-paper; color: $subject-ink;
font-weight: 700; font-size: 12px; line-height: 1.4;
font-family: inherit;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .22);
}
.tw__in:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.tw__btn {
flex: 0 0 auto; margin: 0;
padding: $sp-1 $sp-2; border: 0; border-radius: $r-control;
background: $subject-ink; color: $subject-cream;
font-weight: 700; font-size: 11px; line-height: 1.4;
font-family: inherit; cursor: pointer;
}
.tw__btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.tw__btn:active { transform: translateY(1px); }
@media (max-width: 340px) {
.tw { gap: $sp-1; }
.tw__field { height: 108px; }
.tw__badge { font-size: 10px; padding: 0 $sp-1; }
.tw__badgeNum { font-size: 11px; }
.tw__bar { gap: $sp-1; }
.tw__in { font-size: 11px; padding: $sp-1; }
.tw__btn { padding: $sp-1; font-size: 10px; }
.tw__hint { font-size: 10px; }
}
// reduce — 루프가 아예 안 돈다. 출렁임이 잡힌 정적 한 장이 남으므로
// 문구를 바꾸면 즉시 다시 그린다. 없애야 할 애니메이션이 없어 커서 신호만 정리한다.
@media (prefers-reduced-motion: reduce) {
.tw.is-still .tw__badge { 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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tw {
--face: #17141a;
--depth: #2f6df6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.tw__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: #17141a;
}
.tw__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #2f6df6;
}
.tw__field {
position: relative;
height: 184px;
border-radius: 16px;
overflow: hidden;
background: #ffd23f;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.22);
}
.tw__cv {
display: block;
width: 100%;
height: 100%;
}
.tw__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;
}
.tw__badgeNum {
font-size: 13px;
color: #ffd23f;
}
.tw__badgeUnit {
font-weight: 700;
}
.tw__bar {
display: flex;
align-items: center;
gap: 8px;
}
.tw__in {
flex: 1 1 auto;
min-width: 0;
margin: 0;
padding: 4px 8px;
border: 0;
border-radius: 8px;
background: #ffffff;
color: #17141a;
font-weight: 700;
font-size: 12px;
line-height: 1.4;
font-family: inherit;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.22);
}
.tw__in:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.tw__btn {
flex: 0 0 auto;
margin: 0;
padding: 4px 8px;
border: 0;
border-radius: 12px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.4;
font-family: inherit;
cursor: pointer;
}
.tw__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.tw__btn:active {
transform: translateY(1px);
}
@media (max-width: 340px) {
.tw {
gap: 4px;
}
.tw__field {
height: 108px;
}
.tw__badge {
font-size: 10px;
padding: 0 4px;
}
.tw__badgeNum {
font-size: 11px;
}
.tw__bar {
gap: 4px;
}
.tw__in {
font-size: 11px;
padding: 4px;
}
.tw__btn {
padding: 4px;
font-size: 10px;
}
.tw__hint {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.tw.is-still .tw__badge {
opacity: 0.92 !important;
}
}// 흔들리는 3D 텍스트 — 글자를 보이지 않는 캔버스에 딱 한 번 fillText 로 그려 두고,
// 본 캔버스에는 가로 스캔라인(2px) 단위로 drawImage 잘라 붙인다. 이때 줄마다 사인파 위상만큼
// 옆으로 밀고 가로 폭도 살짝 들숨 쉬게 하면 깃발처럼 출렁이는 글자가 된다.
// 아래쪽에 먼저 깔아 두는 어두운 복사층이 두께(가짜 3D 돌출)를 만든다.
var root = document.getElementById('tw');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d');
var phraseIn = document.getElementById('phraseIn');
var nextBtn = document.getElementById('nextBtn');
var css = getComputedStyle(root);
var FACE = css.getPropertyValue('--face').trim(); // 글자 앞면
var DEPTH = css.getPropertyValue('--depth').trim(); // 아래 복사층(돌출)
var FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
var PRESETS = ['깃발처럼 출렁', 'canvas 한 장으로', '3D 돌출 글자'];
var presetAt = 0;
var ROW = 2; // 스캔라인 한 줄의 높이(CSS px). 화면 배지와 같은 값이다
var DROP = 7; // 복사층을 아래로 미루는 양 — 두께로 읽힌다
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1, raf = 0;
var phrase = phraseIn.value;
// 글자 원본 — 앞면과 복사층 두 장. 그리는 시점마다 다시 만든다(문구·창 폭이 바뀔 때뿐이다)
var face = document.createElement('canvas');
var depth = document.createElement('canvas');
var fctx = face.getContext('2d');
var dctx = depth.getContext('2d');
function fit() {
var r = cv.parentElement.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);
}
// 오프스크린 한 번 그리기 — measureText 로 100px 에서 폭을 재고 판 폭의 84% 에 맞춰 크기를 역산해
// 중앙 정렬한다. 긴 문구를 넣어도 넘치지 않는 이유가 이 두 줄이다.
function build() {
face.width = cv.width; face.height = cv.height;
depth.width = cv.width; depth.height = cv.height;
var w = face.width, h = face.height;
fctx.font = '700 100px ' + FONT;
var wide = Math.max(1, fctx.measureText(phrase).width);
var size = Math.min(100 * (w * 0.84) / wide, h * 0.5);
fctx.textAlign = 'center';
fctx.textBaseline = 'middle';
fctx.fillStyle = FACE;
fctx.font = '700 ' + size + 'px ' + FONT;
fctx.fillText(phrase, w * 0.5, h * 0.47);
dctx.textAlign = 'center';
dctx.textBaseline = 'middle';
dctx.fillStyle = DEPTH;
dctx.font = '700 ' + size + 'px ' + FONT;
dctx.fillText(phrase, w * 0.5, h * 0.47);
}
// 파동 — 사인 합성. 줄의 옆 이동 sx 는 y 와 시간 t 의 사인 둘(서로 반대 방향으로 달리는 행파),
// 가로 스케일 w 는 y 와 t 의 사인 하나. 스케일은 중심 기준이므로 밀림이 x 의 1차 함수가 되어
// 세 항을 합하면 변위가 x·y·t 에 모두 의존하는 마퀴식 출렁임이 된다.
function pass(img, drop, t) {
for (var y = 0; y < H; y += ROW) {
var sx = 7 * Math.sin(0.055 * y + 2.4 * t) + 3 * Math.sin(0.021 * y - 1.5 * t + 1.4);
var w = 1 + 0.05 * Math.sin(0.026 * y - 1.9 * t + 0.7);
ctx.drawImage(img, 0, y * dpr, img.width, ROW * dpr, (W - W * w) * 0.5 + sx, y + drop, W * w, ROW);
}
}
function draw(t) {
ctx.clearRect(0, 0, W, H);
pass(depth, DROP, t - 0.12); // 복사층 먼저 — 위상을 조금 늦게 줘 앞면과 어긋나면 돌출이 더 산다
pass(face, 0, t);
}
function frame(now) {
draw(now / 1000);
raf = requestAnimationFrame(frame);
}
function setPhrase(p) {
phrase = p || PRESETS[0];
build();
if (reduce) draw(1.7);
}
phraseIn.addEventListener('input', function () { setPhrase(phraseIn.value); });
nextBtn.addEventListener('click', function () {
presetAt = (presetAt + 1) % PRESETS.length;
phraseIn.value = PRESETS[presetAt];
setPhrase(phraseIn.value);
});
window.addEventListener('resize', function () { fit(); build(); if (reduce) draw(1.7); });
fit();
build();
if (reduce) {
// reduce — 루프를 시작하지 않는다. 출렁임이 잡힌 한 순간의 정적 한 장만 그린다(빈 화면이 아니다)
root.classList.add('is-still');
draw(1.7);
} else {
raf = requestAnimationFrame(frame);
}// TextHeatWarp — vanilla 판(run/077/demo/04-fx-text-heat-warp/index.html) 의 React 포트.
// 글자를 오프스크린 캔버스에 한 번만 fillText 로 그려 두고, 가로 스캔라인(2px) 단위로 drawImage
// 잘라 붙이되 줄마다 사인파 위상만큼 옆으로 민다. 아래 복사층이 두께(가짜 3D 돌출)를 만든다.
// 움직임은 캔버스 rAF 루프가 전부 그리므로 @keyframes 가 없다 — props 는 CSS 변수로 내려간다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./TextHeatWarp.module.scss";
export type TextHeatWarpProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
// React 이벤트 제네릭이 없는 환경이라 필요한 것만 로컬로 선언한다
type InEvt = { target: { value: string } };
const PRESETS = ["깃발처럼 출렁", "canvas 한 장으로", "3D 돌출 글자"];
const ROW = 2; // 스캔라인 한 줄의 높이(CSS px). 화면 배지와 같은 값이다
const DROP = 7; // 복사층을 아래로 미루는 양 — 두께로 읽힌다
const STILL_T = 1.7; // reduce 정지판을 찍는 파동의 시각(초)
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
const PHRASE_LABEL = "표시할 문구";
const PLACEHOLDER = "문구를 입력하세요";
const NEXT_LABEL = "다음 문구";
export default function TextHeatWarp({
duration = "2s", // vanilla $duration: $dur-loop
easing = "cubic-bezier(.2, .8, .2, 1)", // vanilla $easing: $ease-spring
color = "#2f6df6", // vanilla $color: $subject-blue
}: TextHeatWarpProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const faceRef = useRef<HTMLCanvasElement | null>(null);
const depthRef = useRef<HTMLCanvasElement | null>(null);
const sizeRef = useRef({ w: 0, h: 0, dpr: 1 }) as Box<{ w: number; h: number; dpr: number }>;
const phraseRef = useRef("깃발처럼 출렁") as Box<string>;
const presetRef = useRef(0) as Box<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 cv = cvRef.current;
if (!cv || !cv.parentElement) return;
const r = cv.parentElement.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
sizeRef.current.w = r.width;
sizeRef.current.h = r.height;
sizeRef.current.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);
}
// 오프스크린 한 번 그리기 — measureText 로 100px 에서 폭을 재고 판 폭의 84% 에 맞춰
// 크기를 역산해 중앙 정렬한다. 긴 문구를 넣어도 넘치지 않는 이유가 이 두 줄이다.
function build(): void {
const cv = cvRef.current;
const face = faceRef.current;
const depth = depthRef.current;
const root = rootRef.current;
if (!cv || !face || !depth || !root) return;
face.width = cv.width; face.height = cv.height;
depth.width = cv.width; depth.height = cv.height;
const fctx = face.getContext("2d");
const dctx = depth.getContext("2d");
if (!fctx || !dctx) return;
const cssv = (n: string) => getComputedStyle(root).getPropertyValue(n).trim();
const w = face.width, h = face.height;
const phrase = phraseRef.current;
fctx.font = "700 100px " + FONT;
const wide = Math.max(1, fctx.measureText(phrase).width);
const size = Math.min(100 * (w * 0.84) / wide, h * 0.5);
fctx.textAlign = "center";
fctx.textBaseline = "middle";
fctx.fillStyle = cssv("--face");
fctx.font = "700 " + size + "px " + FONT;
fctx.fillText(phrase, w * 0.5, h * 0.47);
dctx.textAlign = "center";
dctx.textBaseline = "middle";
dctx.fillStyle = cssv("--depth");
dctx.font = "700 " + size + "px " + FONT;
dctx.fillText(phrase, w * 0.5, h * 0.47);
}
// 파동 — 사인 합성. 줄의 옆 이동 sx 는 y 와 시간 t 의 사인 둘, 가로 스케일 w 는 y 와 t 의
// 사인 하나. 스케일은 중심 기준이므로 밀림이 x 의 1차 함수가 되어 세 항을 합하면 변위가
// x·y·t 에 모두 의존하는 마퀴식 출렁임이 된다.
function pass(img: HTMLCanvasElement, drop: number, t: number): void {
const ctx = ctxRef.current;
if (!ctx) return;
const { w: W, h: H, dpr } = sizeRef.current;
for (let y = 0; y < H; y += ROW) {
const sx = 7 * Math.sin(0.055 * y + 2.4 * t) + 3 * Math.sin(0.021 * y - 1.5 * t + 1.4);
const w = 1 + 0.05 * Math.sin(0.026 * y - 1.9 * t + 0.7);
ctx.drawImage(img, 0, y * dpr, img.width, ROW * dpr, (W - W * w) * 0.5 + sx, y + drop, W * w, ROW);
}
}
function draw(t: number): void {
const ctx = ctxRef.current;
const face = faceRef.current;
const depth = depthRef.current;
if (!ctx || !face || !depth) return;
const { w: W, h: H } = sizeRef.current;
ctx.clearRect(0, 0, W, H);
// 복사층 먼저 — 위상을 조금 늦게 줘 앞면과 어긋나면 돌출이 더 산다
pass(depth, DROP, t - 0.12);
pass(face, 0, t);
}
function frame(now: number): void {
draw(now / 1000);
rafRef.current = requestAnimationFrame(frame);
}
function setPhrase(p: string): void {
phraseRef.current = p || PRESETS[0];
build();
if (reduced()) draw(STILL_T);
}
function onInput(e: InEvt): void {
setPhrase(e.target.value);
}
function onNext(): void {
const input = inputRef.current;
presetRef.current = (presetRef.current + 1) % PRESETS.length;
const next = PRESETS[presetRef.current];
if (input) input.value = next;
setPhrase(next);
}
function onResize(): void {
fit();
build();
if (reduced()) draw(STILL_T);
}
useEffect(() => {
const cv = cvRef.current;
if (!cv) return;
ctxRef.current = cv.getContext("2d");
faceRef.current = document.createElement("canvas");
depthRef.current = document.createElement("canvas");
if (!ctxRef.current) return;
fit();
build();
window.addEventListener("resize", onResize);
if (reduced()) {
// reduce — 루프를 시작하지 않는다. 출렁임이 잡힌 한 순간의 정적 한 장만 그린다
rootRef.current?.classList.add("is-still");
draw(STILL_T);
} else {
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="M232 128c-52 110.85-78 55.43-104 0Zm-208 0h104c-26-55.43-52-110.85-104 0" opacity=".2"/><path d="M239.24 131.4c-22 46.8-41.4 68.6-61.2 68.6c-25.1 0-40.73-33.32-57.28-68.6C107.7 103.56 92.9 72 78 72c-16.4 0-36.31 37.21-46.72 59.4a8 8 0 0 1-14.48-6.8C38.71 77.8 58.16 56 78 56c25.1 0 40.73 33.32 57.28 68.6C148.3 152.44 163.1 184 178 184c16.4 0 36.31-37.21 46.72-59.4a8 8 0 0 1 14.48 6.8Z"/></g></svg>
<span>문구를 바꾸면 가로 줄마다 사인파 위상만큼 밀려 출렁입니다</span>
</p>
<div className={styles.field}>
<canvas className={styles.cv} ref={cvRef}></canvas>
<b className={styles.badge} aria-hidden="true"><span className={styles.badgeNum}>2px</span><span className={styles.badgeUnit}>스캔라인</span></b>
</div>
<div className={styles.bar}>
<input
className={styles.in}
ref={inputRef}
type="text"
maxLength={12}
defaultValue="깃발처럼 출렁"
placeholder={PLACEHOLDER}
aria-label={PHRASE_LABEL}
onChange={onInput}
/>
<button className={styles.btn} type="button" onClick={onNext}>{NEXT_LABEL}</button>
</div>
</div>
</main>
);
}04 흔들리는 3D 텍스트 — 측정 누적면적 13.245% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · html5 canvas 애니메이션 9종 — 물방울·블랙홀 특수효과
이 데모가 속한 글
html5 canvas 애니메이션 9종 — 물방울·블랙홀 특수효과html5 canvas 애니메이션은 물방울이 글자를 부풀려 보이게 하고 블랙홀이 화면을 빨아들이는 것처럼, 사진 보정 없이 픽셀을 직접 움직이는 특수효과입니다. 이 아홉 종을 Three.js 없이 캔버스 한 장으로 이 글의 데모 전부 보기


