04名前の値がイニシャルを計算するアバターの遅延読み込み
入力した名前の値からイニシャル2文字を計算して丸いバッジでまず表示し、プロフィール写真が揃うとその場所を実際の写真に切り替える。
使いどころコメント一覧・チームメンバー紹介カード
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 オリジナル · SCSS と React は zip に同梱 · 画像 遅延読み込み 9種 — プレースホルダー アニメーション
このデモが属する記事
画像 遅延読み込み 9種 — プレースホルダー アニメーション画像 遅延読み込みの9つのうち、1つはスクロール位置の値から交差比率を計算して画像をフェードインさせ、1つはクリックの値から読み込みタイミングを計算してiframeのプレースホルダーを実際の表示に変え、1つはアイドル時間の値から遅延時間を計 この記事のデモをすべて見る


