AI 로딩 애니메이션 9가지 — 값 하나로 도는 처리중 표시
궤도 반경 값이 크면 입자가 넓게 흩어지는데 값이 0에 가까워지면 코어로 모이고, 그 사이 후광 링의 색상 값은 완료 색 쪽으로 넘어가며, 토큰 순번 값이 오를 때마다 미리보기 글자가 하나씩 늘어난다. 이 아홉 가지 ai 로딩 애니메이션도 값 하나로 이 흐름을 그대로 재현합니다.
이 글은 이런 하나짜리 계산값을 UI 로 바꿔 보여주는 아홉 가지 AI 로딩 애니메이션을 값 계산 방식별로 나눠 놓았습니다. 아홉 개 모두 requestAnimationFrame 루프 안에서 값 하나만 갱신하고, 클릭이나 Space·Enter 로 멈췄다 다시 움직일 수 있고 R 로 처음 값으로 되돌아갑니다. 창 크기가 바뀌면 resize 리스너가 같은 값으로 즉시 다시 그려 레이아웃이 흐트러지지 않게 막습니다.
01 ai-processing-loading-halo-ring-hue-shift
02 ai-processing-loading-orb-morph-pulse
03 ai-processing-loading-particle-swirl-core
04 ai-processing-loading-neural-node-pulse
05 ai-processing-loading-card-content-stream-fill
07 ai-processing-loading-listening-waveform-mic
08 ai-processing-loading-ghost-text-caret-reveal
09 ai-processing-loading-reduced-motion-static-badge
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 색상 값이 완료 쪽으로 도는 후광 링 AI 로딩
- 02 대기 시간 값이 늘어날수록 숨쉬듯 커지는 오브 펄스
- 03 궤도 반경 값이 좁아지며 코어에 모이는 입자 소용돌이
- 04 지연 값을 따라 마디를 건너가는 신경망 펄스
- 05 채워진 줄 수 값이 늘어나는 결과 카드 스트림 로딩
- 06 완료 값이 뒤집히면 반짝임으로 바뀌는 버튼 아이콘
- 07 입력 크기 값이 막대 높이를 흔드는 듣는 중 파형
- 08 토큰 순번 값이 오를 때마다 한 글자씩 드러나는 고스트 텍스트
- 09 동작 줄임 값을 감지하면 즉시 문구로 바뀌는 처리중 배지
AI 로딩 중 표시는 채팅 로딩·버튼 로딩과 이 지점에서 갈라집니다
AI 채팅 CSS 로딩 애니메이션 9종 은 말풍선 안에서 점이나 커서가 움직이는 대화창 전용 표시이고, 버튼 로딩 9종 — 스피너부터 성공 체크까지 상태 전환 은 클릭 이후 버튼 하나의 상태(대기→성공/실패) 전환에 집중합니다. 이 글의 아홉 개는 대화창이나 버튼 안에 갇히지 않고, 카드·배지·전체 화면 어디에도 붙일 수 있는 '지금 계산 중'이라는 신호 자체를 값 하나로 표현합니다.
캔버스 로딩 애니메이션 효과 9가지 — 코드 그대로 가 중력이나 충돌 같은 물리 값을 계산해 넣는 쪽이라면, 이번 아홉 개 중에서는 03·04 두 개만 캔버스를 쓰고 나머지 일곱 개는 CSS 커스텀 프로퍼티나 SVG path 값을 직접 갱신합니다. 힘을 시뮬레이션하지 않고 hue·반경·지연·진폭처럼 축 하나의 값만 시간에 따라 계산한다는 점이 이 아홉 개를 가장 단순하게 만듭니다.
가짜 처리 중 표시라는 함정에 빠지지 않으려면(사용자 기대 관리)
아홉 개 모두 LOOP 초 단위로 반복되는 고정 애니메이션이며, 실제 모델이나 API 호출과는 연결돼 있지 않습니다. 화면에 뜨는 '처리 중', '듣는 중' 같은 문구도 미리 박아 둔 고정 텍스트일 뿐이라 어떤 서비스명이나 모델명도 담지 않았습니다.
문제는 실제 서비스에 붙일 때입니다. 진짜 완료 신호(응답 도착·스트림 종료)를 연결하지 않고 이 값 계산만 그대로 두면, 실제 처리가 이미 끝났는데도 화면은 LOOP 값을 따라 계속 돌아 사용자에게는 ai 무한로딩처럼 보일 수 있습니다. 반대로 실제 처리가 LOOP 값보다 오래 걸리면 애니메이션은 진작 한 바퀴를 돌고도 아무 일 없다는 듯 같은 리듬을 반복해, "멈춘 건가" 하는 의심을 사기 쉽습니다.
그래서 이 아홉 개는 장식이 아니라 신호로 써야 합니다 — 실제 완료 이벤트가 오는 즉시 애니메이션을 멈추고 결과 화면으로 바꿔치기하는 코드를 여러분이 직접 붙여야, 값과 실제 상태가 어긋나는 함정을 피할 수 있습니다.
01색상 값이 완료 쪽으로 도는 후광 링 AI 로딩
conic-gradient 로 그린 후광 링이 회전하며 처리 진행 값에 따라 색상(hue) 값을 완료 색 쪽으로 서서히 옮겨, 링 색만 보고도 얼마나 끝나가는지 가늠할 수 있습니다. 클릭이나 Space·Enter 로 일시정지를 토글하고 R 로 재시작할 수 있으며, 루프 한 프레임마다 hue 값 하나만 다시 계산합니다.

<main class="stage">
<div class="ld" id="ld">
<p class="ld__hint">
<svg class="ld__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M232 56v56a8 8 0 0 1-16 0V91.3l-25.6 25.7A80 80 0 1 1 174 60a8 8 0 1 1-9 13.2A64 64 0 1 0 179.7 128H216a8 8 0 0 1 0 16h-56a8 8 0 0 1-8-8V80a8 8 0 0 1 16 0v20.7L192.7 76A80.1 80.1 0 0 0 64 128a8 8 0 0 1-16 0a96 96 0 0 1 163.6-68.3L224 71.3V56a8 8 0 0 1 8 8" opacity=".2"/><path d="M224 48a8 8 0 0 0-8 8v15.3l-11.7-11.6a96 96 0 1 0 27.6 51.6a8 8 0 0 0-15.8 2.5a80 80 0 1 1-23-43l-25.7 25.7A8 8 0 0 0 173 112h56a8 8 0 0 0 8-8V56a8 8 0 0 0-13-8"/></g></svg>
<span>클릭하거나 스페이스바를 누르면 멈추거나 다시 갑니다</span>
</p>
<div class="ld__field" id="field" role="application" aria-label="후광 링 색상 전환 실험판" tabindex="0">
<div class="ld__ring" id="ring"></div>
<div class="ld__core" id="core"></div>
<b class="ld__badge" aria-hidden="true"><span class="ld__badgeNum" id="pct">0%</span><span class="ld__badgeUnit">진행</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// halo-ring-hue-shift — conic-gradient 로 그린 후광 링이 회전하며, 진행 값이 전환 구간에 들어서면
// 남은 시간(AI_LOADER_HUE_TRANSITION_MS)에 걸쳐 링 색을 완료 색 쪽으로 서서히 섞는다.
// 움직임은 rAF 가 CSS 커스텀 속성을 매 프레임 고쳐 만든다 — @keyframes 없음.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.ld {
--blue: #{$subject-blue};
--done: #{$stage-yellow};
--ring-size: 200px;
--ring-band: 40px;
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.ld__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;
}
.ld__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-blue-light; }
.ld__field {
position: relative; height: 208px;
display: flex; align-items: center; justify-content: center;
border-radius: $r-card; overflow: hidden;
background: $subject-ink;
box-shadow: inset 0 0 0 1px rgba($subject-cream, .14);
cursor: pointer;
}
.ld__field:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
.ld__ring {
position: relative;
width: var(--ring-size); height: var(--ring-size);
border-radius: $r-pill;
background: conic-gradient(from var(--angle, 0deg), var(--c1, #{$subject-blue}), var(--c2, transparent), var(--c1, #{$subject-blue}));
-webkit-mask: radial-gradient(circle, transparent calc(50% - var(--ring-band)), #000 calc(50% - var(--ring-band) + 1px));
mask: radial-gradient(circle, transparent calc(50% - var(--ring-band)), #000 calc(50% - var(--ring-band) + 1px));
filter: drop-shadow(0 0 14px rgba($subject-blue-light, .5));
}
.ld__core {
position: absolute;
width: 46px; height: 46px; border-radius: $r-pill;
background: var(--core-c, #{$subject-blue});
transform: scale(var(--core-scale, 1));
box-shadow: 0 0 22px 4px rgba($subject-blue-light, .55);
}
.ld__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-cream, .12);
color: $subject-cream; font-weight: 700; font-size: 11px;
}
.ld__badgeNum { font-size: 13px; color: $stage-yellow; }
.ld__badgeUnit { font-weight: 700; }
@media (max-width: 340px) {
.ld { gap: $sp-1; }
.ld__field { height: 124px; }
.ld { --ring-size: 116px; --ring-band: 24px; }
.ld__core { width: 28px; height: 28px; }
.ld__badge { font-size: 10px; padding: 0 $sp-1; }
.ld__badgeNum { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.ld.is-still .ld__field { cursor: default !important; }
.ld.is-still .ld__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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ld {
--blue: #2f6df6;
--done: #ffd23f;
--ring-size: 200px;
--ring-band: 40px;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.ld__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;
}
.ld__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #5b8cf8;
}
.ld__field {
position: relative;
height: 208px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 16px;
overflow: hidden;
background: #17141a;
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.14);
cursor: pointer;
}
.ld__field:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.ld__ring {
position: relative;
width: var(--ring-size);
height: var(--ring-size);
border-radius: 999px;
background: conic-gradient(from var(--angle, 0deg), var(--c1, #2f6df6), var(--c2, transparent), var(--c1, #2f6df6));
-webkit-mask: radial-gradient(circle, transparent calc(50% - var(--ring-band)), #000 calc(50% - var(--ring-band) + 1px));
mask: radial-gradient(circle, transparent calc(50% - var(--ring-band)), #000 calc(50% - var(--ring-band) + 1px));
filter: drop-shadow(0 0 14px rgba(91, 140, 248, 0.5));
}
.ld__core {
position: absolute;
width: 46px;
height: 46px;
border-radius: 999px;
background: var(--core-c, #2f6df6);
transform: scale(var(--core-scale, 1));
box-shadow: 0 0 22px 4px rgba(91, 140, 248, 0.55);
}
.ld__badge {
position: absolute;
right: 8px;
top: 8px;
display: flex;
align-items: baseline;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.12);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
.ld__badgeNum {
font-size: 13px;
color: #ffd23f;
}
.ld__badgeUnit {
font-weight: 700;
}
@media (max-width: 340px) {
.ld {
gap: 4px;
}
.ld__field {
height: 124px;
}
.ld {
--ring-size: 116px;
--ring-band: 24px;
}
.ld__core {
width: 28px;
height: 28px;
}
.ld__badge {
font-size: 10px;
padding: 0 4px;
}
.ld__badgeNum {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.ld.is-still .ld__field {
cursor: default !important;
}
.ld.is-still .ld__badge {
opacity: 0.92 !important;
}
}(function () {
var LOOP = 2.0; // 캡처 창(초) — S.초 와 동일
var ROTATE_SPEED = 128; // 링 회전 속도(도/초)
var AI_LOADER_HUE_TRANSITION_MS = 640; // 후광 링 색상 전환 속도 값(전) — 남은 구간에서 완료 색으로 옮겨가는 창 길이
var root = document.getElementById('ld');
var field = document.getElementById('field');
var ring = document.getElementById('ring');
var core = document.getElementById('core');
var pctEl = document.getElementById('pct');
var paused = false;
var t0 = performance.now();
var raf = 0;
function reduced() {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
function hexToRgb(hex) {
var h = hex.replace('#', '').trim();
if (h.length === 3) h = h[0]+h[0]+h[1]+h[1]+h[2]+h[2];
var n = parseInt(h, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function mix(a, b, t) {
return [
Math.round(a[0] + (b[0] - a[0]) * t),
Math.round(a[1] + (b[1] - a[1]) * t),
Math.round(a[2] + (b[2] - a[2]) * t)
];
}
function smooth(t) { return t * t * (3 - 2 * t); }
function rgbStr(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
var cs = getComputedStyle(root);
var BLUE = hexToRgb(cs.getPropertyValue('--blue').trim());
var DONE = hexToRgb(cs.getPropertyValue('--done').trim());
function draw(sec) {
var angle = (sec * ROTATE_SPEED) % 360;
var p = (sec % LOOP) / LOOP;
var winStart = 1 - (AI_LOADER_HUE_TRANSITION_MS / 1000) / LOOP;
var mixT = p <= winStart ? 0 : smooth((p - winStart) / (1 - winStart));
var col = mix(BLUE, DONE, mixT);
var col2 = mix(BLUE, DONE, mixT * 0.6);
ring.style.setProperty('--angle', angle + 'deg');
ring.style.setProperty('--c1', rgbStr(col, 1));
ring.style.setProperty('--c2', rgbStr(col2, .35));
var corePulse = 1 + 0.08 * Math.sin(sec * Math.PI * 2 / LOOP * 3);
core.style.setProperty('--core-scale', corePulse.toFixed(3));
core.style.setProperty('--core-c', rgbStr(col, 1));
var pct = Math.round(p * 100);
pctEl.textContent = pct + '%';
}
function togglePause() {
paused = !paused;
root.classList.toggle('is-paused', paused);
}
function restart() {
t0 = performance.now();
draw(0);
}
function onKey(e) {
if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); togglePause(); }
else if (e.key === 'r' || e.key === 'R') { e.preventDefault(); restart(); }
}
field.addEventListener('click', togglePause);
field.addEventListener('keydown', onKey);
function onResize() {
if (paused || reduced()) draw((performance.now() - t0) / 1000);
}
window.addEventListener('resize', onResize);
function frame(now) {
if (!paused) draw((now - t0) / 1000);
raf = requestAnimationFrame(frame);
}
if (reduced()) {
root.classList.add('is-still');
draw(LOOP * 0.62);
} else {
raf = requestAnimationFrame(frame);
}
})();// HaloRing — vanilla 판(run/403/demo/01-ai-processing-loading-halo-ring-hue-shift/index.html) 의 React 포트.
// conic-gradient 링을 rAF 로 회전시키고, 진행 값이 전환 구간에 들면 링 색을 완료 색 쪽으로 섞는다.
// 움직임은 CSS 커스텀 속성 갱신이 전부 맡는다 — @keyframes 없음.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./HaloRing.module.scss";
export type HaloRingProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
const LOOP = 2.0;
const ROTATE_SPEED = 128;
const AI_LOADER_HUE_TRANSITION_MS = 640;
const FIELD_LABEL = "후광 링 색상 전환 실험판";
function hexToRgb(hex: string): [number, number, number] {
let h = hex.replace("#", "").trim();
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
const n = parseInt(h, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function mix(a: [number, number, number], b: [number, number, number], t: number): [number, number, number] {
return [
Math.round(a[0] + (b[0] - a[0]) * t),
Math.round(a[1] + (b[1] - a[1]) * t),
Math.round(a[2] + (b[2] - a[2]) * t),
];
}
function smooth(t: number): number { return t * t * (3 - 2 * t); }
function rgbStr(c: [number, number, number], a: number): string { return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")"; }
export default function HaloRing({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2f6df6",
}: HaloRingProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const ringRef = useRef<HTMLDivElement | null>(null);
const coreRef = useRef<HTMLDivElement | null>(null);
const pctRef = useRef<HTMLSpanElement | null>(null);
const pausedRef = useRef(false) as Box<boolean>;
const t0Ref = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const bluRef = useRef<[number, number, number]>([47, 109, 246]) as Box<[number, number, number]>;
const doneRef = useRef<[number, number, number]>([255, 210, 63]) as Box<[number, number, number]>;
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function draw(sec: number): void {
const ring = ringRef.current;
const core = coreRef.current;
const pctEl = pctRef.current;
if (!ring || !core) return;
const angle = (sec * ROTATE_SPEED) % 360;
const p = (sec % LOOP) / LOOP;
const winStart = 1 - (AI_LOADER_HUE_TRANSITION_MS / 1000) / LOOP;
const mixT = p <= winStart ? 0 : smooth((p - winStart) / (1 - winStart));
const col = mix(bluRef.current, doneRef.current, mixT);
const col2 = mix(bluRef.current, doneRef.current, mixT * 0.6);
ring.style.setProperty("--angle", angle + "deg");
ring.style.setProperty("--c1", rgbStr(col, 1));
ring.style.setProperty("--c2", rgbStr(col2, .35));
const corePulse = 1 + 0.08 * Math.sin((sec * Math.PI * 2 / LOOP) * 3);
core.style.setProperty("--core-scale", corePulse.toFixed(3));
core.style.setProperty("--core-c", rgbStr(col, 1));
if (pctEl) pctEl.textContent = Math.round(p * 100) + "%";
}
function togglePause(): void {
pausedRef.current = !pausedRef.current;
const root = rootRef.current;
if (root) root.classList.toggle("is-paused", pausedRef.current);
}
function restart(): void {
t0Ref.current = performance.now();
draw(0);
}
function onKey(e: KeyEvt): void {
if (e.key === " " || e.key === "Enter") { e.preventDefault(); togglePause(); }
else if (e.key === "r" || e.key === "R") { e.preventDefault(); restart(); }
}
function onClick(): void { togglePause(); }
function onResize(): void {
if (pausedRef.current || reduced()) draw((performance.now() - t0Ref.current) / 1000);
}
function frame(now: number): void {
if (!pausedRef.current) draw((now - t0Ref.current) / 1000);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const cs = getComputedStyle(root);
bluRef.current = hexToRgb(cs.getPropertyValue("--blue").trim() || "#2f6df6");
doneRef.current = hexToRgb(cs.getPropertyValue("--done").trim() || "#ffd23f");
t0Ref.current = performance.now();
window.addEventListener("resize", onResize);
if (reduced()) {
root.classList.add("is-still");
draw(LOOP * 0.62);
} 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 56v56a8 8 0 0 1-16 0V91.3l-25.6 25.7A80 80 0 1 1 174 60a8 8 0 1 1-9 13.2A64 64 0 1 0 179.7 128H216a8 8 0 0 1 0 16h-56a8 8 0 0 1-8-8V80a8 8 0 0 1 16 0v20.7L192.7 76A80.1 80.1 0 0 0 64 128a8 8 0 0 1-16 0a96 96 0 0 1 163.6-68.3L224 71.3V56a8 8 0 0 1 8 8" opacity=".2"/><path d="M224 48a8 8 0 0 0-8 8v15.3l-11.7-11.6a96 96 0 1 0 27.6 51.6a8 8 0 0 0-15.8 2.5a80 80 0 1 1-23-43l-25.7 25.7A8 8 0 0 0 173 112h56a8 8 0 0 0 8-8V56a8 8 0 0 0-13-8"/></g></svg>
<span>클릭하거나 스페이스바를 누르면 멈추거나 다시 갑니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onClick={onClick}
onKeyDown={onKey}
>
<div className={styles.ring} ref={ringRef}></div>
<div className={styles.core} ref={coreRef}></div>
<b className={styles.badge} aria-hidden="true"><span className={styles.badgeNum} ref={pctRef}>0%</span><span className={styles.badgeUnit}>진행</span></b>
</div>
</div>
</main>
);
}01 · 색상 값이 완료 쪽으로 도는 후광 링 AI 로딩 — 측정 누적면적 22.139% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function draw(sec) {
var angle = (sec * ROTATE_SPEED) % 360;
var p = (sec % LOOP) / LOOP;
var winStart = 1 - (AI_LOADER_HUE_TRANSITION_MS / 1000) / LOOP;
var mixT = p <= winStart ? 0 : smooth((p - winStart) / (1 - winStart));
var col = mix(BLUE, DONE, mixT);
var col2 = mix(BLUE, DONE, mixT * 0.6);
ring.style.setProperty('--angle', angle + 'deg');
ring.style.setProperty('--c1', rgbStr(col, 1));
ring.style.setProperty('--c2', rgbStr(col2, .35));
var corePulse = 1 + 0.08 * Math.sin(sec * Math.PI * 2 / LOOP * 3);
core.style.setProperty('--core-scale', corePulse.toFixed(3));
core.style.setProperty('--core-c', rgbStr(col, 1));
var pct = Math.round(p * 100);
pctEl.textContent = pct + '%';
}
02대기 시간 값이 늘어날수록 숨쉬듯 커지는 오브 펄스
대기 시간 값을 사인 이징 곡선에 넣어 중심 오브의 크기와 블러 값을 천천히 키웠다 줄였다 반복시키고, 값이 오래 쌓일수록 숨쉬는 폭 자체가 조금씩 커집니다. 숨쉬는 폭은 사인 곡선 하나에 이징을 얹은 값이라, 대기 시간이 늘어도 오브가 갑자기 튀지 않고 폭만 서서히 커집니다.

<main class="stage">
<div class="ld" id="ld">
<p class="ld__hint">
<svg class="ld__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M232 56v56a8 8 0 0 1-16 0V91.3l-25.6 25.7A80 80 0 1 1 174 60a8 8 0 1 1-9 13.2A64 64 0 1 0 179.7 128H216a8 8 0 0 1 0 16h-56a8 8 0 0 1-8-8V80a8 8 0 0 1 16 0v20.7L192.7 76A80.1 80.1 0 0 0 64 128a8 8 0 0 1-16 0a96 96 0 0 1 163.6-68.3L224 71.3V56a8 8 0 0 1 8 8" opacity=".2"/><path d="M224 48a8 8 0 0 0-8 8v15.3l-11.7-11.6a96 96 0 1 0 27.6 51.6a8 8 0 0 0-15.8 2.5a80 80 0 1 1-23-43l-25.7 25.7A8 8 0 0 0 173 112h56a8 8 0 0 0 8-8V56a8 8 0 0 0-13-8"/></g></svg>
<span>클릭하거나 스페이스바를 누르면 멈추거나 다시 갑니다</span>
</p>
<div class="ld__field" id="field" role="application" aria-label="오브 숨쉬기 펄스 실험판" tabindex="0">
<div class="ld__orb" id="orb"></div>
<b class="ld__badge" aria-hidden="true"><span class="ld__badgeNum" id="pct">0%</span><span class="ld__badgeUnit">대기</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// orb-morph-pulse — 대기 시간 값을 사인 이징에 넣어 중심 오브 크기·블러를 숨쉬듯 반복시키고,
// 대기 시간 값이 쌓일수록(accum) 숨쉬는 폭(amp) 자체가 조금씩 커진다. @keyframes 없음 — rAF 전용.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-yellow);
.ld {
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.ld__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;
}
.ld__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-blue-deep; }
.ld__field {
position: relative; height: 208px;
display: flex; align-items: center; justify-content: center;
border-radius: $r-card; overflow: hidden;
background: rgba($subject-ink, .06);
box-shadow: inset 0 0 0 1px rgba($subject-ink, .16);
cursor: pointer;
}
.ld__field:focus-visible { outline: 2px solid $subject-blue-deep; outline-offset: 2px; }
.ld__orb {
--scale: 1; --blur: 12px; --glow: .35;
width: 108px; height: 108px; border-radius: $r-pill;
background: radial-gradient(circle at 38% 34%, $subject-blue-light, $subject-blue-deep 70%);
transform: scale(var(--scale));
filter: blur(calc(var(--blur) * .18));
box-shadow: 0 0 42px calc(var(--glow) * 60px) rgba($subject-blue-deep, var(--glow));
}
.ld__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, .82);
color: $subject-cream; font-weight: 700; font-size: 11px;
}
.ld__badgeNum { font-size: 13px; color: $stage-yellow; }
.ld__badgeUnit { font-weight: 700; }
@media (max-width: 340px) {
.ld { gap: $sp-1; }
.ld__field { height: 124px; }
.ld__orb { width: 64px; height: 64px; }
.ld__badge { font-size: 10px; padding: 0 $sp-1; }
.ld__badgeNum { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.ld.is-still .ld__field { cursor: default !important; }
.ld.is-still .ld__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);
}
.ld {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.ld__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;
}
.ld__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #0a4fe8;
}
.ld__field {
position: relative;
height: 208px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 16px;
overflow: hidden;
background: rgba(23, 20, 26, 0.06);
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.16);
cursor: pointer;
}
.ld__field:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
.ld__orb {
--scale: 1;
--blur: 12px;
--glow: .35;
width: 108px;
height: 108px;
border-radius: 999px;
background: radial-gradient(circle at 38% 34%, #5b8cf8, #0a4fe8 70%);
transform: scale(var(--scale));
filter: blur(calc(var(--blur) * 0.18));
box-shadow: 0 0 42px calc(var(--glow) * 60px) rgba(10, 79, 232, var(--glow));
}
.ld__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.82);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
.ld__badgeNum {
font-size: 13px;
color: #ffd23f;
}
.ld__badgeUnit {
font-weight: 700;
}
@media (max-width: 340px) {
.ld {
gap: 4px;
}
.ld__field {
height: 124px;
}
.ld__orb {
width: 64px;
height: 64px;
}
.ld__badge {
font-size: 10px;
padding: 0 4px;
}
.ld__badgeNum {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.ld.is-still .ld__field {
cursor: default !important;
}
.ld.is-still .ld__badge {
opacity: 0.92 !important;
}
}(function () {
var LOOP = 2.0;
var BREATH_HZ = 0.9; // 숨쉬기 사인 진동수
var AMP_BASE = 0.10; // 숨쉬는 폭 시작 값
var AMP_GROW = 0.09; // 대기 시간 값이 쌓이며 늘어나는 폭
var root = document.getElementById('ld');
var field = document.getElementById('field');
var orb = document.getElementById('orb');
var pctEl = document.getElementById('pct');
var paused = false;
var t0 = performance.now();
var raf = 0;
function reduced() {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
function easeSine(t) { return 0.5 - 0.5 * Math.cos(t * Math.PI); }
function draw(sec) {
var t = sec % LOOP;
var accum = Math.min(sec, LOOP) / LOOP; // 대기 시간 값이 쌓인 정도(0~1)
var amp = AMP_BASE + AMP_GROW * easeSine(accum);
var wave = Math.sin(t * Math.PI * 2 * BREATH_HZ);
var scale = 1 + amp * wave;
var blur = 12 + 9 * Math.max(0, wave);
var glow = 0.35 + 0.25 * Math.max(0, wave);
orb.style.setProperty('--scale', scale.toFixed(4));
orb.style.setProperty('--blur', blur.toFixed(1) + 'px');
orb.style.setProperty('--glow', glow.toFixed(3));
var pct = Math.round((t / LOOP) * 100);
pctEl.textContent = pct + '%';
}
function togglePause() {
paused = !paused;
root.classList.toggle('is-paused', paused);
}
function restart() { t0 = performance.now(); draw(0); }
function onKey(e) {
if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); togglePause(); }
else if (e.key === 'r' || e.key === 'R') { e.preventDefault(); restart(); }
}
field.addEventListener('click', togglePause);
field.addEventListener('keydown', onKey);
function onResize() {
if (paused || reduced()) draw((performance.now() - t0) / 1000);
}
window.addEventListener('resize', onResize);
function frame(now) {
if (!paused) draw((now - t0) / 1000);
raf = requestAnimationFrame(frame);
}
if (reduced()) {
root.classList.add('is-still');
draw(LOOP * 0.62);
} else {
raf = requestAnimationFrame(frame);
}
})();// OrbPulse — vanilla 판(run/403/demo/02-ai-processing-loading-orb-morph-pulse/index.html) 의 React 포트.
// 대기 시간 값을 사인 이징에 넣어 오브 크기·블러를 숨쉬듯 반복시키고, 대기 시간이 쌓일수록 폭이 커진다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./OrbPulse.module.scss";
export type OrbPulseProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
const LOOP = 2.0;
const BREATH_HZ = 0.9;
const AMP_BASE = 0.10;
const AMP_GROW = 0.09;
const FIELD_LABEL = "오브 숨쉬기 펄스 실험판";
function easeSine(t: number): number { return 0.5 - 0.5 * Math.cos(t * Math.PI); }
export default function OrbPulse({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#17141a",
}: OrbPulseProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const orbRef = useRef<HTMLDivElement | null>(null);
const pctRef = useRef<HTMLSpanElement | null>(null);
const pausedRef = useRef(false) as Box<boolean>;
const t0Ref = 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 draw(sec: number): void {
const orb = orbRef.current;
const pctEl = pctRef.current;
if (!orb) return;
const t = sec % LOOP;
const accum = Math.min(sec, LOOP) / LOOP;
const amp = AMP_BASE + AMP_GROW * easeSine(accum);
const wave = Math.sin(t * Math.PI * 2 * BREATH_HZ);
const scale = 1 + amp * wave;
const blur = 12 + 9 * Math.max(0, wave);
const glow = 0.35 + 0.25 * Math.max(0, wave);
orb.style.setProperty("--scale", scale.toFixed(4));
orb.style.setProperty("--blur", blur.toFixed(1) + "px");
orb.style.setProperty("--glow", glow.toFixed(3));
if (pctEl) pctEl.textContent = Math.round((t / LOOP) * 100) + "%";
}
function togglePause(): void {
pausedRef.current = !pausedRef.current;
const root = rootRef.current;
if (root) root.classList.toggle("is-paused", pausedRef.current);
}
function restart(): void { t0Ref.current = performance.now(); draw(0); }
function onKey(e: KeyEvt): void {
if (e.key === " " || e.key === "Enter") { e.preventDefault(); togglePause(); }
else if (e.key === "r" || e.key === "R") { e.preventDefault(); restart(); }
}
function onClick(): void { togglePause(); }
function onResize(): void {
if (pausedRef.current || reduced()) draw((performance.now() - t0Ref.current) / 1000);
}
function frame(now: number): void {
if (!pausedRef.current) draw((now - t0Ref.current) / 1000);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
t0Ref.current = performance.now();
window.addEventListener("resize", onResize);
if (reduced()) {
root.classList.add("is-still");
draw(LOOP * 0.62);
} 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 56v56a8 8 0 0 1-16 0V91.3l-25.6 25.7A80 80 0 1 1 174 60a8 8 0 1 1-9 13.2A64 64 0 1 0 179.7 128H216a8 8 0 0 1 0 16h-56a8 8 0 0 1-8-8V80a8 8 0 0 1 16 0v20.7L192.7 76A80.1 80.1 0 0 0 64 128a8 8 0 0 1-16 0a96 96 0 0 1 163.6-68.3L224 71.3V56a8 8 0 0 1 8 8" opacity=".2"/><path d="M224 48a8 8 0 0 0-8 8v15.3l-11.7-11.6a96 96 0 1 0 27.6 51.6a8 8 0 0 0-15.8 2.5a80 80 0 1 1-23-43l-25.7 25.7A8 8 0 0 0 173 112h56a8 8 0 0 0 8-8V56a8 8 0 0 0-13-8"/></g></svg>
<span>클릭하거나 스페이스바를 누르면 멈추거나 다시 갑니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onClick={onClick}
onKeyDown={onKey}
>
<div className={styles.orb} ref={orbRef}></div>
<b className={styles.badge} aria-hidden="true"><span className={styles.badgeNum} ref={pctRef}>0%</span><span className={styles.badgeUnit}>대기</span></b>
</div>
</div>
</main>
);
}02 · 대기 시간 값이 늘어날수록 숨쉬듯 커지는 오브 펄스 — 측정 누적면적 30.089% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function draw(sec) {
var t = sec % LOOP;
var accum = Math.min(sec, LOOP) / LOOP; // 대기 시간 값이 쌓인 정도(0~1)
var amp = AMP_BASE + AMP_GROW * easeSine(accum);
var wave = Math.sin(t * Math.PI * 2 * BREATH_HZ);
var scale = 1 + amp * wave;
var blur = 12 + 9 * Math.max(0, wave);
var glow = 0.35 + 0.25 * Math.max(0, wave);
orb.style.setProperty('--scale', scale.toFixed(4));
orb.style.setProperty('--blur', blur.toFixed(1) + 'px');
orb.style.setProperty('--glow', glow.toFixed(3));
var pct = Math.round((t / LOOP) * 100);
pctEl.textContent = pct + '%';
}
03궤도 반경 값이 좁아지며 코어에 모이는 입자 소용돌이
코어 둘레를 도는 입자마다 자신만의 궤도 반경 값을 갖고, 처리 진행 값이 오를수록 그 반경 값이 함께 줄어들어 입자들이 점점 코어 쪽으로 모여듭니다. 궤도 반경은 이징 지수 하나로만 줄어들기 때문에, AI_LOADER_ORBIT_SHRINK_EASE 값을 바꾸면 입자가 모이는 리듬 전체가 달라집니다.

<main class="stage">
<div class="ld" id="ld">
<p class="ld__hint">
<svg class="ld__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M232 56v56a8 8 0 0 1-16 0V91.3l-25.6 25.7A80 80 0 1 1 174 60a8 8 0 1 1-9 13.2A64 64 0 1 0 179.7 128H216a8 8 0 0 1 0 16h-56a8 8 0 0 1-8-8V80a8 8 0 0 1 16 0v20.7L192.7 76A80.1 80.1 0 0 0 64 128a8 8 0 0 1-16 0a96 96 0 0 1 163.6-68.3L224 71.3V56a8 8 0 0 1 8 8" opacity=".2"/><path d="M224 48a8 8 0 0 0-8 8v15.3l-11.7-11.6a96 96 0 1 0 27.6 51.6a8 8 0 0 0-15.8 2.5a80 80 0 1 1-23-43l-25.7 25.7A8 8 0 0 0 173 112h56a8 8 0 0 0 8-8V56a8 8 0 0 0-13-8"/></g></svg>
<span>클릭하거나 스페이스바를 누르면 멈추거나 다시 갑니다</span>
</p>
<div class="ld__field" id="field" role="application" aria-label="입자 소용돌이 코어 수렴 실험판" tabindex="0">
<canvas class="ld__cv" id="cv"></canvas>
<b class="ld__badge" aria-hidden="true"><span class="ld__badgeNum" id="pct">0%</span><span class="ld__badgeUnit">진행</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// particle-swirl-core — 입자마다 자신만의 궤도 반경 값을 갖고, 처리 진행 값이 오를수록
// AI_LOADER_ORBIT_SHRINK_EASE 이징으로 그 반경이 함께 줄어 코어 쪽으로 모여든다. 캔버스 rAF 전용.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-orange);
.ld {
--paper: #{$subject-orange-deep};
--trail: #{rgba($subject-orange-deep, .32)};
--core: #{$subject-cream};
--dot: #{$subject-cream};
--dot-deep: #{rgba($subject-cream, .55)};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.ld__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;
}
.ld__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-cream; }
.ld__field {
position: relative; height: 208px;
border-radius: $r-card; overflow: hidden;
background: $subject-orange-deep;
box-shadow: inset 0 0 0 1px rgba($subject-cream, .18);
cursor: pointer;
}
.ld__field:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
.ld__cv { display: block; width: 100%; height: 100%; }
.ld__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, .5);
color: $subject-cream; font-weight: 700; font-size: 11px;
}
.ld__badgeNum { font-size: 13px; color: $stage-yellow; }
.ld__badgeUnit { font-weight: 700; }
@media (max-width: 340px) {
.ld { gap: $sp-1; }
.ld__field { height: 124px; }
.ld__badge { font-size: 10px; padding: 0 $sp-1; }
.ld__badgeNum { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.ld.is-still .ld__field { cursor: default !important; }
.ld.is-still .ld__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: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ld {
--paper: #c93000;
--trail: rgba(201, 48, 0, 0.32);
--core: #fff7e6;
--dot: #fff7e6;
--dot-deep: rgba(255, 247, 230, 0.55);
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.ld__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;
}
.ld__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #fff7e6;
}
.ld__field {
position: relative;
height: 208px;
border-radius: 16px;
overflow: hidden;
background: #c93000;
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.18);
cursor: pointer;
}
.ld__field:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.ld__cv {
display: block;
width: 100%;
height: 100%;
}
.ld__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.5);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
.ld__badgeNum {
font-size: 13px;
color: #ffd23f;
}
.ld__badgeUnit {
font-weight: 700;
}
@media (max-width: 340px) {
.ld {
gap: 4px;
}
.ld__field {
height: 124px;
}
.ld__badge {
font-size: 10px;
padding: 0 4px;
}
.ld__badgeNum {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.ld.is-still .ld__field {
cursor: default !important;
}
.ld.is-still .ld__badge {
opacity: 0.92 !important;
}
}(function () {
var LOOP = 2.0;
var N = 26; // 입자 수
var GOLDEN = 137.508 * Math.PI / 180; // 입자 초기 각도 간격
var CORE_R = 14; // 코어 반경(px)
var MAX_R = 128; // 궤도 반경 값 최대(px)
var AI_LOADER_ORBIT_SHRINK_EASE = 2.2; // 입자 궤도 반경 축소 이징 값(전)
var root = document.getElementById('ld');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d');
var pctEl = document.getElementById('pct');
var paused = false;
var t0 = performance.now();
var raf = 0;
var W = 0, H = 0, DPR = 1;
function reduced() {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
function seeded(i) { // 고정 시드 의사난수(Math.random 미사용)
var v = Math.sin(i * 12.9898) * 43758.5453;
return v - Math.floor(v);
}
var particles = [];
for (var i = 0; i < N; i++) {
particles.push({
baseAngle: i * GOLDEN,
jitter: 0.7 + seeded(i) * 0.5,
speed: 0.55 + seeded(i + 31) * 0.5,
size: 4.2 + seeded(i + 71) * 3.4
});
}
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);
}
var cs = getComputedStyle(root);
var PAPER = cs.getPropertyValue('--paper').trim();
var TRAIL = cs.getPropertyValue('--trail').trim();
var CORE = cs.getPropertyValue('--core').trim();
var DOT = cs.getPropertyValue('--dot').trim();
var DOT_DEEP = cs.getPropertyValue('--dot-deep').trim();
function draw(sec) {
ctx.fillStyle = TRAIL;
ctx.fillRect(0, 0, W, H);
var cx = W / 2, cy = H / 2;
var p = (sec % LOOP) / LOOP;
var shrink = Math.pow(p, 1 / AI_LOADER_ORBIT_SHRINK_EASE); // 궤도 반경 값 축소 이징
for (var i = 0; i < particles.length; i++) {
var q = particles[i];
var angle = q.baseAngle + sec * q.speed;
var r = CORE_R + (MAX_R - CORE_R) * (1 - shrink) * q.jitter;
var x = cx + r * Math.cos(angle);
var y = cy + r * Math.sin(angle) * 0.86;
ctx.beginPath();
ctx.arc(x, y, q.size, 0, Math.PI * 2);
ctx.fillStyle = i % 2 === 0 ? DOT : DOT_DEEP;
ctx.shadowColor = DOT;
ctx.shadowBlur = 10;
ctx.fill();
ctx.shadowBlur = 0;
}
var coreR = CORE_R * (0.85 + 0.3 * (1 - shrink));
ctx.beginPath();
ctx.arc(cx, cy, coreR, 0, Math.PI * 2);
ctx.fillStyle = CORE;
ctx.fill();
var pct = Math.round(p * 100);
pctEl.textContent = pct + '%';
}
function togglePause() {
paused = !paused;
root.classList.toggle('is-paused', paused);
}
function restart() { t0 = performance.now(); draw(0); }
function onKey(e) {
if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); togglePause(); }
else if (e.key === 'r' || e.key === 'R') { e.preventDefault(); restart(); }
}
field.addEventListener('click', togglePause);
field.addEventListener('keydown', onKey);
function onResize() {
fit();
if (paused || reduced()) draw((performance.now() - t0) / 1000);
}
window.addEventListener('resize', onResize);
function frame(now) {
if (!paused) draw((now - t0) / 1000);
raf = requestAnimationFrame(frame);
}
fit();
if (reduced()) {
root.classList.add('is-still');
draw(LOOP * 0.62);
} else {
raf = requestAnimationFrame(frame);
}
})();// ParticleSwirl — vanilla 판(run/403/demo/03-ai-processing-loading-particle-swirl-core/index.html) 의 React 포트.
// 입자마다 자신만의 궤도 반경 값을 갖고, 처리 진행 값이 오를수록 AI_LOADER_ORBIT_SHRINK_EASE 이징으로
// 그 반경이 함께 줄어 코어 쪽으로 모여든다. 캔버스 rAF 전용 — @keyframes 없음.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./ParticleSwirl.module.scss";
export type ParticleSwirlProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
type Particle = { baseAngle: number; jitter: number; speed: number; size: number };
const LOOP = 2.0;
const N = 26;
const GOLDEN = 137.508 * Math.PI / 180;
const CORE_R = 14;
const MAX_R = 128;
const AI_LOADER_ORBIT_SHRINK_EASE = 2.2;
const FIELD_LABEL = "입자 소용돌이 코어 수렴 실험판";
function seeded(i: number): number {
const v = Math.sin(i * 12.9898) * 43758.5453;
return v - Math.floor(v);
}
const PARTICLES: Particle[] = (() => {
const arr: Particle[] = [];
for (let i = 0; i < N; i++) {
arr.push({
baseAngle: i * GOLDEN,
jitter: 0.7 + seeded(i) * 0.5,
speed: 0.55 + seeded(i + 31) * 0.5,
size: 4.2 + seeded(i + 71) * 3.4,
});
}
return arr;
})();
export default function ParticleSwirl({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#fff7e6",
}: ParticleSwirlProps) {
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 pctRef = useRef<HTMLSpanElement | null>(null);
const sizeRef = useRef({ w: 0, h: 0, dpr: 1 }) as Box<{ w: number; h: number; dpr: number }>;
const pausedRef = useRef(false) as Box<boolean>;
const t0Ref = 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 field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.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);
const ctx = ctxRef.current;
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function draw(sec: number): void {
const ctx = ctxRef.current;
const root = rootRef.current;
const pctEl = pctRef.current;
if (!ctx || !root) return;
const W = sizeRef.current.w, H = sizeRef.current.h;
const cssv = (n: string) => getComputedStyle(root).getPropertyValue(n).trim();
const TRAIL = cssv("--trail"), CORE = cssv("--core"), DOT = cssv("--dot"), DOT_DEEP = cssv("--dot-deep");
ctx.fillStyle = TRAIL;
ctx.fillRect(0, 0, W, H);
const cx = W / 2, cy = H / 2;
const p = (sec % LOOP) / LOOP;
const shrink = Math.pow(p, 1 / AI_LOADER_ORBIT_SHRINK_EASE);
for (let i = 0; i < PARTICLES.length; i++) {
const q = PARTICLES[i];
const angle = q.baseAngle + sec * q.speed;
const r = CORE_R + (MAX_R - CORE_R) * (1 - shrink) * q.jitter;
const x = cx + r * Math.cos(angle);
const y = cy + r * Math.sin(angle) * 0.86;
ctx.beginPath();
ctx.arc(x, y, q.size, 0, Math.PI * 2);
ctx.fillStyle = i % 2 === 0 ? DOT : DOT_DEEP;
ctx.shadowColor = DOT;
ctx.shadowBlur = 10;
ctx.fill();
ctx.shadowBlur = 0;
}
const coreR = CORE_R * (0.85 + 0.3 * (1 - shrink));
ctx.beginPath();
ctx.arc(cx, cy, coreR, 0, Math.PI * 2);
ctx.fillStyle = CORE;
ctx.fill();
if (pctEl) pctEl.textContent = Math.round(p * 100) + "%";
}
function togglePause(): void {
pausedRef.current = !pausedRef.current;
const root = rootRef.current;
if (root) root.classList.toggle("is-paused", pausedRef.current);
}
function restart(): void { t0Ref.current = performance.now(); draw(0); }
function onKey(e: KeyEvt): void {
if (e.key === " " || e.key === "Enter") { e.preventDefault(); togglePause(); }
else if (e.key === "r" || e.key === "R") { e.preventDefault(); restart(); }
}
function onClick(): void { togglePause(); }
function onResize(): void {
fit();
if (pausedRef.current || reduced()) draw((performance.now() - t0Ref.current) / 1000);
}
function frame(now: number): void {
if (!pausedRef.current) draw((now - t0Ref.current) / 1000);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const cv = cvRef.current;
if (!cv) return;
ctxRef.current = cv.getContext("2d");
if (!ctxRef.current) return;
t0Ref.current = performance.now();
fit();
window.addEventListener("resize", onResize);
if (reduced()) {
const root = rootRef.current;
if (root) root.classList.add("is-still");
draw(LOOP * 0.62);
} 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 56v56a8 8 0 0 1-16 0V91.3l-25.6 25.7A80 80 0 1 1 174 60a8 8 0 1 1-9 13.2A64 64 0 1 0 179.7 128H216a8 8 0 0 1 0 16h-56a8 8 0 0 1-8-8V80a8 8 0 0 1 16 0v20.7L192.7 76A80.1 80.1 0 0 0 64 128a8 8 0 0 1-16 0a96 96 0 0 1 163.6-68.3L224 71.3V56a8 8 0 0 1 8 8" opacity=".2"/><path d="M224 48a8 8 0 0 0-8 8v15.3l-11.7-11.6a96 96 0 1 0 27.6 51.6a8 8 0 0 0-15.8 2.5a80 80 0 1 1-23-43l-25.7 25.7A8 8 0 0 0 173 112h56a8 8 0 0 0 8-8V56a8 8 0 0 0-13-8"/></g></svg>
<span>클릭하거나 스페이스바를 누르면 멈추거나 다시 갑니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onClick={onClick}
onKeyDown={onKey}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
<b className={styles.badge} aria-hidden="true"><span className={styles.badgeNum} ref={pctRef}>0%</span><span className={styles.badgeUnit}>진행</span></b>
</div>
</div>
</main>
);
}03 · 궤도 반경 값이 좁아지며 코어에 모이는 입자 소용돌이 — 측정 누적면적 25.456% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
ctx.fillStyle = TRAIL;
ctx.fillRect(0, 0, W, H);
var cx = W / 2, cy = H / 2;
var p = (sec % LOOP) / LOOP;
var shrink = Math.pow(p, 1 / AI_LOADER_ORBIT_SHRINK_EASE); // 궤도 반경 값 축소 이징
for (var i = 0; i < particles.length; i++) {
var q = particles[i];
var angle = q.baseAngle + sec * q.speed;
var r = CORE_R + (MAX_R - CORE_R) * (1 - shrink) * q.jitter;
var x = cx + r * Math.cos(angle);
var y = cy + r * Math.sin(angle) * 0.86;
ctx.beginPath();
ctx.arc(x, y, q.size, 0, Math.PI * 2);
ctx.fillStyle = i % 2 === 0 ? DOT : DOT_DEEP;
ctx.shadowColor = DOT;
ctx.shadowBlur = 10;
ctx.fill();
ctx.shadowBlur = 0;
}
var coreR = CORE_R * (0.85 + 0.3 * (1 - shrink));
04지연 값을 따라 마디를 건너가는 신경망 펄스
작은 노드와 선으로 만든 격자 위에서, 각 선마다 다른 지연(delay) 값을 줘 빛 신호가 마디에서 마디로 순서대로 건너가듯 퍼지게 합니다. 선마다 지연 값이 다르기 때문에 신호가 왼쪽 위에서 오른쪽 아래로 퍼지는 것처럼 보이지만, 실제로는 좌표 합만으로 지연 순서를 정한 값 계산입니다.

<main class="stage">
<div class="ld" id="ld">
<p class="ld__hint">
<svg class="ld__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M232 56v56a8 8 0 0 1-16 0V91.3l-25.6 25.7A80 80 0 1 1 174 60a8 8 0 1 1-9 13.2A64 64 0 1 0 179.7 128H216a8 8 0 0 1 0 16h-56a8 8 0 0 1-8-8V80a8 8 0 0 1 16 0v20.7L192.7 76A80.1 80.1 0 0 0 64 128a8 8 0 0 1-16 0a96 96 0 0 1 163.6-68.3L224 71.3V56a8 8 0 0 1 8 8" opacity=".2"/><path d="M224 48a8 8 0 0 0-8 8v15.3l-11.7-11.6a96 96 0 1 0 27.6 51.6a8 8 0 0 0-15.8 2.5a80 80 0 1 1-23-43l-25.7 25.7A8 8 0 0 0 173 112h56a8 8 0 0 0 8-8V56a8 8 0 0 0-13-8"/></g></svg>
<span>클릭하거나 스페이스바를 누르면 멈추거나 다시 갑니다</span>
</p>
<div class="ld__field" id="field" role="application" aria-label="신경망 마디 펄스 실험판" tabindex="0">
<canvas class="ld__cv" id="cv"></canvas>
<b class="ld__badge" aria-hidden="true"><span class="ld__badgeNum" id="pct">0%</span><span class="ld__badgeUnit">전파</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// neural-node-pulse — 각 선마다 다른 지연(delay) 값을 줘 빛 신호가 마디에서 마디로
// 순서대로 건너가듯 퍼진다. 격자 거리 기반 지연 계산 + 캔버스 rAF — @keyframes 없음.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.ld {
--paper: #{$stage-paper};
--line-dim: #{rgba($subject-ink, .16)};
--line-lit: #{$subject-blue-deep};
--node-dim: #{rgba($subject-ink, .28)};
--node-lit: #{$subject-blue-deep};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.ld__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;
}
.ld__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-blue-deep; }
.ld__field {
position: relative; height: 208px;
border-radius: $r-card; overflow: hidden;
background: $stage-paper;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .14);
cursor: pointer;
}
.ld__field:focus-visible { outline: 2px solid $subject-blue-deep; outline-offset: 2px; }
.ld__cv { display: block; width: 100%; height: 100%; }
.ld__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, .82);
color: $subject-cream; font-weight: 700; font-size: 11px;
}
.ld__badgeNum { font-size: 13px; color: $stage-yellow; }
.ld__badgeUnit { font-weight: 700; }
@media (max-width: 340px) {
.ld { gap: $sp-1; }
.ld__field { height: 124px; }
.ld__badge { font-size: 10px; padding: 0 $sp-1; }
.ld__badgeNum { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.ld.is-still .ld__field { cursor: default !important; }
.ld.is-still .ld__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: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ld {
--paper: #ffffff;
--line-dim: rgba(23, 20, 26, 0.16);
--line-lit: #0a4fe8;
--node-dim: rgba(23, 20, 26, 0.28);
--node-lit: #0a4fe8;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.ld__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;
}
.ld__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #0a4fe8;
}
.ld__field {
position: relative;
height: 208px;
border-radius: 16px;
overflow: hidden;
background: #ffffff;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.14);
cursor: pointer;
}
.ld__field:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
.ld__cv {
display: block;
width: 100%;
height: 100%;
}
.ld__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.82);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
.ld__badgeNum {
font-size: 13px;
color: #ffd23f;
}
.ld__badgeUnit {
font-weight: 700;
}
@media (max-width: 340px) {
.ld {
gap: 4px;
}
.ld__field {
height: 124px;
}
.ld__badge {
font-size: 10px;
padding: 0 4px;
}
.ld__badgeNum {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.ld.is-still .ld__field {
cursor: default !important;
}
.ld.is-still .ld__badge {
opacity: 0.92 !important;
}
}(function () {
var LOOP = 2.0;
var COLS = 4, ROWS = 3;
var STEP = 0.22; // 마디 지연(delay) 값 1칸 간격(초)
var EDGE_MS = 0.34; // 선 하나가 밝아지는 데 걸리는 시간(초)
var PULSE_W = 0.30; // 마디 펄스 폭(초)
var root = document.getElementById('ld');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d');
var pctEl = document.getElementById('pct');
var paused = false;
var t0 = performance.now();
var raf = 0;
var W = 0, H = 0, DPR = 1;
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function seeded(i) { var v = Math.sin(i * 12.9898) * 43758.5453; return v - Math.floor(v); }
function clamp01(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
var nodes = [];
for (var r = 0; r < ROWS; r++) {
for (var c = 0; c < COLS; c++) {
var dist = r + c; // 원점에서 이 마디까지 격자 거리
nodes.push({ r: r, c: c, delay: dist * STEP + seeded(r * COLS + c) * 0.04 });
}
}
function idx(r, c) { return r * COLS + c; }
var edges = [];
for (var r2 = 0; r2 < ROWS; r2++) {
for (var c2 = 0; c2 < COLS; c2++) {
if (c2 < COLS - 1) edges.push({ a: idx(r2, c2), b: idx(r2, c2 + 1) });
if (r2 < ROWS - 1) edges.push({ a: idx(r2, c2), b: idx(r2 + 1, c2) });
}
}
for (var e = 0; e < edges.length; e++) {
var ea = nodes[edges[e].a], eb = nodes[edges[e].b];
edges[e].delay = Math.min(ea.delay, eb.delay);
}
var maxDelay = 0;
for (var m = 0; m < nodes.length; m++) if (nodes[m].delay > maxDelay) maxDelay = nodes[m].delay;
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);
}
function pos(n) {
var padX = W * 0.14, padY = H * 0.18;
return {
x: padX + (W - padX * 2) * (n.c / (COLS - 1)),
y: padY + (H - padY * 2) * (n.r / (ROWS - 1))
};
}
var cs = getComputedStyle(root);
var PAPER = cs.getPropertyValue('--paper').trim();
var LINE_DIM = cs.getPropertyValue('--line-dim').trim();
var LINE_LIT = cs.getPropertyValue('--line-lit').trim();
var NODE_DIM = cs.getPropertyValue('--node-dim').trim();
var NODE_LIT = cs.getPropertyValue('--node-lit').trim();
function draw(sec) {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = PAPER;
ctx.fillRect(0, 0, W, H);
var t = sec % LOOP;
for (var i = 0; i < edges.length; i++) {
var ed = edges[i];
var pa = pos(nodes[ed.a]), pb = pos(nodes[ed.b]);
ctx.beginPath();
ctx.moveTo(pa.x, pa.y);
ctx.lineTo(pb.x, pb.y);
ctx.strokeStyle = LINE_DIM;
ctx.lineWidth = 2;
ctx.stroke();
var local = t - ed.delay;
if (local > 0) {
var frac = clamp01(local / EDGE_MS);
ctx.beginPath();
ctx.moveTo(pa.x, pa.y);
ctx.lineTo(pa.x + (pb.x - pa.x) * frac, pa.y + (pb.y - pa.y) * frac);
ctx.strokeStyle = LINE_LIT;
ctx.lineWidth = 3;
ctx.stroke();
}
}
for (var j = 0; j < nodes.length; j++) {
var n = nodes[j];
var p = pos(n);
var localN = t - n.delay;
var glow = 0;
if (localN >= 0) {
glow = localN < PULSE_W ? (localN / PULSE_W) : Math.max(0.32, 1 - (localN - PULSE_W) / 0.7);
}
ctx.beginPath();
ctx.arc(p.x, p.y, 5 + glow * 5, 0, Math.PI * 2);
ctx.fillStyle = glow > 0 ? NODE_LIT : NODE_DIM;
if (glow > 0) { ctx.shadowColor = NODE_LIT; ctx.shadowBlur = 14 * glow; }
ctx.fill();
ctx.shadowBlur = 0;
}
var pct = Math.round(clamp01(t / (maxDelay + EDGE_MS)) * 100);
pctEl.textContent = pct + '%';
}
function togglePause() { paused = !paused; root.classList.toggle('is-paused', paused); }
function restart() { t0 = performance.now(); draw(0); }
function onKey(ev) {
if (ev.key === ' ' || ev.key === 'Enter') { ev.preventDefault(); togglePause(); }
else if (ev.key === 'r' || ev.key === 'R') { ev.preventDefault(); restart(); }
}
field.addEventListener('click', togglePause);
field.addEventListener('keydown', onKey);
function onResize() {
fit();
if (paused || reduced()) draw((performance.now() - t0) / 1000);
}
window.addEventListener('resize', onResize);
function frame(now) {
if (!paused) draw((now - t0) / 1000);
raf = requestAnimationFrame(frame);
}
fit();
if (reduced()) {
root.classList.add('is-still');
draw(LOOP * 0.62);
} else {
raf = requestAnimationFrame(frame);
}
})();// NeuralPulse — vanilla 판(run/403/demo/04-ai-processing-loading-neural-node-pulse/index.html) 의 React 포트.
// 각 선마다 다른 지연(delay) 값을 줘 빛 신호가 마디에서 마디로 순서대로 건너가듯 퍼진다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./NeuralPulse.module.scss";
export type NeuralPulseProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
type Node = { r: number; c: number; delay: number };
type Edge = { a: number; b: number; delay: number };
const LOOP = 2.0;
const COLS = 4, ROWS = 3;
const STEP = 0.22;
const EDGE_MS = 0.34;
const PULSE_W = 0.30;
const FIELD_LABEL = "신경망 마디 펄스 실험판";
function seeded(i: number): number { const v = Math.sin(i * 12.9898) * 43758.5453; return v - Math.floor(v); }
function clamp01(v: number): number { return v < 0 ? 0 : v > 1 ? 1 : v; }
function idx(r: number, c: number): number { return r * COLS + c; }
const NODES: Node[] = (() => {
const arr: Node[] = [];
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const dist = r + c;
arr.push({ r, c, delay: dist * STEP + seeded(r * COLS + c) * 0.04 });
}
}
return arr;
})();
const EDGES: Edge[] = (() => {
const arr: Edge[] = [];
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
if (c < COLS - 1) arr.push({ a: idx(r, c), b: idx(r, c + 1), delay: 0 });
if (r < ROWS - 1) arr.push({ a: idx(r, c), b: idx(r + 1, c), delay: 0 });
}
}
for (const ed of arr) {
ed.delay = Math.min(NODES[ed.a].delay, NODES[ed.b].delay);
}
return arr;
})();
let MAX_DELAY = 0;
for (const n of NODES) if (n.delay > MAX_DELAY) MAX_DELAY = n.delay;
export default function NeuralPulse({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#0a4fe8",
}: NeuralPulseProps) {
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 pctRef = useRef<HTMLSpanElement | null>(null);
const sizeRef = useRef({ w: 0, h: 0, dpr: 1 }) as Box<{ w: number; h: number; dpr: number }>;
const pausedRef = useRef(false) as Box<boolean>;
const t0Ref = 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 field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.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);
const ctx = ctxRef.current;
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function pos(n: Node): { x: number; y: number } {
const W = sizeRef.current.w, H = sizeRef.current.h;
const padX = W * 0.14, padY = H * 0.18;
return {
x: padX + (W - padX * 2) * (n.c / (COLS - 1)),
y: padY + (H - padY * 2) * (n.r / (ROWS - 1)),
};
}
function draw(sec: number): void {
const ctx = ctxRef.current;
const root = rootRef.current;
const pctEl = pctRef.current;
if (!ctx || !root) return;
const W = sizeRef.current.w, H = sizeRef.current.h;
const cssv = (n: string) => getComputedStyle(root).getPropertyValue(n).trim();
const PAPER = cssv("--paper"), LINE_DIM = cssv("--line-dim"), LINE_LIT = cssv("--line-lit");
const NODE_DIM = cssv("--node-dim"), NODE_LIT = cssv("--node-lit");
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = PAPER;
ctx.fillRect(0, 0, W, H);
const t = sec % LOOP;
for (const ed of EDGES) {
const pa = pos(NODES[ed.a]), pb = pos(NODES[ed.b]);
ctx.beginPath();
ctx.moveTo(pa.x, pa.y);
ctx.lineTo(pb.x, pb.y);
ctx.strokeStyle = LINE_DIM;
ctx.lineWidth = 2;
ctx.stroke();
const local = t - ed.delay;
if (local > 0) {
const frac = clamp01(local / EDGE_MS);
ctx.beginPath();
ctx.moveTo(pa.x, pa.y);
ctx.lineTo(pa.x + (pb.x - pa.x) * frac, pa.y + (pb.y - pa.y) * frac);
ctx.strokeStyle = LINE_LIT;
ctx.lineWidth = 3;
ctx.stroke();
}
}
for (const n of NODES) {
const p = pos(n);
const localN = t - n.delay;
let glow = 0;
if (localN >= 0) {
glow = localN < PULSE_W ? (localN / PULSE_W) : Math.max(0.32, 1 - (localN - PULSE_W) / 0.7);
}
ctx.beginPath();
ctx.arc(p.x, p.y, 5 + glow * 5, 0, Math.PI * 2);
ctx.fillStyle = glow > 0 ? NODE_LIT : NODE_DIM;
if (glow > 0) { ctx.shadowColor = NODE_LIT; ctx.shadowBlur = 14 * glow; }
ctx.fill();
ctx.shadowBlur = 0;
}
if (pctEl) pctEl.textContent = Math.round(clamp01(t / (MAX_DELAY + EDGE_MS)) * 100) + "%";
}
function togglePause(): void {
pausedRef.current = !pausedRef.current;
const root = rootRef.current;
if (root) root.classList.toggle("is-paused", pausedRef.current);
}
function restart(): void { t0Ref.current = performance.now(); draw(0); }
function onKey(e: KeyEvt): void {
if (e.key === " " || e.key === "Enter") { e.preventDefault(); togglePause(); }
else if (e.key === "r" || e.key === "R") { e.preventDefault(); restart(); }
}
function onClick(): void { togglePause(); }
function onResize(): void {
fit();
if (pausedRef.current || reduced()) draw((performance.now() - t0Ref.current) / 1000);
}
function frame(now: number): void {
if (!pausedRef.current) draw((now - t0Ref.current) / 1000);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const cv = cvRef.current;
if (!cv) return;
ctxRef.current = cv.getContext("2d");
if (!ctxRef.current) return;
t0Ref.current = performance.now();
fit();
window.addEventListener("resize", onResize);
if (reduced()) {
const root = rootRef.current;
if (root) root.classList.add("is-still");
draw(LOOP * 0.62);
} 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 56v56a8 8 0 0 1-16 0V91.3l-25.6 25.7A80 80 0 1 1 174 60a8 8 0 1 1-9 13.2A64 64 0 1 0 179.7 128H216a8 8 0 0 1 0 16h-56a8 8 0 0 1-8-8V80a8 8 0 0 1 16 0v20.7L192.7 76A80.1 80.1 0 0 0 64 128a8 8 0 0 1-16 0a96 96 0 0 1 163.6-68.3L224 71.3V56a8 8 0 0 1 8 8" opacity=".2"/><path d="M224 48a8 8 0 0 0-8 8v15.3l-11.7-11.6a96 96 0 1 0 27.6 51.6a8 8 0 0 0-15.8 2.5a80 80 0 1 1-23-43l-25.7 25.7A8 8 0 0 0 173 112h56a8 8 0 0 0 8-8V56a8 8 0 0 0-13-8"/></g></svg>
<span>클릭하거나 스페이스바를 누르면 멈추거나 다시 갑니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onClick={onClick}
onKeyDown={onKey}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
<b className={styles.badge} aria-hidden="true"><span className={styles.badgeNum} ref={pctRef}>0%</span><span className={styles.badgeUnit}>전파</span></b>
</div>
</div>
</main>
);
}04 · 지연 값을 따라 마디를 건너가는 신경망 펄스 — 측정 누적면적 11.583% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
var nodes = [];
for (var r = 0; r < ROWS; r++) {
for (var c = 0; c < COLS; c++) {
var dist = r + c; // 원점에서 이 마디까지 격자 거리
nodes.push({ r: r, c: c, delay: dist * STEP + seeded(r * COLS + c) * 0.04 });
}
}
function idx(r, c) { return r * COLS + c; }
var edges = [];
for (var r2 = 0; r2 < ROWS; r2++) {
for (var c2 = 0; c2 < COLS; c2++) {
if (c2 < COLS - 1) edges.push({ a: idx(r2, c2), b: idx(r2, c2 + 1) });
if (r2 < ROWS - 1) edges.push({ a: idx(r2, c2), b: idx(r2 + 1, c2) });
}
}
for (var e = 0; e < edges.length; e++) {
var ea = nodes[edges[e].a], eb = nodes[edges[e].b];
edges[e].delay = Math.min(ea.delay, eb.delay);
}
var maxDelay = 0;
for (var m = 0; m < nodes.length; m++) if (nodes[m].delay > maxDelay) maxDelay = nodes[m].delay;
05채워진 줄 수 값이 늘어나는 결과 카드 스트림 로딩
결과 카드 안의 스켈레톤 줄마다 채워졌는지 여부를 값으로 두고, 그 값이 위에서부터 하나씩 참으로 바뀔 때마다 해당 줄만 실제 텍스트 톤으로 바뀝니다. 값은 위에서부터 순서대로 바뀌고, 줄마다 채워진 비율이 별도 값으로 관리되어 완료된 줄만 실제 텍스트 색으로 넘어갑니다.

<main class="stage">
<div class="ld" id="ld">
<p class="ld__hint">
<svg class="ld__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M232 56v56a8 8 0 0 1-16 0V91.3l-25.6 25.7A80 80 0 1 1 174 60a8 8 0 1 1-9 13.2A64 64 0 1 0 179.7 128H216a8 8 0 0 1 0 16h-56a8 8 0 0 1-8-8V80a8 8 0 0 1 16 0v20.7L192.7 76A80.1 80.1 0 0 0 64 128a8 8 0 0 1-16 0a96 96 0 0 1 163.6-68.3L224 71.3V56a8 8 0 0 1 8 8" opacity=".2"/><path d="M224 48a8 8 0 0 0-8 8v15.3l-11.7-11.6a96 96 0 1 0 27.6 51.6a8 8 0 0 0-15.8 2.5a80 80 0 1 1-23-43l-25.7 25.7A8 8 0 0 0 173 112h56a8 8 0 0 0 8-8V56a8 8 0 0 0-13-8"/></g></svg>
<span>클릭하거나 스페이스바를 누르면 멈추거나 다시 갑니다</span>
</p>
<div class="ld__field" id="field" role="application" aria-label="카드 스트림 채움 실험판" tabindex="0">
<div class="ld__card">
<div class="ld__cardHead"><i></i><i></i><i></i></div>
<div class="ld__line" style="--w:86%" data-i="0"><b class="ld__lineFill"></b></div>
<div class="ld__line" style="--w:64%" data-i="1"><b class="ld__lineFill"></b></div>
<div class="ld__line" style="--w:92%" data-i="2"><b class="ld__lineFill"></b></div>
<div class="ld__line" style="--w:58%" data-i="3"><b class="ld__lineFill"></b></div>
<div class="ld__line" style="--w:78%" data-i="4"><b class="ld__lineFill"></b></div>
<div class="ld__line" style="--w:46%" data-i="5"><b class="ld__lineFill"></b></div>
</div>
<b class="ld__badge" aria-hidden="true"><span class="ld__badgeNum" id="pct">0%</span><span class="ld__badgeUnit">채움</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// card-content-stream-fill — 스켈레톤 줄마다 채워졌는지 여부를 값으로 두고, 그 값이 위에서부터
// 하나씩 참으로 바뀔 때마다 해당 줄만 실제 텍스트 톤으로 바뀐다. rAF 로 폭·색을 함께 보간 — @keyframes 없음.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-ink);
.ld {
--skeleton: #4a4550;
--text-tone: #{$subject-blue-light};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.ld__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;
}
.ld__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-blue-light; }
.ld__field {
position: relative; height: 208px;
display: flex; align-items: center;
border-radius: $r-card; overflow: hidden;
background: $subject-ink;
box-shadow: inset 0 0 0 1px rgba($subject-cream, .14);
cursor: pointer;
padding: 0 $sp-4;
}
.ld__field:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
.ld__card {
width: 100%; display: flex; flex-direction: column; gap: $sp-2;
}
.ld__cardHead {
display: flex; gap: $sp-1; margin-bottom: $sp-1;
i { width: 7px; height: 7px; border-radius: $r-pill; background: rgba($subject-cream, .3); display: block; }
}
.ld__line {
position: relative; height: 10px; width: var(--w, 70%);
border-radius: $r-xs;
background: rgba($subject-cream, .12);
overflow: hidden;
}
.ld__lineFill {
display: block; height: 100%; width: 0%;
border-radius: $r-xs;
background: $subject-blue-light;
}
.ld__line.is-active { box-shadow: 0 0 0 1px rgba($subject-blue-light, .35); }
.ld__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-cream, .12);
color: $subject-cream; font-weight: 700; font-size: 11px;
}
.ld__badgeNum { font-size: 13px; color: $stage-yellow; }
.ld__badgeUnit { font-weight: 700; }
@media (max-width: 340px) {
.ld { gap: $sp-1; }
.ld__field { height: 124px; padding: 0 $sp-2; }
.ld__card { gap: $sp-1; }
.ld__badge { font-size: 10px; padding: 0 $sp-1; }
.ld__badgeNum { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.ld.is-still .ld__field { cursor: default !important; }
.ld.is-still .ld__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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ld {
--skeleton: #4a4550;
--text-tone: #5b8cf8;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.ld__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;
}
.ld__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #5b8cf8;
}
.ld__field {
position: relative;
height: 208px;
display: flex;
align-items: center;
border-radius: 16px;
overflow: hidden;
background: #17141a;
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.14);
cursor: pointer;
padding: 0 16px;
}
.ld__field:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.ld__card {
width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
}
.ld__cardHead {
display: flex;
gap: 4px;
margin-bottom: 4px;
}
.ld__cardHead i {
width: 7px;
height: 7px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.3);
display: block;
}
.ld__line {
position: relative;
height: 10px;
width: var(--w, 70%);
border-radius: 4px;
background: rgba(255, 247, 230, 0.12);
overflow: hidden;
}
.ld__lineFill {
display: block;
height: 100%;
width: 0%;
border-radius: 4px;
background: #5b8cf8;
}
.ld__line.is-active {
box-shadow: 0 0 0 1px rgba(91, 140, 248, 0.35);
}
.ld__badge {
position: absolute;
right: 8px;
top: 8px;
display: flex;
align-items: baseline;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.12);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
.ld__badgeNum {
font-size: 13px;
color: #ffd23f;
}
.ld__badgeUnit {
font-weight: 700;
}
@media (max-width: 340px) {
.ld {
gap: 4px;
}
.ld__field {
height: 124px;
padding: 0 8px;
}
.ld__card {
gap: 4px;
}
.ld__badge {
font-size: 10px;
padding: 0 4px;
}
.ld__badgeNum {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.ld.is-still .ld__field {
cursor: default !important;
}
.ld.is-still .ld__badge {
opacity: 0.92 !important;
}
}(function () {
var LOOP = 2.0;
var LINE_MS = 0.24; // 줄 시작 간격 — 줄마다 채워짐 값이 참이 되는 시각 간격
var FILL_MS = 0.30; // 줄 하나가 채워지는 데 걸리는 시간
var root = document.getElementById('ld');
var field = document.getElementById('field');
var lines = Array.prototype.slice.call(document.querySelectorAll('.ld__line'));
var fills = lines.map(function (l) { return l.querySelector('.ld__lineFill'); });
var pctEl = document.getElementById('pct');
var paused = false;
var t0 = performance.now();
var raf = 0;
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function clamp01(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
function smooth(t) { return t * t * (3 - 2 * t); }
function hexToRgb(hex) {
var h = hex.replace('#', '').trim();
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
var n = parseInt(h, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function mix(a, b, t) {
return [Math.round(a[0] + (b[0] - a[0]) * t), Math.round(a[1] + (b[1] - a[1]) * t), Math.round(a[2] + (b[2] - a[2]) * t)];
}
function rgbStr(c) { return 'rgb(' + c[0] + ',' + c[1] + ',' + c[2] + ')'; }
var cs = getComputedStyle(root);
var SKELETON = hexToRgb(cs.getPropertyValue('--skeleton').trim());
var TEXT = hexToRgb(cs.getPropertyValue('--text-tone').trim());
function draw(sec) {
var t = sec % LOOP;
var doneCount = 0;
for (var i = 0; i < lines.length; i++) {
var local = t - i * LINE_MS;
var raw = clamp01(local / FILL_MS);
var eased = smooth(raw);
fills[i].style.width = (eased * 100).toFixed(1) + '%';
fills[i].style.background = rgbStr(mix(SKELETON, TEXT, eased));
lines[i].classList.toggle('is-active', raw > 0 && raw < 1);
if (raw >= 1) doneCount++;
}
pctEl.textContent = Math.round((doneCount / lines.length) * 100) + '%';
}
function togglePause() { paused = !paused; root.classList.toggle('is-paused', paused); }
function restart() { t0 = performance.now(); draw(0); }
function onKey(e) {
if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); togglePause(); }
else if (e.key === 'r' || e.key === 'R') { e.preventDefault(); restart(); }
}
field.addEventListener('click', togglePause);
field.addEventListener('keydown', onKey);
function onResize() {
if (paused || reduced()) draw((performance.now() - t0) / 1000);
}
window.addEventListener('resize', onResize);
function frame(now) {
if (!paused) draw((now - t0) / 1000);
raf = requestAnimationFrame(frame);
}
if (reduced()) {
root.classList.add('is-still');
draw(LOOP * 0.62);
} else {
raf = requestAnimationFrame(frame);
}
})();// CardStream — vanilla 판(run/403/demo/05-ai-processing-loading-card-content-stream-fill/index.html) 의 React 포트.
// 스켈레톤 줄마다 채워졌는지 여부를 값으로 두고, 위에서부터 하나씩 참으로 바뀔 때마다
// 해당 줄만 실제 텍스트 톤으로 바뀐다. rAF 로 폭·색을 함께 보간 — @keyframes 없음.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./CardStream.module.scss";
export type CardStreamProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
const LOOP = 2.0;
const LINE_MS = 0.24;
const FILL_MS = 0.30;
const WIDTHS = [86, 64, 92, 58, 78, 46];
const FIELD_LABEL = "카드 스트림 채움 실험판";
function clamp01(v: number): number { return v < 0 ? 0 : v > 1 ? 1 : v; }
function smooth(t: number): number { return t * t * (3 - 2 * t); }
function hexToRgb(hex: string): [number, number, number] {
let h = hex.replace("#", "").trim();
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
const n = parseInt(h, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function mix(a: [number, number, number], b: [number, number, number], t: number): [number, number, number] {
return [Math.round(a[0] + (b[0] - a[0]) * t), Math.round(a[1] + (b[1] - a[1]) * t), Math.round(a[2] + (b[2] - a[2]) * t)];
}
function rgbStr(c: [number, number, number]): string { return "rgb(" + c[0] + "," + c[1] + "," + c[2] + ")"; }
export default function CardStream({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#fff7e6",
}: CardStreamProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const lineRefs = useRef<Array<HTMLDivElement | null>>([]) as Box<Array<HTMLDivElement | null>>;
const fillRefs = useRef<Array<HTMLSpanElement | null>>([]) as Box<Array<HTMLSpanElement | null>>;
const pctRef = useRef<HTMLSpanElement | null>(null);
const pausedRef = useRef(false) as Box<boolean>;
const t0Ref = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const skeletonRef = useRef<[number, number, number]>([74, 69, 80]) as Box<[number, number, number]>;
const textRef = useRef<[number, number, number]>([91, 140, 248]) as Box<[number, number, number]>;
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function draw(sec: number): void {
const pctEl = pctRef.current;
const t = sec % LOOP;
let doneCount = 0;
for (let i = 0; i < WIDTHS.length; i++) {
const local = t - i * LINE_MS;
const raw = clamp01(local / FILL_MS);
const eased = smooth(raw);
const fillEl = fillRefs.current[i];
const lineEl = lineRefs.current[i];
if (fillEl) {
fillEl.style.width = (eased * 100).toFixed(1) + "%";
fillEl.style.background = rgbStr(mix(skeletonRef.current, textRef.current, eased));
}
if (lineEl) lineEl.classList.toggle(styles.isActive, raw > 0 && raw < 1);
if (raw >= 1) doneCount++;
}
if (pctEl) pctEl.textContent = Math.round((doneCount / WIDTHS.length) * 100) + "%";
}
function togglePause(): void {
pausedRef.current = !pausedRef.current;
const root = rootRef.current;
if (root) root.classList.toggle("is-paused", pausedRef.current);
}
function restart(): void { t0Ref.current = performance.now(); draw(0); }
function onKey(e: KeyEvt): void {
if (e.key === " " || e.key === "Enter") { e.preventDefault(); togglePause(); }
else if (e.key === "r" || e.key === "R") { e.preventDefault(); restart(); }
}
function onClick(): void { togglePause(); }
function onResize(): void {
if (pausedRef.current || reduced()) draw((performance.now() - t0Ref.current) / 1000);
}
function frame(now: number): void {
if (!pausedRef.current) draw((now - t0Ref.current) / 1000);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const cs = getComputedStyle(root);
skeletonRef.current = hexToRgb(cs.getPropertyValue("--skeleton").trim() || "#4a4550");
textRef.current = hexToRgb(cs.getPropertyValue("--text-tone").trim() || "#5b8cf8");
t0Ref.current = performance.now();
window.addEventListener("resize", onResize);
if (reduced()) {
root.classList.add("is-still");
draw(LOOP * 0.62);
} 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 56v56a8 8 0 0 1-16 0V91.3l-25.6 25.7A80 80 0 1 1 174 60a8 8 0 1 1-9 13.2A64 64 0 1 0 179.7 128H216a8 8 0 0 1 0 16h-56a8 8 0 0 1-8-8V80a8 8 0 0 1 16 0v20.7L192.7 76A80.1 80.1 0 0 0 64 128a8 8 0 0 1-16 0a96 96 0 0 1 163.6-68.3L224 71.3V56a8 8 0 0 1 8 8" opacity=".2"/><path d="M224 48a8 8 0 0 0-8 8v15.3l-11.7-11.6a96 96 0 1 0 27.6 51.6a8 8 0 0 0-15.8 2.5a80 80 0 1 1-23-43l-25.7 25.7A8 8 0 0 0 173 112h56a8 8 0 0 0 8-8V56a8 8 0 0 0-13-8"/></g></svg>
<span>클릭하거나 스페이스바를 누르면 멈추거나 다시 갑니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onClick={onClick}
onKeyDown={onKey}
>
<div className={styles.card}>
<div className={styles.cardHead}><i></i><i></i><i></i></div>
{WIDTHS.map((w, i) => (
<div
key={i}
className={styles.line}
style={{ "--w": w + "%" } as CSSProperties}
ref={(el: HTMLDivElement | null) => { lineRefs.current[i] = el; }}
>
<span className={styles.lineFill} ref={(el: HTMLSpanElement | null) => { fillRefs.current[i] = el; }}></span>
</div>
))}
</div>
<b className={styles.badge} aria-hidden="true"><span className={styles.badgeNum} ref={pctRef}>0%</span><span className={styles.badgeUnit}>채움</span></b>
</div>
</div>
</main>
);
}05 · 채워진 줄 수 값이 늘어나는 결과 카드 스트림 로딩 — 측정 누적면적 14.256% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function draw(sec) {
var t = sec % LOOP;
var doneCount = 0;
for (var i = 0; i < lines.length; i++) {
var local = t - i * LINE_MS;
var raw = clamp01(local / FILL_MS);
var eased = smooth(raw);
fills[i].style.width = (eased * 100).toFixed(1) + '%';
fills[i].style.background = rgbStr(mix(SKELETON, TEXT, eased));
lines[i].classList.toggle('is-active', raw > 0 && raw < 1);
if (raw >= 1) doneCount++;
}
pctEl.textContent = Math.round((doneCount / lines.length) * 100) + '%';
}
06완료 값이 뒤집히면 반짝임으로 바뀌는 버튼 아이콘
버튼 안 아이콘의 path 값을 스피너 모양에서 반짝임(스파클) 모양으로 미리 정해 두고, 완료 값이 false 에서 true 로 바뀌는 순간 그 path 값만 보간해 모양을 바꿉니다. 완료 값이 뒤집히는 순간 path 좌표 여덟 쌍이 동시에 보간되어 스피너 모양이 스파클 모양으로 바뀝니다.
<main class="stage">
<div class="ld" id="ld">
<p class="ld__hint">
<svg class="ld__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M232 56v56a8 8 0 0 1-16 0V91.3l-25.6 25.7A80 80 0 1 1 174 60a8 8 0 1 1-9 13.2A64 64 0 1 0 179.7 128H216a8 8 0 0 1 0 16h-56a8 8 0 0 1-8-8V80a8 8 0 0 1 16 0v20.7L192.7 76A80.1 80.1 0 0 0 64 128a8 8 0 0 1-16 0a96 96 0 0 1 163.6-68.3L224 71.3V56a8 8 0 0 1 8 8" opacity=".2"/><path d="M224 48a8 8 0 0 0-8 8v15.3l-11.7-11.6a96 96 0 1 0 27.6 51.6a8 8 0 0 0-15.8 2.5a80 80 0 1 1-23-43l-25.7 25.7A8 8 0 0 0 173 112h56a8 8 0 0 0 8-8V56a8 8 0 0 0-13-8"/></g></svg>
<span>클릭하거나 스페이스바를 누르면 멈추거나 다시 갑니다</span>
</p>
<div class="ld__field" id="field" role="application" aria-label="버튼 아이콘 스파클 모프 실험판" tabindex="0">
<button class="ld__btn" type="button" tabindex="-1">
<svg class="ld__btnIco" id="btnIco" viewBox="-46 -46 92 92" aria-hidden="true">
<path id="path"></path>
</svg>
</button>
<b class="ld__badge" aria-hidden="true"><span class="ld__badgeNum" id="pct">처리중</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// button-icon-sparkle-morph — 버튼 아이콘의 path 값을 스피너 모양에서 반짝임(스파클) 모양으로
// 미리 정해 두고, 완료 값이 false 에서 true 로 바뀌는 순간 그 path 값만 보간해 모양을 바꾼다.
// SVG path 정점 보간 + rAF — @keyframes 없음.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-yellow);
.ld {
--spin: #{$subject-blue-deep};
--done: #{$subject-ink};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.ld__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;
}
.ld__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-blue-deep; }
.ld__field {
position: relative; height: 208px;
display: flex; align-items: center; justify-content: center;
border-radius: $r-card; overflow: hidden;
background: rgba($subject-ink, .06);
box-shadow: inset 0 0 0 1px rgba($subject-ink, .16);
cursor: pointer;
}
.ld__field:focus-visible { outline: 2px solid $subject-blue-deep; outline-offset: 2px; }
.ld__btn {
position: relative;
display: flex; align-items: center; justify-content: center;
padding: $sp-3;
border: 0; border-radius: $r-pill;
background: $subject-cream;
box-shadow: 0 2px 0 rgba($subject-ink, .12);
cursor: pointer;
}
.ld__btnIco { width: 184px; height: 184px; flex: 0 0 auto; }
.ld__badge {
position: absolute; right: $sp-2; top: $sp-2;
padding: $sp-1 $sp-2; border-radius: $r-pill;
background: rgba($subject-ink, .82);
color: $subject-cream; font-weight: 700; font-size: 11px;
}
@media (max-width: 340px) {
.ld { gap: $sp-1; }
.ld__field { height: 124px; }
.ld__btn { padding: $sp-1; }
.ld__btnIco { width: 100px; height: 100px; }
.ld__badge { font-size: 10px; padding: 0 $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.ld.is-still .ld__field { cursor: default !important; }
.ld.is-still .ld__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);
}
.ld {
--spin: #0a4fe8;
--done: #17141a;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.ld__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;
}
.ld__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #0a4fe8;
}
.ld__field {
position: relative;
height: 208px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 16px;
overflow: hidden;
background: rgba(23, 20, 26, 0.06);
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.16);
cursor: pointer;
}
.ld__field:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
.ld__btn {
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
border: 0;
border-radius: 999px;
background: #fff7e6;
box-shadow: 0 2px 0 rgba(23, 20, 26, 0.12);
cursor: pointer;
}
.ld__btnIco {
width: 184px;
height: 184px;
flex: 0 0 auto;
}
.ld__badge {
position: absolute;
right: 8px;
top: 8px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.82);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
@media (max-width: 340px) {
.ld {
gap: 4px;
}
.ld__field {
height: 124px;
}
.ld__btn {
padding: 4px;
}
.ld__btnIco {
width: 100px;
height: 100px;
}
.ld__badge {
font-size: 10px;
padding: 0 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.ld.is-still .ld__field {
cursor: default !important;
}
.ld.is-still .ld__badge {
opacity: 0.92 !important;
}
}(function () {
var LOOP = 2.0;
var FLIP_AT = LOOP * 0.5; // 완료 값이 뒤집히는 시각
var MORPH_MS = 0.32; // path 값 보간 시간
var ROT_SPEED = 220; // 스피너 회전 속도(도/초)
var R_RING = 26, R_OUT = 40, R_IN = 13;
var root = document.getElementById('ld');
var field = document.getElementById('field');
var path = document.getElementById('path');
var pctEl = document.getElementById('pct');
var paused = false;
var t0 = performance.now();
var raf = 0;
var DONE = false; // 완료 값(초기)
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function clamp01(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
function smooth(t) { return t * t * (3 - 2 * t); }
function pt(ang, r) { return [r * Math.cos(ang), r * Math.sin(ang)]; }
function buildPath(radii, angOffset) {
var d = '';
for (var i = 0; i < 8; i++) {
var ang = angOffset + i * Math.PI / 4;
var p = pt(ang, radii[i]);
d += (i === 0 ? 'M' : 'L') + p[0].toFixed(2) + ',' + p[1].toFixed(2) + ' ';
}
return d + 'Z';
}
function draw(sec) {
var t = sec % LOOP;
var localFlip = t - FLIP_AT;
if (localFlip >= 0) { DONE = true; } else { DONE = false; }
var morphT = DONE ? smooth(clamp01(localFlip / MORPH_MS)) : 0;
var radii = [];
for (var i = 0; i < 8; i++) {
var spinnerR = R_RING;
var sparkleR = (i % 2 === 0) ? R_OUT : R_IN;
radii[i] = spinnerR + (sparkleR - spinnerR) * morphT;
}
var angleOffset = (sec * ROT_SPEED) % 360 * Math.PI / 180;
var rot = DONE ? angleOffset * 0.12 : angleOffset;
path.setAttribute('d', buildPath(radii, rot));
var pulse = 1;
if (DONE && localFlip < 0.5) {
pulse = 1 + 0.22 * Math.sin(clamp01(localFlip / 0.5) * Math.PI);
}
path.setAttribute('transform', 'scale(' + pulse.toFixed(3) + ')');
path.setAttribute('fill', DONE ? 'var(--done)' : 'var(--spin)');
pctEl.textContent = DONE ? '완료' : '처리중';
}
function togglePause() { paused = !paused; root.classList.toggle('is-paused', paused); }
function restart() { t0 = performance.now(); draw(0); }
function onKey(e) {
if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); togglePause(); }
else if (e.key === 'r' || e.key === 'R') { e.preventDefault(); restart(); }
}
field.addEventListener('click', togglePause);
field.addEventListener('keydown', onKey);
function onResize() {
if (paused || reduced()) draw((performance.now() - t0) / 1000);
}
window.addEventListener('resize', onResize);
function frame(now) {
if (!paused) draw((now - t0) / 1000);
raf = requestAnimationFrame(frame);
}
if (reduced()) {
root.classList.add('is-still');
draw(LOOP * 0.62);
} else {
raf = requestAnimationFrame(frame);
}
})();// SparkleMorph — vanilla 판(run/403/demo/06-ai-processing-loading-button-icon-sparkle-morph/index.html) 의 React 포트.
// 버튼 아이콘의 path 값을 스피너 모양에서 반짝임(스파클) 모양으로 미리 정해 두고, 완료 값이
// false 에서 true 로 바뀌는 순간 그 path 값만 보간해 모양을 바꾼다. SVG 정점 보간 + rAF.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./SparkleMorph.module.scss";
export type SparkleMorphProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
const LOOP = 2.0;
const FLIP_AT = LOOP * 0.5;
const MORPH_MS = 0.32;
const ROT_SPEED = 220;
const R_RING = 26, R_OUT = 40, R_IN = 13;
const FIELD_LABEL = "버튼 아이콘 스파클 모프 실험판";
function clamp01(v: number): number { return v < 0 ? 0 : v > 1 ? 1 : v; }
function smooth(t: number): number { return t * t * (3 - 2 * t); }
function pt(ang: number, r: number): [number, number] { return [r * Math.cos(ang), r * Math.sin(ang)]; }
function buildPath(radii: number[], angOffset: number): string {
let d = "";
for (let i = 0; i < 8; i++) {
const ang = angOffset + i * Math.PI / 4;
const p = pt(ang, radii[i]);
d += (i === 0 ? "M" : "L") + p[0].toFixed(2) + "," + p[1].toFixed(2) + " ";
}
return d + "Z";
}
export default function SparkleMorph({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#17141a",
}: SparkleMorphProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const pathRef = useRef<SVGPathElement | null>(null);
const pctRef = useRef<HTMLSpanElement | null>(null);
const pausedRef = useRef(false) as Box<boolean>;
const t0Ref = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const doneRef = useRef(false) as Box<boolean>;
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function draw(sec: number): void {
const path = pathRef.current;
const pctEl = pctRef.current;
if (!path) return;
const t = sec % LOOP;
const localFlip = t - FLIP_AT;
if (localFlip >= 0) { doneRef.current = true; } else { doneRef.current = false; }
const done = doneRef.current;
const morphT = done ? smooth(clamp01(localFlip / MORPH_MS)) : 0;
const radii: number[] = [];
for (let i = 0; i < 8; i++) {
const spinnerR = R_RING;
const sparkleR = (i % 2 === 0) ? R_OUT : R_IN;
radii[i] = spinnerR + (sparkleR - spinnerR) * morphT;
}
const angleOffset = ((sec * ROT_SPEED) % 360) * Math.PI / 180;
const rot = done ? angleOffset * 0.12 : angleOffset;
path.setAttribute("d", buildPath(radii, rot));
let pulse = 1;
if (done && localFlip < 0.5) {
pulse = 1 + 0.22 * Math.sin(clamp01(localFlip / 0.5) * Math.PI);
}
path.setAttribute("transform", "scale(" + pulse.toFixed(3) + ")");
path.setAttribute("fill", done ? "var(--done)" : "var(--spin)");
if (pctEl) pctEl.textContent = done ? "완료" : "처리중";
}
function togglePause(): void {
pausedRef.current = !pausedRef.current;
const root = rootRef.current;
if (root) root.classList.toggle("is-paused", pausedRef.current);
}
function restart(): void { t0Ref.current = performance.now(); draw(0); }
function onKey(e: KeyEvt): void {
if (e.key === " " || e.key === "Enter") { e.preventDefault(); togglePause(); }
else if (e.key === "r" || e.key === "R") { e.preventDefault(); restart(); }
}
function onClick(): void { togglePause(); }
function onResize(): void {
if (pausedRef.current || reduced()) draw((performance.now() - t0Ref.current) / 1000);
}
function frame(now: number): void {
if (!pausedRef.current) draw((now - t0Ref.current) / 1000);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
t0Ref.current = performance.now();
window.addEventListener("resize", onResize);
if (reduced()) {
const root = rootRef.current;
if (root) root.classList.add("is-still");
draw(LOOP * 0.62);
} 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 56v56a8 8 0 0 1-16 0V91.3l-25.6 25.7A80 80 0 1 1 174 60a8 8 0 1 1-9 13.2A64 64 0 1 0 179.7 128H216a8 8 0 0 1 0 16h-56a8 8 0 0 1-8-8V80a8 8 0 0 1 16 0v20.7L192.7 76A80.1 80.1 0 0 0 64 128a8 8 0 0 1-16 0a96 96 0 0 1 163.6-68.3L224 71.3V56a8 8 0 0 1 8 8" opacity=".2"/><path d="M224 48a8 8 0 0 0-8 8v15.3l-11.7-11.6a96 96 0 1 0 27.6 51.6a8 8 0 0 0-15.8 2.5a80 80 0 1 1-23-43l-25.7 25.7A8 8 0 0 0 173 112h56a8 8 0 0 0 8-8V56a8 8 0 0 0-13-8"/></g></svg>
<span>클릭하거나 스페이스바를 누르면 멈추거나 다시 갑니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onClick={onClick}
onKeyDown={onKey}
>
<button className={styles.btn} type="button" tabIndex={-1}>
<svg className={styles.btnIco} viewBox="-46 -46 92 92" aria-hidden="true">
<path ref={pathRef}></path>
</svg>
</button>
<b className={styles.badge} aria-hidden="true"><span className={styles.badgeNum} ref={pctRef}>처리중</span></b>
</div>
</div>
</main>
);
}06 · 완료 값이 뒤집히면 반짝임으로 바뀌는 버튼 아이콘 — 측정 누적면적 10.111% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
var t = sec % LOOP;
var localFlip = t - FLIP_AT;
if (localFlip >= 0) { DONE = true; } else { DONE = false; }
var morphT = DONE ? smooth(clamp01(localFlip / MORPH_MS)) : 0;
var radii = [];
for (var i = 0; i < 8; i++) {
var spinnerR = R_RING;
var sparkleR = (i % 2 === 0) ? R_OUT : R_IN;
radii[i] = spinnerR + (sparkleR - spinnerR) * morphT;
}
var angleOffset = (sec * ROT_SPEED) % 360 * Math.PI / 180;
var rot = DONE ? angleOffset * 0.12 : angleOffset;
path.setAttribute('d', buildPath(radii, rot));
var pulse = 1;
if (DONE && localFlip < 0.5) {
pulse = 1 + 0.22 * Math.sin(clamp01(localFlip / 0.5) * Math.PI);
}
path.setAttribute('transform', 'scale(' + pulse.toFixed(3) + ')');
07입력 크기 값이 막대 높이를 흔드는 듣는 중 파형
마이크 입력 크기를 흉내 낸 값을 막대마다 다르게 주고, 그 값이 오르내릴 때마다 막대 높이가 따라 흔들려 지금 소리를 듣고 있는 상태를 보여줍니다. 실제 마이크 입력을 받는 게 아니라 막대마다 다른 두 사인 값을 합성해 흔드는 값이라, 진짜 소리와는 무관하게 항상 같은 리듬으로 움직입니다.

<main class="stage">
<div class="ld" id="ld">
<p class="ld__hint">
<svg class="ld__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M232 56v56a8 8 0 0 1-16 0V91.3l-25.6 25.7A80 80 0 1 1 174 60a8 8 0 1 1-9 13.2A64 64 0 1 0 179.7 128H216a8 8 0 0 1 0 16h-56a8 8 0 0 1-8-8V80a8 8 0 0 1 16 0v20.7L192.7 76A80.1 80.1 0 0 0 64 128a8 8 0 0 1-16 0a96 96 0 0 1 163.6-68.3L224 71.3V56a8 8 0 0 1 8 8" opacity=".2"/><path d="M224 48a8 8 0 0 0-8 8v15.3l-11.7-11.6a96 96 0 1 0 27.6 51.6a8 8 0 0 0-15.8 2.5a80 80 0 1 1-23-43l-25.7 25.7A8 8 0 0 0 173 112h56a8 8 0 0 0 8-8V56a8 8 0 0 0-13-8"/></g></svg>
<span>클릭하거나 스페이스바를 누르면 멈추거나 다시 갑니다</span>
</p>
<div class="ld__field" id="field" role="application" aria-label="듣는 중 파형 실험판" tabindex="0">
<div class="ld__bars" id="bars"></div>
<b class="ld__badge" aria-hidden="true"><span class="ld__badgeNum" id="pct">듣는 중</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// listening-waveform-mic — 마이크 입력 크기를 흉내 낸 합성 진폭 값을 막대마다 다르게 주고,
// 그 값이 오르내릴 때마다 막대 높이가 따라 흔들린다. 실 마이크·getUserMedia·Web Audio 없음 —
// 고정 시드(seeded) 두 사인파 합성 + rAF. @keyframes 없음.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-orange);
.ld {
--bar: #{$subject-cream};
--bar-dim: #{rgba($subject-cream, .34)};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.ld__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;
}
.ld__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-cream; }
.ld__field {
position: relative; height: 208px;
display: flex; align-items: center; justify-content: center;
border-radius: $r-card; overflow: hidden;
background: rgba($stage-paper, .08);
box-shadow: inset 0 0 0 1px rgba($subject-cream, .22);
cursor: pointer;
}
.ld__field:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
.ld__bars {
display: flex; align-items: flex-end; justify-content: center;
gap: 4px; height: 100px; width: 100%; padding: 0 $sp-4;
}
.ld__bars > i {
display: block; flex: 1 1 auto; max-width: 10px;
border-radius: $r-xs;
background: var(--bar);
height: 8px;
box-shadow: 0 0 8px rgba($subject-cream, .35);
}
.ld__badge {
position: absolute; right: $sp-2; top: $sp-2;
padding: $sp-1 $sp-2; border-radius: $r-pill;
background: rgba($stage-ink, .48);
color: $subject-cream; font-weight: 700; font-size: 11px;
}
.ld__badgeNum { font-size: 11px; color: $subject-cream; }
@media (max-width: 340px) {
.ld { gap: $sp-1; }
.ld__field { height: 124px; }
.ld__bars { height: 60px; gap: 4px; padding: 0 $sp-2; }
.ld__badge { font-size: 10px; padding: 0 $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.ld.is-still .ld__field { cursor: default !important; }
.ld.is-still .ld__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: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ld {
--bar: #fff7e6;
--bar-dim: rgba(255, 247, 230, 0.34);
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.ld__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;
}
.ld__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #fff7e6;
}
.ld__field {
position: relative;
height: 208px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 16px;
overflow: hidden;
background: rgba(255, 255, 255, 0.08);
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.22);
cursor: pointer;
}
.ld__field:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.ld__bars {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 4px;
height: 100px;
width: 100%;
padding: 0 16px;
}
.ld__bars > i {
display: block;
flex: 1 1 auto;
max-width: 10px;
border-radius: 4px;
background: var(--bar);
height: 8px;
box-shadow: 0 0 8px rgba(255, 247, 230, 0.35);
}
.ld__badge {
position: absolute;
right: 8px;
top: 8px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.48);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
.ld__badgeNum {
font-size: 11px;
color: #fff7e6;
}
@media (max-width: 340px) {
.ld {
gap: 4px;
}
.ld__field {
height: 124px;
}
.ld__bars {
height: 60px;
gap: 4px;
padding: 0 8px;
}
.ld__badge {
font-size: 10px;
padding: 0 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.ld.is-still .ld__field {
cursor: default !important;
}
.ld.is-still .ld__badge {
opacity: 0.92 !important;
}
}(function () {
var LOOP = 2.0;
var N = 20; // 막대 수
var BASE_H = 8; // 막대 최소 높이(px)
var MAX_H = 92; // 막대 최대 높이(px)
var root = document.getElementById('ld');
var field = document.getElementById('field');
var barsWrap = document.getElementById('bars');
var pctEl = document.getElementById('pct');
var paused = false;
var t0 = performance.now();
var raf = 0;
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function clamp01(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
function seeded(i) { var v = Math.sin(i * 12.9898) * 43758.5453; return v - Math.floor(v); }
var bars = [];
var bels = [];
for (var i = 0; i < N; i++) {
bars.push({
freqA: 2.1 + seeded(i) * 1.8, // 마이크 입력 크기 값을 흉내 낸 합성 진폭 — 기본 진동수
freqB: 4.7 + seeded(i + 50) * 2.4, // 보조 진동수(잔떨림)
phase: seeded(i + 90) * Math.PI * 2
});
var b = document.createElement('i');
barsWrap.appendChild(b);
bels.push(b);
}
function draw(sec) {
var t = sec;
for (var i = 0; i < N; i++) {
var q = bars[i];
var env = 0.5 + 0.5 * Math.sin(t * q.freqA * Math.PI * 2 + q.phase);
var jit = Math.sin(t * q.freqB * Math.PI * 2 + q.phase * 1.7) * 0.22;
var level = clamp01(env + jit);
var h = BASE_H + (MAX_H - BASE_H) * level;
bels[i].style.height = h.toFixed(1) + 'px';
bels[i].style.opacity = (0.55 + level * 0.45).toFixed(2);
}
var avg = 0;
for (var j = 0; j < N; j++) avg += parseFloat(bels[j].style.height);
avg = Math.round((avg / N - BASE_H) / (MAX_H - BASE_H) * 100);
pctEl.textContent = avg > 8 ? '듣는 중' : '대기';
}
function togglePause() { paused = !paused; root.classList.toggle('is-paused', paused); }
function restart() { t0 = performance.now(); draw(0); }
function onKey(e) {
if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); togglePause(); }
else if (e.key === 'r' || e.key === 'R') { e.preventDefault(); restart(); }
}
field.addEventListener('click', togglePause);
field.addEventListener('keydown', onKey);
function onResize() {
if (paused || reduced()) draw((performance.now() - t0) / 1000 % LOOP);
}
window.addEventListener('resize', onResize);
function frame(now) {
if (!paused) draw((now - t0) / 1000);
raf = requestAnimationFrame(frame);
}
if (reduced()) {
root.classList.add('is-still');
draw(LOOP * 0.62);
} else {
raf = requestAnimationFrame(frame);
}
})();// WaveformMic — vanilla 판(run/403/demo/07-ai-processing-loading-listening-waveform-mic/index.html) 의 React 포트.
// 마이크 입력 크기를 흉내 낸 합성 진폭 값을 막대마다 다르게 주고, 그 값이 오르내릴 때마다
// 막대 높이가 따라 흔들려 지금 소리를 듣고 있는 상태를 보여준다. 실 마이크·getUserMedia 없음 —
// 고정 시드(seeded) 두 사인파 합성 + rAF.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./WaveformMic.module.scss";
export type WaveformMicProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
const LOOP = 2.0;
const N = 20;
const BASE_H = 8;
const MAX_H = 92;
const FIELD_LABEL = "듣는 중 파형 실험판";
function clamp01(v: number): number { return v < 0 ? 0 : v > 1 ? 1 : v; }
function seeded(i: number): number { const v = Math.sin(i * 12.9898) * 43758.5453; return v - Math.floor(v); }
type Bar = { freqA: number; freqB: number; phase: number };
const BARS: Bar[] = (() => {
const arr: Bar[] = [];
for (let i = 0; i < N; i++) {
arr.push({
freqA: 2.1 + seeded(i) * 1.8,
freqB: 4.7 + seeded(i + 50) * 2.4,
phase: seeded(i + 90) * Math.PI * 2,
});
}
return arr;
})();
export default function WaveformMic({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#fff7e6",
}: WaveformMicProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const barRefs = useRef<(HTMLElement | null)[]>([]) as Box<(HTMLElement | null)[]>;
const pctRef = useRef<HTMLSpanElement | null>(null);
const pausedRef = useRef(false) as Box<boolean>;
const t0Ref = 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 draw(sec: number): void {
const t = sec;
let sum = 0;
for (let i = 0; i < N; i++) {
const q = BARS[i];
const env = 0.5 + 0.5 * Math.sin(t * q.freqA * Math.PI * 2 + q.phase);
const jit = Math.sin(t * q.freqB * Math.PI * 2 + q.phase * 1.7) * 0.22;
const level = clamp01(env + jit);
const h = BASE_H + (MAX_H - BASE_H) * level;
const el = barRefs.current[i];
if (el) {
el.style.height = h.toFixed(1) + "px";
el.style.opacity = (0.55 + level * 0.45).toFixed(2);
}
sum += h;
}
const avg = Math.round((sum / N - BASE_H) / (MAX_H - BASE_H) * 100);
const pctEl = pctRef.current;
if (pctEl) pctEl.textContent = avg > 8 ? "듣는 중" : "대기";
}
function togglePause(): void {
pausedRef.current = !pausedRef.current;
const root = rootRef.current;
if (root) root.classList.toggle("is-paused", pausedRef.current);
}
function restart(): void { t0Ref.current = performance.now(); draw(0); }
function onKey(e: KeyEvt): void {
if (e.key === " " || e.key === "Enter") { e.preventDefault(); togglePause(); }
else if (e.key === "r" || e.key === "R") { e.preventDefault(); restart(); }
}
function onClick(): void { togglePause(); }
function onResize(): void {
if (pausedRef.current || reduced()) draw((performance.now() - t0Ref.current) / 1000 % LOOP);
}
function frame(now: number): void {
if (!pausedRef.current) draw((now - t0Ref.current) / 1000);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
t0Ref.current = performance.now();
window.addEventListener("resize", onResize);
if (reduced()) {
const root = rootRef.current;
if (root) root.classList.add("is-still");
draw(LOOP * 0.62);
} 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 56v56a8 8 0 0 1-16 0V91.3l-25.6 25.7A80 80 0 1 1 174 60a8 8 0 1 1-9 13.2A64 64 0 1 0 179.7 128H216a8 8 0 0 1 0 16h-56a8 8 0 0 1-8-8V80a8 8 0 0 1 16 0v20.7L192.7 76A80.1 80.1 0 0 0 64 128a8 8 0 0 1-16 0a96 96 0 0 1 163.6-68.3L224 71.3V56a8 8 0 0 1 8 8" opacity=".2"/><path d="M224 48a8 8 0 0 0-8 8v15.3l-11.7-11.6a96 96 0 1 0 27.6 51.6a8 8 0 0 0-15.8 2.5a80 80 0 1 1-23-43l-25.7 25.7A8 8 0 0 0 173 112h56a8 8 0 0 0 8-8V56a8 8 0 0 0-13-8"/></g></svg>
<span>클릭하거나 스페이스바를 누르면 멈추거나 다시 갑니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onClick={onClick}
onKeyDown={onKey}
>
<div className={styles.bars}>
{BARS.map((_, i) => (
<i key={i} ref={(el: HTMLElement | null) => { barRefs.current[i] = el; }}></i>
))}
</div>
<b className={styles.badge} aria-hidden="true"><span className={styles.badgeNum} ref={pctRef}>듣는 중</span></b>
</div>
</div>
</main>
);
}07 · 입력 크기 값이 막대 높이를 흔드는 듣는 중 파형 — 측정 누적면적 17.854% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function draw(sec) {
var t = sec;
for (var i = 0; i < N; i++) {
var q = bars[i];
var env = 0.5 + 0.5 * Math.sin(t * q.freqA * Math.PI * 2 + q.phase);
var jit = Math.sin(t * q.freqB * Math.PI * 2 + q.phase * 1.7) * 0.22;
var level = clamp01(env + jit);
var h = BASE_H + (MAX_H - BASE_H) * level;
bels[i].style.height = h.toFixed(1) + 'px';
bels[i].style.opacity = (0.55 + level * 0.45).toFixed(2);
}
var avg = 0;
for (var j = 0; j < N; j++) avg += parseFloat(bels[j].style.height);
avg = Math.round((avg / N - BASE_H) / (MAX_H - BASE_H) * 100);
pctEl.textContent = avg > 8 ? '듣는 중' : '대기';
}
08토큰 순번 값이 오를 때마다 한 글자씩 드러나는 고스트 텍스트
미리 정해 둔 결과 문장을 글자 배열로 쪼개 두고, 토큰 순번 값이 하나씩 올라갈 때마다 그 순번까지의 글자만 옅은 고스트 색에서 실제 색으로 바뀌며 캐럿이 그 뒤를 따라갑니다. 토큰 순번 값이 하나 오를 때마다 그 순번까지의 글자 색만 다시 계산되고, 캐럿은 마지막으로 드러난 글자의 위치 값을 따라갑니다.

<main class="stage">
<div class="ld" id="ld">
<p class="ld__hint">
<svg class="ld__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M232 56v56a8 8 0 0 1-16 0V91.3l-25.6 25.7A80 80 0 1 1 174 60a8 8 0 1 1-9 13.2A64 64 0 1 0 179.7 128H216a8 8 0 0 1 0 16h-56a8 8 0 0 1-8-8V80a8 8 0 0 1 16 0v20.7L192.7 76A80.1 80.1 0 0 0 64 128a8 8 0 0 1-16 0a96 96 0 0 1 163.6-68.3L224 71.3V56a8 8 0 0 1 8 8" opacity=".2"/><path d="M224 48a8 8 0 0 0-8 8v15.3l-11.7-11.6a96 96 0 1 0 27.6 51.6a8 8 0 0 0-15.8 2.5a80 80 0 1 1-23-43l-25.7 25.7A8 8 0 0 0 173 112h56a8 8 0 0 0 8-8V56a8 8 0 0 0-13-8"/></g></svg>
<span>클릭하거나 스페이스바를 누르면 멈추거나 다시 갑니다</span>
</p>
<div class="ld__field" id="field" role="application" aria-label="고스트 텍스트 캐럿 리빌 실험판" tabindex="0">
<i class="ld__fill" id="fill" aria-hidden="true"></i>
<p class="ld__sentence" id="sentence"></p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ghost-text-caret-reveal — 미리 정해 둔 결과 문장을 글자 배열로 쪼개 두고, 토큰 순번 값이
// 하나씩 올라갈 때마다 그 순번까지의 글자만 옅은 고스트 색에서 실제 색으로 바뀌며 캐럿이
// 그 뒤를 따라간다. 순번 division + 글자별 fade 보간 + 캐럿 offsetLeft 추적. @keyframes 없음.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue-deep;
@include stage($stage-paper);
.ld {
--ghost: #c9c5cf;
--real: #{$subject-blue-deep};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.ld__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;
}
.ld__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-blue-deep; }
.ld__field {
position: relative; height: 208px;
display: flex; align-items: center;
border-radius: $r-card; overflow: hidden;
background: $subject-cream;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .12);
cursor: pointer;
padding: 0 $sp-5;
}
.ld__field:focus-visible { outline: 2px solid $subject-blue-deep; outline-offset: 2px; }
.ld__fill {
position: absolute; left: 0; top: 0; bottom: 0; width: 0;
background: rgba($subject-blue-deep, .14);
border-right: 1px solid rgba($subject-blue-deep, .28);
}
.ld__sentence {
position: relative; z-index: 1;
margin: 0; white-space: pre-wrap; word-break: keep-all;
font-weight: 700; font-size: clamp(20px, 5.6vw, 28px); line-height: 1.55;
}
.ld__ch { color: var(--ghost); }
.ld__caret {
position: absolute; left: 0; top: 0; z-index: 2;
width: 3px; height: 1.2em; border-radius: $r-xs;
background: var(--real);
transform-origin: top left;
}
@media (max-width: 340px) {
.ld { gap: $sp-1; }
.ld__field { height: 124px; padding: 0 $sp-3; }
.ld__sentence { font-size: 15px; line-height: 1.45; }
}
@media (prefers-reduced-motion: reduce) {
.ld.is-still .ld__field { cursor: default !important; }
.ld.is-still .ld__caret { opacity: 1 !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);
}
.ld {
--ghost: #c9c5cf;
--real: #0a4fe8;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.ld__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;
}
.ld__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #0a4fe8;
}
.ld__field {
position: relative;
height: 208px;
display: flex;
align-items: center;
border-radius: 16px;
overflow: hidden;
background: #fff7e6;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.12);
cursor: pointer;
padding: 0 20px;
}
.ld__field:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
.ld__fill {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 0;
background: rgba(10, 79, 232, 0.14);
border-right: 1px solid rgba(10, 79, 232, 0.28);
}
.ld__sentence {
position: relative;
z-index: 1;
margin: 0;
white-space: pre-wrap;
word-break: keep-all;
font-weight: 700;
font-size: clamp(20px, 5.6vw, 28px);
line-height: 1.55;
}
.ld__ch {
color: var(--ghost);
}
.ld__caret {
position: absolute;
left: 0;
top: 0;
z-index: 2;
width: 3px;
height: 1.2em;
border-radius: 4px;
background: var(--real);
transform-origin: top left;
}
@media (max-width: 340px) {
.ld {
gap: 4px;
}
.ld__field {
height: 124px;
padding: 0 12px;
}
.ld__sentence {
font-size: 15px;
line-height: 1.45;
}
}
@media (prefers-reduced-motion: reduce) {
.ld.is-still .ld__field {
cursor: default !important;
}
.ld.is-still .ld__caret {
opacity: 1 !important;
}
}(function () {
var LOOP = 2.0;
var AI_LOADER_TOKEN_REVEAL_MS = 62; // 토큰 순번 하나가 올라가는 데 걸리는 시간(ms)
var FADE_MS = 130; // 글자 하나가 고스트 색에서 실제 색으로 바뀌는 시간(ms)
var BLINK_MS = 280; // 캐럿 깜빡임 주기(ms)
var SENTENCE = '입력한 내용을 정리해 순서대로 알려드릴게요';
var root = document.getElementById('ld');
var field = document.getElementById('field');
var sentenceEl = document.getElementById('sentence');
var fill = document.getElementById('fill');
var paused = false;
var t0 = performance.now();
var raf = 0;
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function clamp01(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
function hexToRgb(hex) {
var n = parseInt(hex.replace('#', ''), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function mix(a, b, t) {
return [
Math.round(a[0] + (b[0] - a[0]) * t),
Math.round(a[1] + (b[1] - a[1]) * t),
Math.round(a[2] + (b[2] - a[2]) * t)
];
}
function rgbStr(c) { return 'rgb(' + c[0] + ',' + c[1] + ',' + c[2] + ')'; }
var chars = Array.prototype.slice.call(SENTENCE);
var LEN = chars.length;
var els = [];
chars.forEach(function (ch) {
var span = document.createElement('span');
span.className = 'ld__ch';
span.textContent = ch;
sentenceEl.appendChild(span);
els.push(span);
});
var caret = document.createElement('b');
caret.className = 'ld__caret';
caret.setAttribute('aria-hidden', 'true');
sentenceEl.appendChild(caret);
var GHOST = null, REAL = null;
function readColors() {
var cs = getComputedStyle(root);
GHOST = hexToRgb(cs.getPropertyValue('--ghost').trim());
REAL = hexToRgb(cs.getPropertyValue('--real').trim());
}
function draw(sec) {
if (!GHOST) readColors();
var t = sec % LOOP;
var elapsedMs = t * 1000;
var counter = Math.max(0, Math.min(LEN, Math.floor(elapsedMs / AI_LOADER_TOKEN_REVEAL_MS)));
for (var i = 0; i < LEN; i++) {
var revealAt = i * AI_LOADER_TOKEN_REVEAL_MS;
var fade = clamp01((elapsedMs - revealAt) / FADE_MS);
els[i].style.color = rgbStr(mix(GHOST, REAL, fade));
}
var target = counter < LEN ? els[counter] : els[LEN - 1];
if (target) {
var left = counter < LEN ? target.offsetLeft : target.offsetLeft + target.offsetWidth;
var top = target.offsetTop;
caret.style.transform = 'translate(' + left.toFixed(1) + 'px,' + top.toFixed(1) + 'px)';
fill.style.width = left.toFixed(1) + 'px';
}
var blinkOn = Math.floor(elapsedMs / BLINK_MS) % 2 === 0;
caret.style.opacity = blinkOn ? '1' : '.15';
}
function togglePause() { paused = !paused; root.classList.toggle('is-paused', paused); }
function restart() { t0 = performance.now(); draw(0); }
function onKey(e) {
if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); togglePause(); }
else if (e.key === 'r' || e.key === 'R') { e.preventDefault(); restart(); }
}
field.addEventListener('click', togglePause);
field.addEventListener('keydown', onKey);
function onResize() {
if (paused || reduced()) draw((performance.now() - t0) / 1000 % LOOP);
}
window.addEventListener('resize', onResize);
function frame(now) {
if (!paused) draw((now - t0) / 1000);
raf = requestAnimationFrame(frame);
}
if (reduced()) {
root.classList.add('is-still');
draw(LOOP * 0.62);
} else {
raf = requestAnimationFrame(frame);
}
})();// GhostCaret — vanilla 판(run/403/demo/08-ai-processing-loading-ghost-text-caret-reveal/index.html) 의 React 포트.
// 미리 정해 둔 결과 문장을 글자 배열로 쪼개 두고, 토큰 순번 값이 하나씩 올라갈 때마다 그 순번까지의
// 글자만 옅은 고스트 색에서 실제 색으로 바뀌며 캐럿이 그 뒤를 따라간다. 실 스트리밍 연결 없음.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./GhostCaret.module.scss";
export type GhostCaretProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
const LOOP = 2.0;
const AI_LOADER_TOKEN_REVEAL_MS = 62;
const FADE_MS = 130;
const BLINK_MS = 280;
const SENTENCE = "입력한 내용을 정리해 순서대로 알려드릴게요";
const CHARS = Array.from(SENTENCE);
const LEN = CHARS.length;
const FIELD_LABEL = "고스트 텍스트 캐럿 리빌 실험판";
function clamp01(v: number): number { return v < 0 ? 0 : v > 1 ? 1 : v; }
function hexToRgb(hex: string): [number, number, number] {
const n = parseInt(hex.replace("#", ""), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function mix(a: [number, number, number], b: [number, number, number], t: number): [number, number, number] {
return [
Math.round(a[0] + (b[0] - a[0]) * t),
Math.round(a[1] + (b[1] - a[1]) * t),
Math.round(a[2] + (b[2] - a[2]) * t),
];
}
function rgbStr(c: [number, number, number]): string { return "rgb(" + c[0] + "," + c[1] + "," + c[2] + ")"; }
export default function GhostCaret({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#0a4fe8",
}: GhostCaretProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const fillRef = useRef<HTMLElement | null>(null);
const caretRef = useRef<HTMLElement | null>(null);
const chRefs = useRef<(HTMLElement | null)[]>([]) as Box<(HTMLElement | null)[]>;
const pausedRef = useRef(false) as Box<boolean>;
const t0Ref = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const colorsRef = useRef<{ ghost: [number, number, number]; real: [number, number, number] } | null>(null);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function readColors(): void {
const root = rootRef.current;
if (!root) return;
const cs = getComputedStyle(root);
colorsRef.current = {
ghost: hexToRgb(cs.getPropertyValue("--ghost").trim()),
real: hexToRgb(cs.getPropertyValue("--real").trim()),
};
}
function draw(sec: number): void {
if (!colorsRef.current) readColors();
const colors = colorsRef.current;
if (!colors) return;
const t = sec % LOOP;
const elapsedMs = t * 1000;
const counter = Math.max(0, Math.min(LEN, Math.floor(elapsedMs / AI_LOADER_TOKEN_REVEAL_MS)));
for (let i = 0; i < LEN; i++) {
const revealAt = i * AI_LOADER_TOKEN_REVEAL_MS;
const fade = clamp01((elapsedMs - revealAt) / FADE_MS);
const el = chRefs.current[i];
if (el) el.style.color = rgbStr(mix(colors.ghost, colors.real, fade));
}
const target = counter < LEN ? chRefs.current[counter] : chRefs.current[LEN - 1];
const caret = caretRef.current;
const fill = fillRef.current;
if (target && caret && fill) {
const left = counter < LEN ? target.offsetLeft : target.offsetLeft + target.offsetWidth;
const top = target.offsetTop;
caret.style.transform = "translate(" + left.toFixed(1) + "px," + top.toFixed(1) + "px)";
fill.style.width = left.toFixed(1) + "px";
}
const blinkOn = Math.floor(elapsedMs / BLINK_MS) % 2 === 0;
if (caret) caret.style.opacity = blinkOn ? "1" : ".15";
}
function togglePause(): void {
pausedRef.current = !pausedRef.current;
const root = rootRef.current;
if (root) root.classList.toggle("is-paused", pausedRef.current);
}
function restart(): void { t0Ref.current = performance.now(); draw(0); }
function onKey(e: KeyEvt): void {
if (e.key === " " || e.key === "Enter") { e.preventDefault(); togglePause(); }
else if (e.key === "r" || e.key === "R") { e.preventDefault(); restart(); }
}
function onClick(): void { togglePause(); }
function onResize(): void {
if (pausedRef.current || reduced()) draw((performance.now() - t0Ref.current) / 1000 % LOOP);
}
function frame(now: number): void {
if (!pausedRef.current) draw((now - t0Ref.current) / 1000);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
t0Ref.current = performance.now();
window.addEventListener("resize", onResize);
if (reduced()) {
const root = rootRef.current;
if (root) root.classList.add("is-still");
draw(LOOP * 0.62);
} 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 56v56a8 8 0 0 1-16 0V91.3l-25.6 25.7A80 80 0 1 1 174 60a8 8 0 1 1-9 13.2A64 64 0 1 0 179.7 128H216a8 8 0 0 1 0 16h-56a8 8 0 0 1-8-8V80a8 8 0 0 1 16 0v20.7L192.7 76A80.1 80.1 0 0 0 64 128a8 8 0 0 1-16 0a96 96 0 0 1 163.6-68.3L224 71.3V56a8 8 0 0 1 8 8" opacity=".2"/><path d="M224 48a8 8 0 0 0-8 8v15.3l-11.7-11.6a96 96 0 1 0 27.6 51.6a8 8 0 0 0-15.8 2.5a80 80 0 1 1-23-43l-25.7 25.7A8 8 0 0 0 173 112h56a8 8 0 0 0 8-8V56a8 8 0 0 0-13-8"/></g></svg>
<span>클릭하거나 스페이스바를 누르면 멈추거나 다시 갑니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onClick={onClick}
onKeyDown={onKey}
>
<i className={styles.fill} ref={fillRef} aria-hidden="true"></i>
<p className={styles.sentence}>
{CHARS.map((ch, i) => (
<span key={i} className={styles.ch} ref={(el: HTMLElement | null) => { chRefs.current[i] = el; }}>{ch}</span>
))}
<b className={styles.caret} ref={caretRef} aria-hidden="true"></b>
</p>
</div>
</div>
</main>
);
}08 · 토큰 순번 값이 오를 때마다 한 글자씩 드러나는 고스트 텍스트 — 측정 누적면적 46.62% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function draw(sec) {
if (!GHOST) readColors();
var t = sec % LOOP;
var elapsedMs = t * 1000;
var counter = Math.max(0, Math.min(LEN, Math.floor(elapsedMs / AI_LOADER_TOKEN_REVEAL_MS)));
for (var i = 0; i < LEN; i++) {
var revealAt = i * AI_LOADER_TOKEN_REVEAL_MS;
var fade = clamp01((elapsedMs - revealAt) / FADE_MS);
els[i].style.color = rgbStr(mix(GHOST, REAL, fade));
}
var target = counter < LEN ? els[counter] : els[LEN - 1];
if (target) {
var left = counter < LEN ? target.offsetLeft : target.offsetLeft + target.offsetWidth;
var top = target.offsetTop;
caret.style.transform = 'translate(' + left.toFixed(1) + 'px,' + top.toFixed(1) + 'px)';
fill.style.width = left.toFixed(1) + 'px';
}
var blinkOn = Math.floor(elapsedMs / BLINK_MS) % 2 === 0;
caret.style.opacity = blinkOn ? '1' : '.15';
}
09동작 줄임 값을 감지하면 즉시 문구로 바뀌는 처리중 배지
시스템의 동작 줄임 설정 값을 감지해, 그 값이 켜져 있으면 위 여덟 패턴 대신 애니메이션 없이 '처리 중' 문구 배지가 바로 나타나고, 꺼져 있을 때만 원래 속도로 움직입니다. 동작 줄임이 꺼져 있을 때는 점 세 개가 사인 값으로 튀어 오르고, 켜지는 순간에는 스프링 감쇠 값 하나로 배지가 한 번만 튀어 오른 뒤 멈춥니다.

<main class="stage">
<div class="ld" id="ld">
<p class="ld__hint">
<svg class="ld__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M232 56v56a8 8 0 0 1-16 0V91.3l-25.6 25.7A80 80 0 1 1 174 60a8 8 0 1 1-9 13.2A64 64 0 1 0 179.7 128H216a8 8 0 0 1 0 16h-56a8 8 0 0 1-8-8V80a8 8 0 0 1 16 0v20.7L192.7 76A80.1 80.1 0 0 0 64 128a8 8 0 0 1-16 0a96 96 0 0 1 163.6-68.3L224 71.3V56a8 8 0 0 1 8 8" opacity=".2"/><path d="M224 48a8 8 0 0 0-8 8v15.3l-11.7-11.6a96 96 0 1 0 27.6 51.6a8 8 0 0 0-15.8 2.5a80 80 0 1 1-23-43l-25.7 25.7A8 8 0 0 0 173 112h56a8 8 0 0 0 8-8V56a8 8 0 0 0-13-8"/></g></svg>
<span>클릭하거나 스페이스바를 누르면 멈추거나 다시 갑니다</span>
</p>
<div class="ld__field" id="field" role="application" aria-label="동작 줄임 배지 전환 실험판" tabindex="0">
<div class="ld__scene">
<div class="ld__dots" id="dots" aria-hidden="true">
<i></i><i></i><i></i>
</div>
<div class="ld__badge" id="badge">
<b class="ld__badgeDot" aria-hidden="true"></b>
<span id="badgeText">처리 중</span>
</div>
</div>
<p class="ld__state" id="state">동작 줄임: 꺼짐 · 움직이는 중</p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// reduced-motion-static-badge — 동작 줄임 설정 값을 감지해, 그 값이 켜져 있으면 애니메이션 없이
// '처리 중' 문구 배지가 즉시 나타나고, 꺼져 있을 때만 원래 속도로 움직인다. 이 항목 자체가
// 접근성 정지 프레임의 주제 — 루프 절반은 바운스 점(사인), 절반은 스프링 오버슛 배지. @keyframes 없음.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-ink);
.ld {
--dot: #{$subject-blue-light};
--badge-bg: #{$subject-cream};
--badge-fg: #{$stage-ink};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.ld__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;
}
.ld__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-cream; }
.ld__field {
position: relative; height: 208px;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: $sp-3;
border-radius: $r-card; overflow: hidden;
background: rgba($subject-cream, .06);
box-shadow: inset 0 0 0 1px rgba($subject-cream, .18);
cursor: pointer;
}
.ld__field:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
.ld__scene { position: relative; width: 100%; height: 64px; display: flex; align-items: center; justify-content: center; }
.ld__dots {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center; gap: $sp-2;
}
.ld__dots > i {
width: 14px; height: 14px; border-radius: 50%;
background: var(--dot);
}
.ld__badge {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center; gap: $sp-2;
opacity: 0; transform: scale(.7);
}
.ld__badgeDot { width: 10px; height: 10px; border-radius: 50%; background: var(--badge-fg); }
.ld__badge span {
padding: $sp-2 $sp-4; border-radius: $r-pill;
background: var(--badge-bg); color: var(--badge-fg);
font-weight: 700; font-size: 15px;
}
.ld__state {
margin: 0; font-size: 12px; font-weight: 600; color: rgba($subject-cream, .78);
}
@media (max-width: 340px) {
.ld { gap: $sp-1; }
.ld__field { height: 124px; gap: $sp-2; }
.ld__scene { height: 44px; }
.ld__dots > i { width: 10px; height: 10px; }
.ld__state { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.ld.is-still .ld__field { cursor: default !important; }
.ld.is-still .ld__badge { opacity: 1 !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);
}
.ld {
--dot: #5b8cf8;
--badge-bg: #fff7e6;
--badge-fg: #17141a;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.ld__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;
}
.ld__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #fff7e6;
}
.ld__field {
position: relative;
height: 208px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
border-radius: 16px;
overflow: hidden;
background: rgba(255, 247, 230, 0.06);
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.18);
cursor: pointer;
}
.ld__field:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.ld__scene {
position: relative;
width: 100%;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
}
.ld__dots {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.ld__dots > i {
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--dot);
}
.ld__badge {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
opacity: 0;
transform: scale(0.7);
}
.ld__badgeDot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--badge-fg);
}
.ld__badge span {
padding: 8px 16px;
border-radius: 999px;
background: var(--badge-bg);
color: var(--badge-fg);
font-weight: 700;
font-size: 15px;
}
.ld__state {
margin: 0;
font-size: 12px;
font-weight: 600;
color: rgba(255, 247, 230, 0.78);
}
@media (max-width: 340px) {
.ld {
gap: 4px;
}
.ld__field {
height: 124px;
gap: 8px;
}
.ld__scene {
height: 44px;
}
.ld__dots > i {
width: 10px;
height: 10px;
}
.ld__state {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.ld.is-still .ld__field {
cursor: default !important;
}
.ld.is-still .ld__badge {
opacity: 1 !important;
}
}(function () {
var LOOP = 2.0;
var SWITCH_AT = LOOP * 0.5; // 동작 줄임 값이 켜졌다고 보는 시각
var SPRING_MS = 0.36; // 배지가 나타날 때 스프링 오버슛 시간
var BOUNCE_HZ = 2.4; // 점 세 개 바운스 진동수
var root = document.getElementById('ld');
var field = document.getElementById('field');
var dots = document.getElementById('dots').children;
var badge = document.getElementById('badge');
var stateEl = document.getElementById('state');
var paused = false;
var t0 = performance.now();
var raf = 0;
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function clamp01(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
// 임계 감쇠 스프링 근사(오버슛 포함): t=0 에서 0, t=1 부근에서 1을 살짝 넘겼다 정착
function spring(t) {
var x = clamp01(t);
return 1 - Math.exp(-6 * x) * Math.cos(7 * x);
}
function draw(sec) {
var t = sec % LOOP;
var on = t >= SWITCH_AT; // 동작 줄임 값(시뮬레이션)
if (on) {
var localT = (t - SWITCH_AT) / SPRING_MS;
var s = spring(localT);
badge.style.opacity = '1';
badge.style.transform = 'scale(' + (0.7 + 0.3 * s).toFixed(3) + ')';
dots.length && (root.querySelector('.ld__dots').style.opacity = '0');
stateEl.textContent = '동작 줄임: 켜짐 · 처리 중 배지';
} else {
badge.style.opacity = '0';
badge.style.transform = 'scale(.7)';
root.querySelector('.ld__dots').style.opacity = '1';
for (var i = 0; i < dots.length; i++) {
var phase = i * (Math.PI * 2 / 3);
var y = Math.sin(t * BOUNCE_HZ * Math.PI * 2 + phase);
dots[i].style.transform = 'translateY(' + (y * 7).toFixed(1) + 'px)';
dots[i].style.opacity = (0.55 + 0.45 * (0.5 + 0.5 * y)).toFixed(2);
}
stateEl.textContent = '동작 줄임: 꺼짐 · 움직이는 중';
}
}
function togglePause() { paused = !paused; root.classList.toggle('is-paused', paused); }
function restart() { t0 = performance.now(); draw(0); }
function onKey(e) {
if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); togglePause(); }
else if (e.key === 'r' || e.key === 'R') { e.preventDefault(); restart(); }
}
field.addEventListener('click', togglePause);
field.addEventListener('keydown', onKey);
function onResize() {
if (paused || reduced()) draw((performance.now() - t0) / 1000 % LOOP);
}
window.addEventListener('resize', onResize);
function frame(now) {
if (!paused) draw((now - t0) / 1000);
raf = requestAnimationFrame(frame);
}
if (reduced()) {
root.classList.add('is-still');
draw(SWITCH_AT + SPRING_MS); // 배지가 이미 자리 잡은 정지 프레임
} else {
raf = requestAnimationFrame(frame);
}
})();// ReduceBadge — vanilla 판(run/403/demo/09-ai-processing-loading-reduced-motion-static-badge/index.html) 의 React 포트.
// 동작 줄임 설정 값을 감지해, 그 값이 켜져 있으면 애니메이션 없이 '처리 중' 문구 배지가 즉시
// 나타나고, 꺼져 있을 때만 원래 속도로 움직인다. 이 항목 자체가 접근성 정지 프레임의 주제다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./ReduceBadge.module.scss";
export type ReduceBadgeProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
const LOOP = 2.0;
const SWITCH_AT = LOOP * 0.5;
const SPRING_MS = 0.36;
const BOUNCE_HZ = 2.4;
const FIELD_LABEL = "동작 줄임 배지 전환 실험판";
function clamp01(v: number): number { return v < 0 ? 0 : v > 1 ? 1 : v; }
function spring(t: number): number {
const x = clamp01(t);
return 1 - Math.exp(-6 * x) * Math.cos(7 * x);
}
export default function ReduceBadge({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#fff7e6",
}: ReduceBadgeProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const dotsWrapRef = useRef<HTMLDivElement | null>(null);
const dotRefs = useRef<(HTMLElement | null)[]>([]) as Box<(HTMLElement | null)[]>;
const badgeRef = useRef<HTMLDivElement | null>(null);
const stateRef = useRef<HTMLParagraphElement | null>(null);
const pausedRef = useRef(false) as Box<boolean>;
const t0Ref = 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 draw(sec: number): void {
const dotsWrap = dotsWrapRef.current;
const badge = badgeRef.current;
const stateEl = stateRef.current;
if (!dotsWrap || !badge) return;
const t = sec % LOOP;
const on = t >= SWITCH_AT;
if (on) {
const localT = (t - SWITCH_AT) / SPRING_MS;
const s = spring(localT);
badge.style.opacity = "1";
badge.style.transform = "scale(" + (0.7 + 0.3 * s).toFixed(3) + ")";
dotsWrap.style.opacity = "0";
if (stateEl) stateEl.textContent = "동작 줄임: 켜짐 · 처리 중 배지";
} else {
badge.style.opacity = "0";
badge.style.transform = "scale(.7)";
dotsWrap.style.opacity = "1";
for (let i = 0; i < dotRefs.current.length; i++) {
const phase = i * (Math.PI * 2 / 3);
const y = Math.sin(t * BOUNCE_HZ * Math.PI * 2 + phase);
const el = dotRefs.current[i];
if (el) {
el.style.transform = "translateY(" + (y * 7).toFixed(1) + "px)";
el.style.opacity = (0.55 + 0.45 * (0.5 + 0.5 * y)).toFixed(2);
}
}
if (stateEl) stateEl.textContent = "동작 줄임: 꺼짐 · 움직이는 중";
}
}
function togglePause(): void {
pausedRef.current = !pausedRef.current;
const root = rootRef.current;
if (root) root.classList.toggle("is-paused", pausedRef.current);
}
function restart(): void { t0Ref.current = performance.now(); draw(0); }
function onKey(e: KeyEvt): void {
if (e.key === " " || e.key === "Enter") { e.preventDefault(); togglePause(); }
else if (e.key === "r" || e.key === "R") { e.preventDefault(); restart(); }
}
function onClick(): void { togglePause(); }
function onResize(): void {
if (pausedRef.current || reduced()) draw((performance.now() - t0Ref.current) / 1000 % LOOP);
}
function frame(now: number): void {
if (!pausedRef.current) draw((now - t0Ref.current) / 1000);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
t0Ref.current = performance.now();
window.addEventListener("resize", onResize);
if (reduced()) {
const root = rootRef.current;
if (root) root.classList.add("is-still");
draw(SWITCH_AT + SPRING_MS);
} 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 56v56a8 8 0 0 1-16 0V91.3l-25.6 25.7A80 80 0 1 1 174 60a8 8 0 1 1-9 13.2A64 64 0 1 0 179.7 128H216a8 8 0 0 1 0 16h-56a8 8 0 0 1-8-8V80a8 8 0 0 1 16 0v20.7L192.7 76A80.1 80.1 0 0 0 64 128a8 8 0 0 1-16 0a96 96 0 0 1 163.6-68.3L224 71.3V56a8 8 0 0 1 8 8" opacity=".2"/><path d="M224 48a8 8 0 0 0-8 8v15.3l-11.7-11.6a96 96 0 1 0 27.6 51.6a8 8 0 0 0-15.8 2.5a80 80 0 1 1-23-43l-25.7 25.7A8 8 0 0 0 173 112h56a8 8 0 0 0 8-8V56a8 8 0 0 0-13-8"/></g></svg>
<span>클릭하거나 스페이스바를 누르면 멈추거나 다시 갑니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onClick={onClick}
onKeyDown={onKey}
>
<div className={styles.scene}>
<div className={styles.dots} ref={dotsWrapRef} aria-hidden="true">
<i ref={(el: HTMLElement | null) => { dotRefs.current[0] = el; }}></i>
<i ref={(el: HTMLElement | null) => { dotRefs.current[1] = el; }}></i>
<i ref={(el: HTMLElement | null) => { dotRefs.current[2] = el; }}></i>
</div>
<div className={styles.badge} ref={badgeRef}>
<b className={styles.badgeDot} aria-hidden="true"></b>
<span>처리 중</span>
</div>
</div>
<p className={styles.state} ref={stateRef}>동작 줄임: 꺼짐 · 움직이는 중</p>
</div>
</div>
</main>
);
}09 · 동작 줄임 값을 감지하면 즉시 문구로 바뀌는 처리중 배지 — 측정 누적면적 2.808% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function draw(sec) {
var t = sec % LOOP;
var on = t >= SWITCH_AT; // 동작 줄임 값(시뮬레이션)
if (on) {
var localT = (t - SWITCH_AT) / SPRING_MS;
var s = spring(localT);
badge.style.opacity = '1';
badge.style.transform = 'scale(' + (0.7 + 0.3 * s).toFixed(3) + ')';
dots.length && (root.querySelector('.ld__dots').style.opacity = '0');
stateEl.textContent = '동작 줄임: 켜짐 · 처리 중 배지';
} else {
badge.style.opacity = '0';
badge.style.transform = 'scale(.7)';
root.querySelector('.ld__dots').style.opacity = '1';
for (var i = 0; i < dots.length; i++) {
var phase = i * (Math.PI * 2 / 3);
var y = Math.sin(t * BOUNCE_HZ * Math.PI * 2 + phase);
dots[i].style.transform = 'translateY(' + (y * 7).toFixed(1) + 'px)';
dots[i].style.opacity = (0.55 + 0.45 * (0.5 + 0.5 * y)).toFixed(2);
}
stateEl.textContent = '동작 줄임: 꺼짐 · 움직이는 중';
}
}
아홉 개 값이 실제로 얼마나 움직였는지 (23프레임 측정)
| 항목 | 누적면적 | 강도_평균 | 움직인프레임 |
|---|---|---|---|
| 01 후광 링 | 22.14% | 30.8 | 23/23 |
| 02 오브 펄스 | 30.09% | 40.9 | 23/23 |
| 03 입자 소용돌이 | 25.46% | 60.7 | 23/23 |
| 04 신경망 펄스 | 11.58% | 69.1 | 23/23 |
| 05 카드 스트림 | 14.26% | 105.4 | 20/23 |
| 06 버튼 아이콘 스파클 | 10.11% | 175.4 | 23/23 |
| 07 듣는 중 파형 | 17.85% | 100.9 | 23/23 |
| 08 고스트 텍스트 | 46.62% | 52.6 | 20/23 |
| 09 처리중 배지 | 2.81% | 120.3 | 17/23 |
누적면적이 가장 큰 값은 08 고스트 텍스트(46.62%)로, 글자 배열 전체의 색이 매 프레임 다시 계산되기 때문입니다. 반대로 09 배지는 2.81%로 가장 작은데, 동작 줄임 값 하나만 보고 정지 프레임 근처에서 점 세 개만 작게 움직이는 정적 배지라 원래 면적이 작은 게 정상입니다 — 09를 억지로 키우지 않고 그대로 둔 이유이기도 합니다. 05·08 두 항목만 20/23 프레임에서 값이 고정 구간에 머물러 움직인프레임이 23 에 못 미칩니다.
실제 화면 어디에 이 아홉 개를 붙이면 좋을까
정적 ai 로딩 이미지 한 장만 걸어두는 것보다, 01 후광 링이나 08 고스트 텍스트처럼 값이 실제로 움직이는 쪽이 사용자에게 진행 신호를 더 분명히 전달합니다. 요약·정리·초안 생성처럼 몇 초 이상 걸리는 화면에서 결과 영역 위에 얹어 두면, 사용자가 새로고침을 누르기 전에 지금 계산 중이라는 값을 계속 보여줄 수 있습니다.
채팅형 인터페이스라면 03 입자 소용돌이로 입력을 받는 단계를, 07 파형으로 음성을 듣는 단계를 서로 다른 항목으로 나눠 붙이는 편이 자연스럽고, 카드형 목록에서 항목 하나씩 결과가 채워지는 화면이라면 05 스트림 로딩이 줄 단위 진행을 그대로 보여줍니다.
동작 줄임 설정을 켠 사용자에게는 09 배지처럼 애니메이션 없이 문구만 바뀌는 쪽을 기본값으로 깔아 두는 게 안전합니다.
값 하나만 바꿔서 느낌이 어떻게 달라지는지 확인해보세요 (변형 3종)
아래 세 값만 각각 바꿔서 재생해보면 같은 아홉 개 구조 안에서도 느낌이 크게 갈립니다.
| 이름 | 바꾼 값 | 느낌 |
|---|---|---|
| 후광 링 색상 전환 속도 값 조정 | 01 AI_LOADER_HUE_TRANSITION_MS 640 → 260 | 값을 짧게 줄이면 진행 값이 살짝만 올라도 색이 훅 넘어가 완료 직전처럼 보이고, 값을 늘리면 색이 거의 끝까지 가야 넘어가 여유 있게 느껴져야 합니다. |
| 입자 궤도 반경 축소 이징 값 조정 | 03 AI_LOADER_ORBIT_SHRINK_EASE 2.2 → 4.8 | 이징 값을 선형에 가깝게 두면 입자가 일정한 속도로 코어에 모여드는 느낌이고, 값을 급격한 곡선으로 바꾸면 초반엔 거의 안 움직이다 막판에 확 모여드는 쪽에 가까워집니다. |
| 고스트 텍스트 토큰 노출 간격 값 조정 | 08 AI_LOADER_TOKEN_REVEAL_MS 62 → 26 | 간격 값을 짧게 줄이면 글자가 거의 한꺼번에 쏟아지듯 나와야 하고, 값을 늘리면 한 글자씩 또박또박 읽히는 속도에 가까워집니다. |
값을 코드에서 직접 바꿔보며 아홉 개 원본 파일을 전부 손에 쥐고 싶다면 379w2wxy 을 입력해 압축을 풀면 됩니다.
접근성 — 동작 줄임을 켜면 아홉 개 모두 값 하나로 멈춥니다
아홉 항목 모두 prefers-reduced-motion 값이 reduce 이면 requestAnimationFrame 루프를 아예 시작하지 않고, 진행률 62% 지점(LOOP * 0.62)에 해당하는 프레임 한 장만 draw() 로 그려 넣고 멈춥니다. 09 배지만 예외로, 스프링이 끝난 시점(SWITCH_AT + SPRING_MS)의 정지 화면을 한 번 그립니다.
실험판 요소마다 role="application" 값과 tabindex="0" 값이 들어 있어 Tab 으로 초점을 옮길 수 있고, 항목마다 다른 aria-label 값을 읽어 줘 스크린리더 사용자도 지금 어떤 로딩인지 구분할 수 있습니다. 접근성 값은 코드에 이미 반영돼 있어 별도 설정 없이 시스템 설정만 따라갑니다.
자주 묻는 질문 (FAQ)
이 로딩 애니메이션이 실제 AI 처리 속도와 맞아떨어지나요?
아니요. 아홉 개 모두 LOOP 초 단위로 정해진 고정 시간 동안 반복되는 값이라, 실제 서버 응답이 그보다 빠르거나 늦어도 애니메이션 자체는 같은 리듬으로 돕니다. 진짜 완료 신호는 여러분의 코드에서 API 응답을 받는 시점에 별도로 연결해야 합니다.
항목마다 값을 바꾸면 파일도 따로 받아야 하나요?
아니요. 값 하나씩 실험해보고 싶다면 위쪽 변형 표 옆에서 압축 파일을 받아 아홉 개 HTML·CSS·JS 를 로컬에서 직접 열어보면 됩니다.
ai 로딩 중 상태가 화면 어디에 있는지 사용자가 못 찾으면 어떻게 하나요?
role="application" 값과 tabindex="0" 값이 실험판 요소에 들어 있어 Tab 으로 초점을 옮길 수 있고, aria-label 값이 항목마다 다른 이름을 읽어 줘 스크린리더 사용자도 지금 어떤 로딩인지 구분할 수 있습니다.









