08토큰 순번 값이 오를 때마다 한 글자씩 드러나는 고스트 텍스트
미리 정해 둔 결과 문장을 글자 배열로 쪼개 두고, 토큰 순번 값이 하나씩 올라갈 때마다 그 순번까지의 글자만 옅은 고스트 색에서 실제 색으로 바뀌며 캐럿이 그 뒤를 따라간다.
쓰임에디터 안 AI 자동완성 미리보기, 문서 작성 도우미 제안 줄
AI_LOADER_TOKEN_REVEAL_MSFADE_MSBLINK_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 원작 · zip 에 SCSS·React 포함 · AI 로딩 애니메이션 9가지 — 값 하나로 도는 처리중 표시
이 데모가 속한 글
AI 로딩 애니메이션 9가지 — 값 하나로 도는 처리중 표시궤도 반경 값이 크면 입자가 넓게 흩어지는데 값이 0에 가까워지면 코어로 모이고, 그 사이 후광 링의 색상 값은 완료 색 쪽으로 넘어가며, 토큰 순번 값이 오를 때마다 미리보기 글자가 하나씩 늘어난다. 이 아홉 가지 이 글의 데모 전부 보기


