AI処理中 アイコン 9パターン — 値で動くローディング
処理中 アイコンとは:軌道半径の値が大きいと粒子は広く散らばるが、値が0に近づくとコアに集まり、その間ハローリングの色相の値は完了色へ動き、トークン番号の値が上がるたびプレビューの文字が一つずつ増える。
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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 色相値が完了色へ動くハローリングAIローダー
- 02 待機時間の値が伸びるほど呼吸が広がるオーブパルス
- 03 軌道半径の値が縮みコアに集まる粒子の渦
- 04 遅延値をたどってノードを渡るニューラルパルス
- 05 埋まった行数の値が増える結果カードのストリーム読み込み
- 06 完了値が反転すると輝きに変わるボタンアイコン
- 07 入力レベルの値がバーの高さを揺らす聴き取り中波形
- 08 トークン番号の値が上がるたび一文字ずつ現れるゴーストテキスト
- 09 モーション低減値を検知すると即座に文言へ変わる処理中バッジ
これから紹介する九つはすべて requestAnimationFrame の上で動いており、画面に何を出すかを決める材料はただ一つ、0から1の進捗値か経過時間の値、または一度だけ反転する真偽値です。モデルもAPIもストリーミングも呼び出していません。08番の「表示された」文もページのソースにすでに書かれた固定文字列で、残り八つも同じ種類のタイマー駆動の数値を別のCSSプロパティやcanvasの描画呼び出しに振り分けているだけです。以下の順番は、01番の色相値による単純な一プロパティ更新から、09番のように値が表示中のマークアップ全体を丸ごと入れ替えるところまで進みます。
9つのパターンに入る前に、どこで壊れるかを先に知っておきます
九つとも固定の2秒サイクル(LOOP = 2.0)で回っていて、そのサイクルは本物のリクエストが実際どれだけかかるかをまったく知りません。01番を本物のAPI呼び出しの前に置いて停止条件を一切書かなければ、リングは完了色へ動いては開始色へ戻ることを2秒ごとに無限に繰り返します。三周目、四周目を見ているユーザーにとってそれは「まだ処理中」ではなく「壊れている」としか読めません。アニメーション自体には、これがシミュレーションだと伝える手段が何もないからです。01番のAI_LOADER_HUE_TRANSITION_MSの窓や09番のSWITCH_ATによる分岐は本物の進捗マーカーのように見えますが、実際はLOOPに結びついたカレンダー時間の定数であって、実際の完了イベントとは無関係です——これはデモの既定値であって約束ではないと考えるべきです。期待値を正しく管理するとは、自分の実際のリクエストが解決した瞬間に本物の停止関数を呼ぶこと(requestAnimationFrameを取り消し、完了状態に切り替えること)であって、「処理中」が真でなくなった地点を過ぎても九つのどれかをループさせ続けないことです。
9つのAIローディングパターンを測ります
以下の数値は各デモ自身の画面録画パスから得たものです。累積面積はループ全体でタイルのピクセルがどれだけ変化したかを示し、平均強度は変化した箇所のピクセルごとの平均バイト差、動いたフレーム数は23回のフレーム間差分のうち実際にピクセルが動いた回数です。
| # | パターン | 累積面積 | 平均強度 | 動いたフレーム |
|---|---|---|---|---|
| 01 | ハローリング色相シフト | 22.139% | 30.8 | 23/23 |
| 02 | オーブモーフパルス | 30.089% | 40.9 | 23/23 |
| 03 | 粒子の渦とコア収束 | 25.456% | 60.7 | 23/23 |
| 04 | ニューラルノードパルス | 11.583% | 69.1 | 23/23 |
| 05 | カードコンテンツストリーム充填 | 14.256% | 105.4 | 20/23 |
| 06 | ボタンアイコンスパークルモーフ | 10.111% | 175.4 | 23/23 |
| 07 | 聴き取り中波形 | 17.854% | 100.9 | 23/23 |
| 08 | ゴーストテキストキャレット表示 | 46.62% | 52.6 | 20/23 |
| 09 | モーション低減時の静止バッジ | 2.808% | 120.3 | 17/23 |
08番のゴーストテキスト表示は文中のほぼすべての文字が有効なフレームごとに色を塗り直すため、累積面積が46.62%ともっとも大きくなります。一方09番は2.808%ともっとも低く、これは意図した設計です——モーション低減が検知された時点で動きをすべて静止テキストのバッジに置き換えることが目的なので、面積が小さいこと自体が不足ではなく正解です。09番の動いたフレームが17/23と他より少ないのも同じ理由で、バッジは現れる瞬間とドットが止まる瞬間の二回だけ描き直せば十分で、23回の全サンプルで動く必要がありません。
01色相値が完了色へ動くハローリングAIローダー
conic-gradientで描いたリングは一定のROTATE_SPEEDで回り続ける一方、色相の値はループ末尾のAI_LOADER_HUE_TRANSITION_MSの窓に入ってようやく完了色へ動き始めます。smooth()による三次イージングがその窓の中でBLUEからDONEへ滑らかに混ぜ合わせるので、色の変化が唐突な切り替えではなく落ち着いた収束として見えます。リングとコアは毎フレーム同じ補間色を共有しており、二つの要素がばらばらに動いて見えることがありません。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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待機時間の値が伸びるほど呼吸が広がるオーブパルス
accumの値がループ全体で0から1へ上がり、それをeaseSine()に通すことで、オーブの呼吸そのものの振れ幅がループの経過とともに広がっていきます。実際の呼吸の上下運動は別のBREATH_HZサイン波が担っており、スケール・ぼかし・グローの三つはすべて同じ合成値から毎フレーム読み出されるため、広い呼吸は必ず柔らかい輪郭と明るいグローを同時に伴います。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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軌道半径の値が縮みコアに集まる粒子の渦
26個の粒子は黄金角(約137.508度)ずつずらして配置されており、同じ光線上に二つ重なることがありません。それぞれの粒子は自分専用の軌道半径の値を持ち、進捗が上がるにつれてMAX_RからCORE_Rへ向かって縮んでいきますが、その縮み方はAI_LOADER_ORBIT_SHRINK_EASEでイージングされているため線形ではありません。粒子ごとのjitter値が同じ進捗地点でも半径をわずかに違わせているおかげで、渦が一枚のリングとして均一に収縮するのではなく、緩やかな雲のように見えます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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;
}
04遅延値をたどってノードを渡るニューラルパルス
4列3行のノード格子が辺でつながれており、各ノードの遅延値は起点の角からの格子距離(dist * STEP、さらに小さなノードごとのゆらぎを加えたもの)で決まるため、パルスはいつも左上から始まって外側へ広がります。各辺は両端の遅延値のうち小さい方を受け継ぎ、経過時間がその遅延を超えると暗い線の上にもう一本明るい線が重なって描かれ、EDGE_MSの間に辺の長さの0%から100%まで伸びます——光は本当にノードからノードへ伝わっていくのであって、すべての線が一斉に光るわけではありません。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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();
}
}
05埋まった行数の値が増える結果カードのストリーム読み込み
6本のスケルトン行はそれぞれインデックス番号にLINE_MSを掛けた分だけずらして充填を始め、そこからFILL_MSの間にsmooth()イージングで幅を0%から100%まで伸ばしつつ、色もSKELETONの平板な色調からTEXTの本文色調へ同じ速さで混ざっていきます。doneCountの集計はある行の局所進捗が本当に1に達したときだけ増えるので、パーセント表示は「充填し始めた行数」ではなく「完全に実色になった行数」と正確に一致します。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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完了値が反転すると輝きに変わるボタンアイコン
スピナー形と輝き(スパークル)形は同じ8個の半径値として、異なる大きさ(スピナー用のR_RING、交互のR_OUT・R_IN)であらかじめ用意されています。DONEという真偽値がtrueに反転した瞬間、二つの独立したSVGパスを入れ替えるのではなく、morphTがMORPH_MSの間にその8個の半径値を一方の形の数値からもう一方へイージングしながら補間します。反転後の0.5秒間は短いスケールのパルスが重なり、スピナーの回転自体も反転後は元の速度の12%まで落ちるので、完了したはずの形の下でスピナーがまだ回り続けているようには見えません。
<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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 ? '完了' : '処理中';
07入力レベルの値がバーの高さを揺らす聴き取り中波形
20本のバーはそれぞれ固定シードの二つのサイン周波数(主な包絡線を作るfreqAと、その上に乗る小さなゆらぎのfreqB)を持ち、毎フレームそれらを合成して1本ごとの入力レベルの値を作り、その値がBASE_HからMAX_Hの間で高さを決めます。マイクにはどこも触れていません——このファイルのどこにもgetUserMediaの呼び出しはなく、seeded()はバーの番号を種にした決定的な擬似乱数関数なので、波形は有機的に見えながら毎回まったく同じ形で再現されます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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トークン番号の値が上がるたび一文字ずつ現れるゴーストテキスト
読み込み時に一度だけ決まった文が文字配列に分割され、AI_LOADER_TOKEN_REVEAL_MSごとに一段ずつ上がるトークン番号の値が、その番号までの文字を薄いゴースト色から本来の色へ何文字切り替えるかを決めます。切り替えは瞬時ではなく各文字ごとのFADE_MSの窓でゆっくりフェードします。キャレット要素は現在の文字のoffsetLeft・offsetTopを使って表示済みと未表示の境目を追いかけ、本物のテキストカーソルのように表示の先頭を歩きながら、独立したBLINK_MSのタイマーで点滅します。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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モーション低減値を検知すると即座に文言へ変わる処理中バッジ
prefers-reduced-motionは一度だけ読み取られますが、この項目はその値がループ途中のSWITCH_ATで反転する瞬間をあえてシミュレートしています。反転前は三つの点がそれぞれ独立したBOUNCE_HZのサイン位相で跳ね、反転後はバッジがフェードインしながら、1をわずかに超えてから落ち着く臨界減衰のspring()近似でスケールアップします——実際のコードが毎フレームではなく一度だけ行うはずの検知処理の代わりを、ここではこの近似が担っています。この項目以外の八つはモーション低減がオフのときは通常どおり動き続けており、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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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 = '動きの軽減: オフ・動作中';
}
}
チャット・ボタン・物理演算ローダーとの違いを説明します
9種のAIチャットローディングアニメーションはメッセージ吹き出しとチャットUI特有の三点または流し込みテキストの比喩を中心に組み立てられていますが、上の九つはボタンやカードヘッダー、ツールバーのアイコンに単独で置くための表示であって、会話スレッドの中身ではありません。もっとも近い親戚は05番のカードストリーム充填ですが、それも吹き出しに返信を打ち込むのではなく、結果カード内のスケルトン行を埋めていくものです。9種のローディングボタン状態はクリックからスピナー、成功チェックまで一つの要素のライフサイクル全体をモデル化していますが、06番はそのアイドルから完了までの流れを借りつつ、待機状態自体はPromiseが解決する状態機械ではなくタイマーで無期限に動き続けます。
9種のキャンバス物理ローダーは流体の表面や衝突する粒子群、セルオートマトンといった本物の物理シミュレーションを計算しており、03番の粒子の渦と04番のノードと辺のcanvas描画も同じctx.fillRect・ctx.arcの描画方式を共有しています。違いはそれぞれの値が何を表しているかです——あの九つの値は物理演算のステップを進めますが、03番と04番の値はそれぞれ軌道半径と伝搬遅延にすぎず、衝突も力の計算も一切含まれていません。
試せる3つのバリエーションとreduced-motionで消えるもの
| バリエーション | 変更した値 | どう感じるはずか |
|---|---|---|
| ハローリングの色相遷移速度値を調整する | 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 | 間隔値を短く縮めると文字がほぼ一気に出てくるはずで、値を伸ばすと一文字ずつはっきり読める速さに近づくはずです。 |
九つともprefers-reduced-motionをページ読み込み時に一度だけ読み、毎フレームは読みません。そのメディアクエリがreduceを返すとループは一度も始まらず、サイクル上の決まった一点だけを静止フレームとして描きます(09番はドットではなく、すでに落ち着いたバッジの状態そのものを描きます)。どのタイルもrole="application"・tabindex="0"・aria-labelを備え、同じハンドラがクリック・Enterキー・スペースキー(どちらもループの切り替え)・Rキー(再スタート)に応え、さらにリサイズ時にはそのサイズでタイルを描き直すリスナーも付いているので、マウスを使わないキーボードだけの訪問者でも九つのどれでも再生・一時停止・再スタートできます。九つのソースファイルは1パターンにつき1本、この記事のデモと同じフォルダの379w2wxyの奥にあります——上の三つの値をバリエーションの目標値へ動かす前に一本開いておくと、測定表の上では問題なく見えても実際の処理中表示では止まって見える値に気づけます。
どこで実際に使うのかを説明します
06番のボタンアイコンモーフは「要約する」「書き直す」といったインラインのAIアクションボタンに向いています。DONEが反転する前後でボタン自体の外形はまったく同じままアイコンの中身だけが入れ替わるので、レイアウトの他の部分が動く必要がありません。05番のカードストリーム充填は、本当にセクションごとに内容が埋まっていくAI要約カードやレポートカードに合っています。各行のスケルトンから本文色調への切り替えは、その行の本物のコンテンツが実際に準備できたときだけ進むので、まだ存在しないコンテンツの上を一律にアニメーションさせることがありません。
09番は残り八つのどれか一つの代わりではなく、そのすべてが必要とする受け皿としてどこにでも登場します。ai loading animation css的な処理中表示を出荷する製品なら、訪問者のOSがモーション低減を報告した瞬間にプレーンなテキストへ収束する仕組みと必ず組み合わせるべきで、09番のバッジはすでに作られ、すでに上で測定済みのその収束そのものです。
AI処理中の表示は他にどこで見られますか
処理中 アイコン くるくるで検索すると@keyframesだけで回る単純なスピナーがたくさん見つかり、それも出発点としては悪くありませんが、その多くは01番の色相シフトや06番の形状モーフのように「まだ処理中」と「たった今終わった」を区別していません。keyframesのループは二つの固定状態の間を補間するだけで、リクエストが本当に解決したかどうかを知らないからです。処理中 アイコン フリーで配布素材を探す場合も同じ問いを当てはめてください——本物の停止条件を持っているかどうかです。上の罠の節はあなたが自分で書いたパターンだけでなく、配布されているパターンにも同じように当てはまります。
よくある質問にお答えします
九つのうちどれかは本物のAIモデルやAPI、ストリーミング応答とつながっていますか
いいえ。九つとも requestAnimationFrame を通してローカルのタイマー値を読んでいるだけです。08番の表示文はページのソースにすでに書かれた固定文字列で、この記事のzipの中のどのファイルもネットワークリクエストを一切行いません。
08番のゴーストテキスト表示はAIと関係ない普通のタイプライター演出としても使えますか
はい——SENTENCEを別の文字列に差し替えるだけで、AI_LOADER_TOKEN_REVEAL_MS・FADE_MS・BLINK_MSはそのままで構いません。表示のロジック自体は文の意味がAIに関係あるかどうかに依存しておらず、ただ文字配列を順番にたどっているだけだからです。
09番はモーション低減のパターンなのに、なぜ動いたフレームが23回中17回しかないのですか
静止バッジはほとんどの時間じっとしているのが正しい姿だからです。ドットがフェードアウトしてバッジがフェードインする瞬間と、スプリングが落ち着く瞬間の二回だけ描き直せば十分で、サンプルされた全フレームで動く必要はありません。もしここで動いたフレーム数が高ければ、それは実際にはモーションが低減されていないことを意味してしまいます。DOMバッジをインラインの考え中 アニメーション用svgに置き換えても、駆動する値そのものは変わらないので同じ低いフレーム数のままです。
出典: MDN — prefers-reduced-motion · MDN — conic-gradient() · MDN — progressbar ロール · MDN — status ロール · MDN — Window.requestAnimationFrame()
関連記事: 【コピペOK】AIチャット ローディング アニメーション css 9選 · ボタン ローディング 9種 — スピナーから成功チェックまでの状態遷移 · ローディング アニメーション js 9パターン — 物理演算で作る — 一つ目は上の九つと同じ構成をチャット吹き出し専用の比喩に置き換えたもの、二つ目はクリックから成功までボタン一つの全ライフサイクルをモデル化したもの、三つ目はDOM要素の代わりにキャンバスのピクセルを使いながら03番・04番と同じ「一つの値がすべてを動かす」考え方を保っています。









