01대체 서체에서 실제 폰트로 넘어가는 지연 로딩 텍스트
폰트가 준비됐는지 값을 계산해 대체 서체로 먼저 보여주다가 실제 폰트가 갖춰지면 글자 모양을 자연스럽게 바꾼다.
쓰임본문 텍스트 많은 랜딩페이지·블로그 글머리
STAGGER_STEP_MSFOUT_DELAY_MSRESET_DELAY_MS

<main class="stage">
<div class="lff is-demo" id="lff">
<div class="lff__badge" id="lffBadge">대체 서체 표시 중</div>
<h2 class="lff__head" id="lffHead" aria-label="지연 로딩 헤드라인"></h2>
<p class="lff__hint">클릭하면 다시 재생</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 대체 서체 → 실제 서체 지연 로딩. 글자마다 인덱스 값으로 transition-delay 를 미리 계산해
// 심어두면(자바스크립트) 클래스 하나만 켜져도 순서대로 스프링 오버슛이 번져 나간다.
$duration: $dur-enter;
$easing: $ease-elastic;
$color: $subject-sky;
@include stage($stage-ink);
.lff {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
width: min(100%, 300px);
outline: none;
cursor: pointer;
}
.lff__badge {
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(110, 200, 255, .16);
color: $color;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.lff__head {
margin: 0;
padding: $sp-4 $sp-4;
border-radius: $r-card;
background: rgba(255, 255, 255, .06);
color: $stage-paper;
font-size: 22px;
font-weight: 700;
font-family: "Times New Roman", Georgia, serif;
text-align: center;
transition: font-family 1ms linear;
}
.lff__char {
display: inline-block;
transform: scale(.82);
transform-origin: 50% 100%;
opacity: .55;
transition: transform $duration $easing, opacity $dur-base $ease-out;
}
.lff--loaded .lff__head {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.lff__char--loaded {
transform: scale(1);
opacity: 1;
}
.lff__hint {
margin: 0;
color: rgba(255, 255, 255, .5);
font-size: 11px;
}
@media (max-width: 340px) {
.lff__head { font-size: 18px; padding: $sp-3 $sp-3; }
.lff { gap: $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.lff__char { transition: none !important; }
.lff__head { transition: none !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);
}
.lff {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
width: min(100%, 300px);
outline: none;
cursor: pointer;
}
.lff__badge {
padding: 4px 12px;
border-radius: 999px;
background: rgba(110, 200, 255, 0.16);
color: #6ec8ff;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.lff__head {
margin: 0;
padding: 16px 16px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.06);
color: #ffffff;
font-size: 22px;
font-weight: 700;
font-family: "Times New Roman", Georgia, serif;
text-align: center;
transition: font-family 1ms linear;
}
.lff__char {
display: inline-block;
transform: scale(0.82);
transform-origin: 50% 100%;
opacity: 0.55;
transition: transform 600ms cubic-bezier(0.34, 1.56, 0.64, 1), opacity 300ms ease-out;
}
.lff--loaded .lff__head {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.lff__char--loaded {
transform: scale(1);
opacity: 1;
}
.lff__hint {
margin: 0;
color: rgba(255, 255, 255, 0.5);
font-size: 11px;
}
@media (max-width: 340px) {
.lff__head {
font-size: 18px;
padding: 12px 12px;
}
.lff {
gap: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.lff__char {
transition: none !important;
}
.lff__head {
transition: none !important;
}
}(function () {
var root = document.getElementById('lff');
var badge = document.getElementById('lffBadge');
var head = document.getElementById('lffHead');
var TEXT = '지연 로딩 헤드라인';
var STAGGER_STEP_MS = 45;
var FOUT_DELAY_MS = 700;
var HOLD_MS = 1800;
var RESET_DELAY_MS = 900;
var timer = null;
// 계산 겹: 글자 인덱스 값으로 전환 지연 시간을 계산해 순서대로 스프링 오버슛이 번진다.
var chars = [];
for (var i = 0; i < TEXT.length; i += 1) {
var span = document.createElement('span');
span.className = 'lff__char';
span.textContent = TEXT[i] === ' ' ? ' ' : TEXT[i];
span.style.transitionDelay = (i * STAGGER_STEP_MS) + 'ms';
head.appendChild(span);
chars.push(span);
}
function resetState() {
root.classList.remove('lff--loaded');
for (var i = 0; i < chars.length; i += 1) { chars[i].classList.remove('lff__char--loaded'); }
badge.textContent = '대체 서체 표시 중';
}
function swapToLoaded() {
root.classList.add('lff--loaded');
for (var i = 0; i < chars.length; i += 1) { chars[i].classList.add('lff__char--loaded'); }
badge.textContent = '실제 서체 로드 완료';
}
function playOnce(loop) {
resetState();
timer = setTimeout(function () {
swapToLoaded();
if (loop) {
timer = setTimeout(function () { playOnce(true); }, HOLD_MS + RESET_DELAY_MS);
}
}, FOUT_DELAY_MS);
}
function replay() {
if (timer) { clearTimeout(timer); }
root.classList.remove('is-demo');
playOnce(false);
}
root.addEventListener('click', replay);
root.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); replay(); }
});
root.tabIndex = 0;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
root.classList.remove('is-demo');
swapToLoaded();
} else {
playOnce(true);
}
})();import { useEffect, useRef, useState, useCallback } from "react";
import styles from "./FontSwapFout.module.scss";
// 대체 서체 → 실제 서체 지연 로딩. 글자 인덱스 값으로 transition-delay 를 계산해
// 순서대로 스프링 오버슛이 번지게 한다.
const TEXT = "지연 로딩 헤드라인";
const STAGGER_STEP_MS = 45;
const FOUT_DELAY_MS = 700;
const HOLD_MS = 1800;
const RESET_DELAY_MS = 900;
export default function FontSwapFout() {
const rootRef = useRef<HTMLDivElement>(null);
const [demo, setDemo] = useState(true);
const [loaded, setLoaded] = useState(false);
const [badge, setBadge] = useState("대체 서체 표시 중");
const timerRef = useRef<number | null>(null);
const playOnce = useCallback((loop: boolean) => {
setLoaded(false);
setBadge("대체 서체 표시 중");
timerRef.current = window.setTimeout(() => {
setLoaded(true);
setBadge("실제 서체 로드 완료");
if (loop) {
timerRef.current = window.setTimeout(() => { playOnce(true); }, HOLD_MS + RESET_DELAY_MS);
}
}, FOUT_DELAY_MS);
}, []);
const replay = useCallback(() => {
if (timerRef.current !== null) { window.clearTimeout(timerRef.current); }
setDemo(false);
playOnce(false);
}, [playOnce]);
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
setDemo(false);
setLoaded(true);
setBadge("실제 서체 로드 완료");
} else {
playOnce(true);
}
const root = rootRef.current;
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); replay(); }
}
root?.addEventListener("keydown", onKeyDown);
return () => {
if (timerRef.current !== null) { window.clearTimeout(timerRef.current); }
root?.removeEventListener("keydown", onKeyDown);
};
}, [playOnce, replay]);
const chars = TEXT.split("").map((ch, i) => (
<span
key={i}
className={`${styles["lff__char"]} ${loaded ? styles["lff__char--loaded"] : ""}`}
style={{ transitionDelay: `${i * STAGGER_STEP_MS}ms` }}
>
{ch === " " ? " " : ch}
</span>
));
return (
<main className={styles.stage}>
<div
ref={rootRef}
className={`${styles.lff} ${demo ? styles["is-demo"] : ""} ${loaded ? styles["lff--loaded"] : ""}`}
tabIndex={0}
onClick={replay}
>
<div className={styles["lff__badge"]}>{badge}</div>
<h2 className={styles["lff__head"]} aria-label="지연 로딩 헤드라인">{chars}</h2>
<p className={styles["lff__hint"]}>클릭하면 다시 재생</p>
</div>
</main>
);
}01 대체 서체에서 실제 폰트로 넘어가는 지연 로딩 텍스트 — 측정 누적면적 5.237% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 이미지 지연 로딩 9종 — 레이지 로딩 자리표시자 모음
이 데모가 속한 글
이미지 지연 로딩 9종 — 레이지 로딩 자리표시자 모음이미지 지연 로딩 아홉 가지 가운데 하나는 스크롤 위치 값으로 교차 비율을 계산해 이미지를 페이드인시키고, 하나는 클릭 여부 값으로 로드 시점을 계산해 지도 iframe 자리를 실제 화면으로 바꾸며, 하나는 유휴 시 이 글의 데모 전부 보기


