023段フォールバック・アバター
写真を待つ枠、写真が来なければイニシャル、名前すらなければ人型と、3段階が一コマで切り替わります。
使いどころ外部から受け取った写真URLがよく壊れる画面
steps(1, end)translateXobject-fit
<main class="stage">
<section class="fb is-demo" aria-label="3段フォールバック・アバター" data-step="photo">
<p class="fb__name">管理者</p>
<!-- 세 겹이 한 자리에 겹쳐 앉는다: 사진 자리(로딩 시머) · 이니셜 · 사람 도형.
data-step 이 어느 겹을 보일지 정한다 -->
<span class="fb__slot">
<span class="fb__shine" aria-hidden="true"></span>
<img class="fb__photo" src="member-photo.jpg" alt="">
<span class="fb__initial" aria-hidden="true"></span>
<span class="fb__mark" aria-hidden="true">
<svg viewBox="0 0 256 256" width="34" height="34" aria-hidden="true" focusable="false" fill="currentColor"><path d="M192,96a64,64,0,1,1-64-64A64,64,0,0,1,192,96Z" opacity="0.2"/><path d="M230.92,212c-15.23-26.33-38.7-45.21-66.09-54.16a72,72,0,1,0-73.66,0C63.78,166.78,40.31,185.66,25.08,212a8,8,0,1,0,13.85,8c18.84-32.56,52.14-52,89.07-52s70.23,19.44,89.07,52a8,8,0,1,0,13.85-8ZM72,96a56,56,0,1,1,56,56A56.06,56.06,0,0,1,72,96Z"/></svg>
</span>
</span>
<p class="fb__step">
<span class="fb__stepTxt fb__stepTxt--photo">1段階・写真を待機中</span>
<span class="fb__stepTxt fb__stepTxt--initials">2段階・イニシャル代替</span>
<span class="fb__stepTxt fb__stepTxt--mark">3段階・人型代替</span>
</p>
<button class="fb__btn" type="button">
<span class="fb__btnTxt fb__btnTxt--mark">人型へ切り替え</span>
<span class="fb__btnTxt fb__btnTxt--initials">イニシャルへ戻す</span>
</button>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 279 아바타 팔레트 — 이니셜 글자는 언제나 $subject-cream. 괄호는 크림 글자 기준 실측 대비비
$av-blue: #7c90be; // 5.29:1
$av-violet: #9791c9; // 5.76:1
$av-teal: #479082; // 5.79:1
$av-plum: #ab5488; // 6.52:1
$av-brick: $error; // 오류 토큰 — 5.13:1. 리터럴 금지 (BD10)
$av-slate: #3b4a63; // 8.40:1
$av-mist: #b6b0c0; // 잉크 무대 보조 글자 8.65:1
$av-deep: #4a4452; // 옐로 무대 보조 글자 6.49:1 · 페이퍼 무대 9.38:1
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 세 단계 컷이 도는 한 바퀴
$easing: $ease-out; // 시머 막대가 지나가는 결
$color: $stage-ink; // 옐로 무대 글자 12.63:1 — 크림은 1.35:1 로 금지
@include stage($stage-yellow);
.fb {
display: grid;
justify-items: center;
gap: $sp-3;
width: min(320px, 92vw);
}
.fb__name {
margin: 0;
color: $color;
font-weight: 700;
font-size: 14px;
line-height: 1.2;
}
// 세 겹이 겹쳐 앉는 자리 — 사진(안 오면 시머) · 이니셜 · 사람 도형
.fb__slot {
position: relative;
display: grid;
place-items: center;
width: 68px;
height: 68px;
border-radius: 50%;
background: $stage-paper;
overflow: hidden;
}
.fb__photo {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover; // 칩 — 어떤 비율의 사진이 와도 원을 꽉 채운다
opacity: 0;
}
// 로딩 시머 막대 — 대기 단계에서 계속 지나가며 움직임 프레임을 채운다
.fb__shine {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 34px;
background: rgba(23, 20, 26, .12);
animation-name: fbShine;
animation-duration: 1s;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.fb__initial {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: $stage-paper;
color: $color; // 옐로 무대 글자는 잉크로
font-weight: 700;
font-size: 26px;
line-height: 1;
opacity: 0;
}
.fb__mark {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: $stage-paper;
color: $color;
opacity: 0;
}
.fb__mark svg { width: 34px; height: 34px; }
// 정적 표시 — data-step 이 보일 겹 하나만 켠다 (.is-demo 를 뗀 화면이 비면 안 된다)
.fb[data-step="photo"] .fb__photo { opacity: 1; }
.fb[data-step="initials"] .fb__initial { opacity: 1; }
.fb[data-step="mark"] .fb__mark { opacity: 1; }
// 단계 한 줄 — 세 문장이 한 칸에 겹쳐 있고 컷 타이밍에 맞춰 바뀐다
.fb__step {
display: grid;
justify-items: center;
margin: 0;
}
.fb__stepTxt {
grid-area: 1 / 1;
color: $av-deep; // 옐로 무대 보조 글자 6.49:1
font-weight: 600;
font-size: 12px;
line-height: 1.3;
opacity: 0;
}
.fb[data-step="photo"] .fb__stepTxt--photo,
.fb[data-step="initials"] .fb__stepTxt--initials,
.fb[data-step="mark"] .fb__stepTxt--mark { opacity: 1; }
.fb__btn {
display: grid;
margin: 0;
padding: $sp-2 $sp-5;
border: 0;
border-radius: $r-control;
background: $color;
color: $stage-yellow;
font-family: inherit;
font-weight: 700;
font-size: 13px;
line-height: 1.2;
cursor: pointer;
}
.fb__btn:focus-visible { outline: 2px solid $color; outline-offset: 3px; }
.fb__btnTxt { grid-area: 1 / 1; visibility: hidden; }
.fb[data-step="initials"] .fb__btnTxt--mark,
.fb[data-step="mark"] .fb__btnTxt--initials { visibility: visible; }
// ── 격자 미리보기 루프 ────────────────────────────────
// 세 단계를 steps(1, end) 로 한 프레임에 컷한다 — 절반 투명 상태가 겹치지 않는다.
// 0% 대기 / 34% 이니셜 / 67% 사람 도형, 88% 안에 대기로 복귀.
// 컷만 있으면 움직임 프레임이 모자라니 대기 단계의 시머 막대가 계속 지나가 프레임을 채운다
.fb.is-demo .fb__photo,
.fb.is-demo .fb__stepTxt--photo {
animation-name: fbCutPhoto;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.fb.is-demo .fb__initial,
.fb.is-demo .fb__stepTxt--initials {
animation-name: fbCutInitial;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.fb.is-demo .fb__mark,
.fb.is-demo .fb__stepTxt--mark {
animation-name: fbCutMark;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes fbCutPhoto {
0% { opacity: 1; }
34% { opacity: 0; }
88% { opacity: 1; }
100% { opacity: 1; }
}
@keyframes fbCutInitial {
0% { opacity: 0; }
34% { opacity: 1; }
67% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes fbCutMark {
0% { opacity: 0; }
67% { opacity: 1; }
88% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes fbShine {
0% { transform: translateX(-40px); }
100% { transform: translateX(80px); }
}
// 좁은 화면 — 표시와 여백만 줄인다. 값을 올리지 않는다.
@media (max-width: 340px), (max-height: 220px) {
.fb { gap: $sp-2; }
.fb__slot { width: 56px; height: 56px; }
.fb__initial { font-size: 22px; }
.fb__mark svg { width: 28px; height: 28px; }
.fb__name { font-size: 12px; }
.fb__stepTxt { font-size: 11px; }
.fb__btn { padding: $sp-2 $sp-4; font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
// .fb.is-demo … (0,3,0) 를 이기려면 !important 가 필요하다.
// 컷과 시머만 끄고, 정적 규칙(data-step) 이 보일 겹을 이어받는다
.fb__photo, .fb__initial, .fb__mark, .fb__stepTxt, .fb__shine { animation: none !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);
}
.fb {
display: grid;
justify-items: center;
gap: 12px;
width: min(320px, 92vw);
}
.fb__name {
margin: 0;
color: #17141a;
font-weight: 700;
font-size: 14px;
line-height: 1.2;
}
.fb__slot {
position: relative;
display: grid;
place-items: center;
width: 68px;
height: 68px;
border-radius: 50%;
background: #ffffff;
overflow: hidden;
}
.fb__photo {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
}
.fb__shine {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 34px;
background: rgba(23, 20, 26, 0.12);
animation-name: fbShine;
animation-duration: 1s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.fb__initial {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: #ffffff;
color: #17141a;
font-weight: 700;
font-size: 26px;
line-height: 1;
opacity: 0;
}
.fb__mark {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: #ffffff;
color: #17141a;
opacity: 0;
}
.fb__mark svg {
width: 34px;
height: 34px;
}
.fb[data-step=photo] .fb__photo {
opacity: 1;
}
.fb[data-step=initials] .fb__initial {
opacity: 1;
}
.fb[data-step=mark] .fb__mark {
opacity: 1;
}
.fb__step {
display: grid;
justify-items: center;
margin: 0;
}
.fb__stepTxt {
grid-area: 1/1;
color: #4a4452;
font-weight: 600;
font-size: 12px;
line-height: 1.3;
opacity: 0;
}
.fb[data-step=photo] .fb__stepTxt--photo,
.fb[data-step=initials] .fb__stepTxt--initials,
.fb[data-step=mark] .fb__stepTxt--mark {
opacity: 1;
}
.fb__btn {
display: grid;
margin: 0;
padding: 8px 20px;
border: 0;
border-radius: 12px;
background: #17141a;
color: #ffd23f;
font-family: inherit;
font-weight: 700;
font-size: 13px;
line-height: 1.2;
cursor: pointer;
}
.fb__btn:focus-visible {
outline: 2px solid #17141a;
outline-offset: 3px;
}
.fb__btnTxt {
grid-area: 1/1;
visibility: hidden;
}
.fb[data-step=initials] .fb__btnTxt--mark,
.fb[data-step=mark] .fb__btnTxt--initials {
visibility: visible;
}
.fb.is-demo .fb__photo,
.fb.is-demo .fb__stepTxt--photo {
animation-name: fbCutPhoto;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.fb.is-demo .fb__initial,
.fb.is-demo .fb__stepTxt--initials {
animation-name: fbCutInitial;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.fb.is-demo .fb__mark,
.fb.is-demo .fb__stepTxt--mark {
animation-name: fbCutMark;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes fbCutPhoto {
0% {
opacity: 1;
}
34% {
opacity: 0;
}
88% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes fbCutInitial {
0% {
opacity: 0;
}
34% {
opacity: 1;
}
67% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes fbCutMark {
0% {
opacity: 0;
}
67% {
opacity: 1;
}
88% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes fbShine {
0% {
transform: translateX(-40px);
}
100% {
transform: translateX(80px);
}
}
@media (max-width: 340px), (max-height: 220px) {
.fb {
gap: 8px;
}
.fb__slot {
width: 56px;
height: 56px;
}
.fb__initial {
font-size: 22px;
}
.fb__mark svg {
width: 28px;
height: 28px;
}
.fb__name {
font-size: 12px;
}
.fb__stepTxt {
font-size: 11px;
}
.fb__btn {
padding: 8px 16px;
font-size: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.fb__photo, .fb__initial, .fb__mark, .fb__stepTxt, .fb__shine {
animation: none !important;
}
}(function () {
var root = document.querySelector('.fb');
var img = root.querySelector('.fb__photo');
var btn = root.querySelector('.fb__btn');
var nameEl = root.querySelector('.fb__name');
var initialEl = root.querySelector('.fb__initial');
// 이니셜은 名前 표시에서 계산한다(한국어 첫 글자 1자 · 라틴 앞 두 단어 머리글자)
function initialsOf(text) {
var trimmed = text.trim();
if (!trimmed) return '';
if (/[가-힣]/.test(trimmed[0])) return trimmed[0];
var words = trimmed.split(/\s+/).filter(function (w) { return /[A-Za-z]/.test(w[0] || ''); });
return words.slice(0, 2).map(function (w) { return w[0].toUpperCase(); }).join('');
}
initialEl.textContent = initialsOf(nameEl.textContent);
// 사진 파일은 일부러 없다(404) — error 이벤트에서 스스로 이니셜 단계로 내려간다
img.addEventListener('error', function () {
if (root.dataset.step === 'photo') root.dataset.step = 'initials';
});
if (img.complete && img.naturalWidth === 0 && root.dataset.step === 'photo') {
root.dataset.step = 'initials';
}
// 버튼 하나가 이니셜과 사람 도형을 오간다 (키보드 Enter·Space 로도 눌린다)
btn.addEventListener('click', function () {
root.dataset.step = (root.dataset.step === 'mark') ? 'initials' : 'mark';
});
// 사람이 손을 대면 미리보기 루프를 뗀다
function stopLoop() { root.classList.remove('is-demo'); }
root.addEventListener('pointerdown', stopLoop);
root.addEventListener('focusin', stopLoop);
})();import { useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./AvatarFallbackChain.module.scss";
export type AvatarFallbackChainProps = {
label?: string;
name?: string;
stepPhoto?: string;
stepInitials?: string;
stepMark?: string;
btnToMark?: string;
btnToInitials?: string;
/** 세 단계 컷이 도는 한 바퀴(ms) */
duration?: number;
easing?: string;
/** 옐로 무대 글자색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// 이니셜은 이름에서 계산한다(한국어 첫 글자 1자 · 라틴 앞 두 단어 머리글자)
function initialsOf(text: string): string {
const trimmed = text.trim();
if (!trimmed) return "";
if (/[가-힣]/.test(trimmed[0])) return trimmed[0];
const words = trimmed.split(/\s+/).filter((w) => /[A-Za-z]/.test(w[0] || ""));
return words.slice(0, 2).map((w) => w[0].toUpperCase()).join("");
}
export default function AvatarFallbackChain({
label = "3단 대체 아바타",
name = "관리자",
stepPhoto = "1단계 · 사진 기다리는 중",
stepInitials = "2단계 · 이니셜 대체",
stepMark = "3단계 · 사람 도형 대체",
btnToMark = "사람 도형으로",
btnToInitials = "이니셜로 되돌리기",
duration = 2000,
easing = "ease-out",
color = "#17141a",
}: AvatarFallbackChainProps): ReactNode {
const [step, setStep] = useState<"photo" | "initials" | "mark">("photo");
const [loop, setLoop] = useState<boolean>(true);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section
className={loop ? `${styles.fb} ${styles.isDemo}` : styles.fb}
style={vars}
aria-label={label}
data-step={step}
onPointerDown={() => setLoop(false)}
onFocus={() => setLoop(false)}
>
<p className={styles.name}>{name}</p>
{/* 세 겹이 한 자리에 겹쳐 앉는다: 사진 자리(로딩 시머) · 이니셜 · 사람 도형.
data-step 이 어느 겹을 보일지 정한다 */}
<span className={styles.slot}>
<span className={styles.shine} aria-hidden="true" />
{/* 사진 파일은 일부러 없다(404) — error 에서 스스로 이니셜 단계로 내려간다 */}
<img
className={styles.photo}
src="member-photo.jpg"
alt=""
onError={() => setStep((s) => (s === "photo" ? "initials" : s))}
/>
<span className={styles.initial} aria-hidden="true">{initialsOf(name)}</span>
<span className={styles.mark} aria-hidden="true">
<svg viewBox="0 0 256 256" width="34" height="34" aria-hidden="true" focusable="false" fill="currentColor"><path d="M192,96a64,64,0,1,1-64-64A64,64,0,0,1,192,96Z" opacity="0.2" /><path d="M230.92,212c-15.23-26.33-38.7-45.21-66.09-54.16a72,72,0,1,0-73.66,0C63.78,166.78,40.31,185.66,25.08,212a8,8,0,1,0,13.85,8c18.84-32.56,52.14-52,89.07-52s70.23,19.44,89.07,52a8,8,0,1,0,13.85-8ZM72,96a56,56,0,1,1,56,56A56.06,56.06,0,0,1,72,96Z" /></svg>
</span>
</span>
<p className={styles.step}>
<span className={`${styles.stepTxt} ${styles.stepTxtPhoto}`}>{stepPhoto}</span>
<span className={`${styles.stepTxt} ${styles.stepTxtInitials}`}>{stepInitials}</span>
<span className={`${styles.stepTxt} ${styles.stepTxtMark}`}>{stepMark}</span>
</p>
{/* 버튼 하나가 이니셜과 사람 도형을 오간다 (키보드 Enter·Space 로도 눌린다) */}
<button
className={styles.btn}
type="button"
onClick={() => setStep((s) => (s === "mark" ? "initials" : "mark"))}
>
<span className={`${styles.btnTxt} ${styles.btnTxtMark}`}>{btnToMark}</span>
<span className={`${styles.btnTxt} ${styles.btnTxtInitials}`}>{btnToInitials}</span>
</button>
</section>
);
}02 3段フォールバック・アバター — 計測 累積面積 3.383% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · プロフィール UI デザイン 9種 — 画像ファイルなし


