04이름 값이 이니셜을 계산하는 아바타 지연 로딩
입력한 이름 값에서 이니셜 두 글자를 계산해 원형 배지로 먼저 보여주고, 프로필 사진이 갖춰지면 그 자리를 실제 사진으로 바꾼다.
쓰임댓글 목록·팀원 소개 카드
ROW_STAGGER_MSLOAD_BASE_MSPALETTE
<main class="stage">
<div class="lai is-demo" id="lai">
<ul class="lai__list" id="laiList"></ul>
<button type="button" class="lai__btn" id="laiBtn">사진 불러오기</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 이름 값 → 이니셜 계산(자리표시자) + 글자코드 합 해시(배지 색) + 행 인덱스 값(등장 지연).
// 세 계산 겹이 함께 실제 지연 로딩의 "먼저 보여주고 나중에 바뀐다"를 만든다.
$duration: $dur-base;
$easing: $ease-pop;
$color: $subject-pink;
@include stage($stage-paper);
.lai {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
width: min(100%, 240px);
}
.lai__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $sp-2;
width: 100%;
}
.lai__row {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-1 $sp-2;
border-radius: $r-sm;
background: rgba(23, 20, 26, .04);
}
.lai__avatar {
position: relative;
flex: 0 0 auto;
width: 32px;
height: 32px;
border-radius: 50%;
display: grid;
place-items: center;
overflow: hidden;
}
.lai__row--a { background: rgba(255, 158, 187, .3); color: darken($subject-pink, 25%); }
.lai__row--b { background: rgba(76, 212, 166, .3); color: darken($subject-mint, 30%); }
.lai__row--c { background: rgba(185, 160, 255, .3); color: darken($subject-lilac, 28%); }
.lai__row--d { background: rgba(110, 200, 255, .3); color: darken($subject-sky, 30%); }
.lai__initials {
font-weight: 700;
font-size: 13px;
transition: opacity $dur-quick $ease-out;
}
.lai__photo {
position: absolute;
inset: 0;
border-radius: 50%;
opacity: 0;
transform: scale(.6);
transition: transform $dur-enter $easing, opacity $dur-base $ease-out;
background: conic-gradient(from 120deg, $subject-blue, $subject-mint, $subject-pink, $subject-blue);
}
.lai__avatar--loaded .lai__initials { opacity: 0; }
.lai__avatar--loaded .lai__photo { opacity: 1; transform: scale(1); }
.lai__name {
font-size: 13px;
font-weight: 600;
color: $stage-ink;
}
.lai__btn {
border: none;
border-radius: $r-pill;
padding: $sp-1 $sp-3;
background: $color;
color: $stage-ink;
font-weight: 700;
font-size: 12px;
cursor: pointer;
}
@media (max-width: 340px) {
.lai__avatar { width: 28px; height: 28px; }
.lai__row { padding: $sp-1 $sp-2; gap: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.lai__photo, .lai__initials { 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: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lai {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: min(100%, 240px);
}
.lai__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.lai__row {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.04);
}
.lai__avatar {
position: relative;
flex: 0 0 auto;
width: 32px;
height: 32px;
border-radius: 50%;
display: grid;
place-items: center;
overflow: hidden;
}
.lai__row--a {
background: rgba(255, 158, 187, 0.3);
color: rgb(100%, 11.9607843137%, 38.2817869416%);
}
.lai__row--b {
background: rgba(76, 212, 166, 0.3);
color: rgb(10.2543720191%, 42.6868044515%, 31.7170111288%);
}
.lai__row--c {
background: rgba(185, 160, 255, 0.3);
color: rgb(31.2858617131%, 6.7450980392%, 100%);
}
.lai__row--d {
background: rgba(110, 200, 255, 0.3);
color: rgb(0%, 51.6024340771%, 83.137254902%);
}
.lai__initials {
font-weight: 700;
font-size: 13px;
transition: opacity 220ms ease-out;
}
.lai__photo {
position: absolute;
inset: 0;
border-radius: 50%;
opacity: 0;
transform: scale(0.6);
transition: transform 600ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 300ms ease-out;
background: conic-gradient(from 120deg, #2f6df6, #4cd4a6, #ff9ebb, #2f6df6);
}
.lai__avatar--loaded .lai__initials {
opacity: 0;
}
.lai__avatar--loaded .lai__photo {
opacity: 1;
transform: scale(1);
}
.lai__name {
font-size: 13px;
font-weight: 600;
color: #17141a;
}
.lai__btn {
border: none;
border-radius: 999px;
padding: 4px 12px;
background: #ff9ebb;
color: #17141a;
font-weight: 700;
font-size: 12px;
cursor: pointer;
}
@media (max-width: 340px) {
.lai__avatar {
width: 28px;
height: 28px;
}
.lai__row {
padding: 4px 8px;
gap: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.lai__photo, .lai__initials {
transition: none !important;
}
}(function () {
var root = document.getElementById('lai');
var list = document.getElementById('laiList');
var btn = document.getElementById('laiBtn');
var NAMES = ['김하늘', '이도윤', '박서준'];
var PALETTE = ['lai__row--a', 'lai__row--b', 'lai__row--c', 'lai__row--d'];
var ROW_STAGGER_MS = 260;
var LOAD_BASE_MS = 320;
var CYCLE_GAP_MS = 2400;
// 계산 겹 1: 이름 문자열에서 이니셜 두 글자를 잘라낸다.
function getInitials(name) { return name.slice(0, 2); }
// 계산 겹 2: 이름 글자 코드값을 모두 더한 뒤 팔레트 길이로 나눈 나머지로 배지 색을 고른다(해시).
function colorHashClass(name) {
var sum = 0;
for (var i = 0; i < name.length; i += 1) { sum += name.charCodeAt(i); }
return PALETTE[sum % PALETTE.length];
}
var rows = NAMES.map(function (name, i) {
var li = document.createElement('li');
li.className = 'lai__row';
li.innerHTML =
'<span class="lai__avatar ' + colorHashClass(name) + '" data-idx="' + i + '">' +
'<span class="lai__initials">' + getInitials(name) + '</span>' +
'<span class="lai__photo"></span>' +
'</span>' +
'<span class="lai__name">' + name + '</span>';
list.appendChild(li);
return li;
});
function reset() {
rows.forEach(function (li) { li.querySelector('.lai__avatar').classList.remove('lai__avatar--loaded'); });
}
// 계산 겹 3: 목록 위치(행 인덱스) 값으로 등장 지연 시간을 계산해 순서대로 사진이 나타난다.
var timers = [];
function loadPhotos() {
reset();
rows.forEach(function (li, i) {
var t = setTimeout(function () {
li.querySelector('.lai__avatar').classList.add('lai__avatar--loaded');
}, LOAD_BASE_MS + i * ROW_STAGGER_MS);
timers.push(t);
});
}
function clearTimers() { timers.forEach(clearTimeout); timers = []; }
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
clearTimers();
loadPhotos();
});
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var autoplayTimer = null;
if (reduce) {
root.classList.remove('is-demo');
rows.forEach(function (li) { li.querySelector('.lai__avatar').classList.add('lai__avatar--loaded'); });
} else {
(function loop() {
clearTimers();
loadPhotos();
autoplayTimer = setTimeout(function () {
if (!root.classList.contains('is-demo')) { return; }
reset();
setTimeout(function () {
if (root.classList.contains('is-demo')) { loop(); }
}, 500);
}, LOAD_BASE_MS + rows.length * ROW_STAGGER_MS + CYCLE_GAP_MS);
})();
}
})();import { useEffect, useRef, useState, useCallback } from "react";
import styles from "./AvatarInitialsFallback.module.scss";
// 이름 값 → 이니셜 계산(자리표시자) + 글자코드 합 해시(배지 색) + 행 인덱스 값(등장 지연).
const NAMES = ["김하늘", "이도윤", "박서준"];
const PALETTE = ["lai__row--a", "lai__row--b", "lai__row--c", "lai__row--d"];
const ROW_STAGGER_MS = 260;
const LOAD_BASE_MS = 320;
const CYCLE_GAP_MS = 2400;
function getInitials(name: string): string {
return name.slice(0, 2);
}
function colorHashClass(name: string): string {
let sum = 0;
for (let i = 0; i < name.length; i += 1) { sum += name.charCodeAt(i); }
return PALETTE[sum % PALETTE.length];
}
type Box<T> = { current: T };
function useBox<T>(initial: T): Box<T> {
return useRef(initial) as unknown as Box<T>;
}
export default function AvatarInitialsFallback() {
const rootRef = useRef<HTMLDivElement>(null);
const [demo, setDemo] = useState(true);
const [loaded, setLoaded] = useState<boolean[]>(NAMES.map(() => false));
const timersRef = useBox<number[]>([]);
const loopTimerRef = useRef<number | null>(null);
const clearTimers = useCallback(() => {
timersRef.current.forEach((t) => window.clearTimeout(t));
timersRef.current = [];
}, [timersRef]);
const loadPhotos = useCallback(() => {
setLoaded(NAMES.map(() => false));
NAMES.forEach((_, i) => {
const t = window.setTimeout(() => {
setLoaded((prev) => prev.map((v, j) => (j === i ? true : v)));
}, LOAD_BASE_MS + i * ROW_STAGGER_MS);
timersRef.current.push(t);
});
}, [timersRef]);
const onClickLoad = useCallback(() => {
setDemo(false);
clearTimers();
loadPhotos();
}, [clearTimers, loadPhotos]);
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
setDemo(false);
setLoaded(NAMES.map(() => true));
return;
}
let cancelled = false;
function loop() {
if (cancelled) { return; }
clearTimers();
loadPhotos();
loopTimerRef.current = window.setTimeout(() => {
if (cancelled || !rootRef.current?.classList.contains(styles["is-demo"])) { return; }
setLoaded(NAMES.map(() => false));
loopTimerRef.current = window.setTimeout(() => {
if (!cancelled && rootRef.current?.classList.contains(styles["is-demo"])) { loop(); }
}, 500);
}, LOAD_BASE_MS + NAMES.length * ROW_STAGGER_MS + CYCLE_GAP_MS);
}
loop();
return () => {
cancelled = true;
clearTimers();
if (loopTimerRef.current !== null) { window.clearTimeout(loopTimerRef.current); }
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.lai} ${demo ? styles["is-demo"] : ""}`}>
<ul className={styles["lai__list"]}>
{NAMES.map((name, i) => (
<li key={name} className={styles["lai__row"]}>
<span className={`${styles["lai__avatar"]} ${styles[colorHashClass(name)]} ${loaded[i] ? styles["lai__avatar--loaded"] : ""}`}>
<span className={styles["lai__initials"]}>{getInitials(name)}</span>
<span className={styles["lai__photo"]} />
</span>
<span className={styles["lai__name"]}>{name}</span>
</li>
))}
</ul>
<button type="button" className={styles["lai__btn"]} onClick={onClickLoad}>사진 불러오기</button>
</div>
</main>
);
}04 이름 값이 이니셜을 계산하는 아바타 지연 로딩 — 측정 누적면적 1.731% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 이미지 지연 로딩 9종 — 레이지 로딩 자리표시자 모음
이 데모가 속한 글
이미지 지연 로딩 9종 — 레이지 로딩 자리표시자 모음이미지 지연 로딩 아홉 가지 가운데 하나는 스크롤 위치 값으로 교차 비율을 계산해 이미지를 페이드인시키고, 하나는 클릭 여부 값으로 로드 시점을 계산해 지도 iframe 자리를 실제 화면으로 바꾸며, 하나는 유휴 시 이 글의 데모 전부 보기


