08호버 사용자 카드
아바타에 손이 머문 뒤에야 작은 카드가 올라오고 손이 떠나도 잠시 기다렸다 닫히므로, 카드 위로 마우스를 옮기는 동안 카드가 사라지지 않습니다.
쓰임이름만 나온 자리에서 누구인지 확인해야 하는 화면
aria-describedbytranslateY:focus-visible
<main class="stage">
<section class="uc is-demo" aria-label="사용자 카드 안내">
<!-- 가짜 포인터 — 미리보기 루프에서만 보인다 -->
<span class="uc__pointer" aria-hidden="true"></span>
<p class="uc__line">
<span class="uc__ago">방금</span>
<button class="uc__trigger" type="button" aria-describedby="uc-card">
<span class="uc__av" aria-hidden="true"></span>
<span class="uc__name">편집자</span>
</button>
<span class="uc__rest">님이 문서를 고쳤습니다</span>
</p>
<!-- 절대배치 카드 — 뿌리의 padding-bottom 이 열린 높이만큼 자리를 잡는다 -->
<div class="uc__card" id="uc-card" role="tooltip">
<div class="uc__head">
<span class="uc__bigav" aria-hidden="true"></span>
<p class="uc__who">
<span class="uc__cardName">편집자</span>
<span class="uc__job">문서 편집 담당</span>
</p>
</div>
<div class="uc__stats">
<p class="uc__stat"><span class="uc__val">128</span><span class="uc__lab">고친 문서</span></p>
<p class="uc__stat"><span class="uc__val">36</span><span class="uc__lab">받은 감사</span></p>
</div>
<span class="uc__fill" aria-hidden="true"><span class="uc__fillIn"></span></span>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 279 아바타 팔레트(쓰는 색만) — 이니셜 글자는 언제나 $subject-cream
$av-blue: #7c90be; // 5.29:1 (크림 글자 기준)
$av-deep: #4a4452; // 페이퍼 무대 보조 글자 9.38:1
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 포인터가 다가가 카드가 열리고 닫히는 한 바퀴
$easing: $ease-out; // 포인터 이동·카드 자리 잡기
$color: $av-blue; // 아바타·초점 테두리·채우는 막대·포인터
@include stage($stage-paper);
.uc {
position: relative;
box-sizing: border-box;
width: min(300px, 100%);
// 포인터가 다가오는 공간 + 절대배치 카드의 열린 높이(237 실측 — 세로 예약 없으면 BD9 가 터진다)
padding-top: $sp-6;
padding-bottom: calc(#{$sp-12} * 2 + #{$sp-3});
}
.uc__line {
position: relative;
display: flex;
align-items: center;
margin: 0;
color: $stage-ink;
font-weight: 500;
font-size: 13px;
line-height: 1.2;
}
.uc__ago { flex: none; margin-right: $sp-1; color: $av-deep; }
.uc__rest { margin-left: $sp-1; }
.uc__trigger {
display: inline-flex;
align-items: center;
flex: none;
gap: $sp-1;
margin: 0;
padding: $sp-1 $sp-2;
border: 0;
border-radius: $r-pill;
background: transparent;
color: $stage-ink;
font-family: inherit;
font-weight: 700;
font-size: 13px;
line-height: 1.2;
cursor: pointer;
}
.uc__trigger:hover { background: rgba($stage-ink, .06); }
.uc__trigger:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.uc__av, .uc__bigav {
display: grid;
place-items: center;
flex: none;
border-radius: 50%;
background: $av-blue;
color: $subject-cream;
font-weight: 700;
line-height: 1;
}
.uc__av { width: $sp-6; height: $sp-6; font-size: 11px; }
.uc__bigav { width: $sp-8; height: $sp-8; font-size: 14px; }
// ── 사용자 카드(정적 상태 — 루프가 없어도 열려 있는 모습이 그대로 보인다) ──
.uc__card {
position: absolute;
top: calc(#{$sp-6} + #{$sp-8} + #{$sp-1}); // 포인터 공간 24 + 줄 높이 32 + 간격 4
left: 0;
box-sizing: border-box;
display: grid;
gap: $sp-2;
width: calc(#{$sp-12} * 4);
padding: $sp-3;
border-radius: $r-card;
background: $subject-cream;
box-shadow: $shadow-raised;
}
.uc__card[hidden] { display: none; }
.uc__head { display: flex; align-items: center; gap: $sp-2; }
.uc__who { display: grid; gap: $sp-1; margin: 0; }
.uc__cardName { color: $stage-ink; font-weight: 700; font-size: 14px; line-height: 1.2; }
.uc__job { color: $av-deep; font-weight: 500; font-size: 11px; line-height: 1.2; }
.uc__stats { display: flex; gap: $sp-4; }
.uc__stat { display: grid; gap: $sp-1; margin: 0; }
.uc__val { color: $stage-ink; font-weight: 700; font-size: 15px; line-height: 1; }
.uc__lab { color: $av-deep; font-weight: 500; font-size: 10px; line-height: 1; }
.uc__fill {
display: block;
height: $sp-1;
border-radius: $r-pill;
background: rgba($stage-ink, .12);
overflow: hidden;
}
.uc__fillIn {
display: block;
height: 100%;
border-radius: inherit;
background: $color;
transform: scaleX(0);
transform-origin: left center; // 규칙8: 통계 막대 왼쪽 채움
}
// 가짜 포인터 — 실제 조용한 화면에서는 감춘다
.uc__pointer {
position: absolute;
top: 0;
left: $sp-1;
width: $sp-3;
height: $sp-3;
border-radius: 50%;
background: $color;
box-shadow: 0 0 0 $sp-1 $stage-paper;
pointer-events: none;
}
.uc:not(.is-demo) .uc__pointer { visibility: hidden; }
// ── 격자 미리보기 루프 ────────────────────────────────
// 포인터가 트리거로 미끄러지고(0~26%), 도착 순간 카드가 steps(1,end) 컷으로 한 프레임에 나타난다.
// 컷 직후 translateY 6px→0 팝이 프레임을 채우고 28~84% 는 막대가 scaleX 로 차오른다.
// 닫힘(84~92%)은 clip-path 위프로 여러 프레임에 면적을 나눠 줄인다 — 열림 컷이 언제나 최대 변화가
// 되어 poster 가 '카드가 떠 있는 장면'이 된다(266 실측 계열). 복귀는 92% 안에 끝난다.
// 실제 사용자에게는 카드가 타이머(300ms 지연·200ms 유예)로 열리고 닫힌다 — 루프는 그 요약이다.
.uc.is-demo .uc__card {
animation-name: ucCard, ucWipe;
animation-duration: $duration, $duration;
animation-timing-function: $easing, ease-in;
animation-iteration-count: infinite, infinite;
}
.uc.is-demo .uc__fillIn {
animation-name: ucFill;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.uc.is-demo .uc__pointer {
animation-name: ucPointer;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes ucCard {
0% { opacity: 0; transform: translateY(6px); animation-timing-function: steps(1, end); }
26% { opacity: 1; transform: translateY(6px); animation-timing-function: $easing; }
34%, 100% { opacity: 1; transform: translateY(0); }
}
@keyframes ucWipe {
0%, 84% { clip-path: inset(0 0 0 0); animation-timing-function: ease-in; }
92%, 100% { clip-path: inset(0 0 100% 0); }
}
@keyframes ucFill {
0%, 28% { transform: scaleX(0); }
84%, 100% { transform: scaleX(1); }
}
@keyframes ucPointer {
0% { translate: 0 0; }
26% { translate: 64px 22px; }
40% { translate: 64px 26px; }
54% { translate: 64px 22px; }
84% { translate: 64px 22px; }
92%, 100% { translate: 0 0; }
}
// 좁은 화면 — 포인터 공간만 줄인다. 카드·줄 크기는 그대로(포인터 키프레임 px 와 묶여 있다)
@media (max-width: 340px), (max-height: 220px) {
.uc { padding-top: $sp-4; }
.uc__line { font-size: 12px; }
.uc__trigger { font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
// .uc.is-demo .uc__card (0,3,0) 를 이기려면 !important. 카드는 열린 채 멈춰 보인다
.uc__card { animation: none !important; }
.uc__fillIn { animation: none !important; transform: scaleX(1) !important; }
.uc__pointer { 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: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.uc {
position: relative;
box-sizing: border-box;
width: min(300px, 100%);
padding-top: 24px;
padding-bottom: calc(48px * 2 + 12px);
}
.uc__line {
position: relative;
display: flex;
align-items: center;
margin: 0;
color: #17141a;
font-weight: 500;
font-size: 13px;
line-height: 1.2;
}
.uc__ago {
flex: none;
margin-right: 4px;
color: #4a4452;
}
.uc__rest {
margin-left: 4px;
}
.uc__trigger {
display: inline-flex;
align-items: center;
flex: none;
gap: 4px;
margin: 0;
padding: 4px 8px;
border: 0;
border-radius: 999px;
background: transparent;
color: #17141a;
font-family: inherit;
font-weight: 700;
font-size: 13px;
line-height: 1.2;
cursor: pointer;
}
.uc__trigger:hover {
background: rgba(23, 20, 26, 0.06);
}
.uc__trigger:focus-visible {
outline: 2px solid #7c90be;
outline-offset: 2px;
}
.uc__av, .uc__bigav {
display: grid;
place-items: center;
flex: none;
border-radius: 50%;
background: #7c90be;
color: #fff7e6;
font-weight: 700;
line-height: 1;
}
.uc__av {
width: 24px;
height: 24px;
font-size: 11px;
}
.uc__bigav {
width: 32px;
height: 32px;
font-size: 14px;
}
.uc__card {
position: absolute;
top: calc(24px + 32px + 4px);
left: 0;
box-sizing: border-box;
display: grid;
gap: 8px;
width: calc(48px * 4);
padding: 12px;
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.uc__card[hidden] {
display: none;
}
.uc__head {
display: flex;
align-items: center;
gap: 8px;
}
.uc__who {
display: grid;
gap: 4px;
margin: 0;
}
.uc__cardName {
color: #17141a;
font-weight: 700;
font-size: 14px;
line-height: 1.2;
}
.uc__job {
color: #4a4452;
font-weight: 500;
font-size: 11px;
line-height: 1.2;
}
.uc__stats {
display: flex;
gap: 16px;
}
.uc__stat {
display: grid;
gap: 4px;
margin: 0;
}
.uc__val {
color: #17141a;
font-weight: 700;
font-size: 15px;
line-height: 1;
}
.uc__lab {
color: #4a4452;
font-weight: 500;
font-size: 10px;
line-height: 1;
}
.uc__fill {
display: block;
height: 4px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.12);
overflow: hidden;
}
.uc__fillIn {
display: block;
height: 100%;
border-radius: inherit;
background: #7c90be;
transform: scaleX(0);
transform-origin: left center;
}
.uc__pointer {
position: absolute;
top: 0;
left: 4px;
width: 12px;
height: 12px;
border-radius: 50%;
background: #7c90be;
box-shadow: 0 0 0 4px #ffffff;
pointer-events: none;
}
.uc:not(.is-demo) .uc__pointer {
visibility: hidden;
}
.uc.is-demo .uc__card {
animation-name: ucCard, ucWipe;
animation-duration: 2s, 2s;
animation-timing-function: ease-out, ease-in;
animation-iteration-count: infinite, infinite;
}
.uc.is-demo .uc__fillIn {
animation-name: ucFill;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.uc.is-demo .uc__pointer {
animation-name: ucPointer;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes ucCard {
0% {
opacity: 0;
transform: translateY(6px);
animation-timing-function: steps(1, end);
}
26% {
opacity: 1;
transform: translateY(6px);
animation-timing-function: ease-out;
}
34%, 100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes ucWipe {
0%, 84% {
clip-path: inset(0 0 0 0);
animation-timing-function: ease-in;
}
92%, 100% {
clip-path: inset(0 0 100% 0);
}
}
@keyframes ucFill {
0%, 28% {
transform: scaleX(0);
}
84%, 100% {
transform: scaleX(1);
}
}
@keyframes ucPointer {
0% {
translate: 0 0;
}
26% {
translate: 64px 22px;
}
40% {
translate: 64px 26px;
}
54% {
translate: 64px 22px;
}
84% {
translate: 64px 22px;
}
92%, 100% {
translate: 0 0;
}
}
@media (max-width: 340px), (max-height: 220px) {
.uc {
padding-top: 16px;
}
.uc__line {
font-size: 12px;
}
.uc__trigger {
font-size: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.uc__card {
animation: none !important;
}
.uc__fillIn {
animation: none !important;
transform: scaleX(1) !important;
}
.uc__pointer {
animation: none !important;
}
}(function () {
var root = document.querySelector('.uc');
var trigger = root.querySelector('.uc__trigger');
var card = root.querySelector('.uc__card');
var openT = 0, closeT = 0;
// 호버 — 300ms 지연 뒤 열리고, 벗어나면 200ms 유예 뒤 닫힌다.
// 유예 동안 카드 위로 옮겨 가면 닫히지 않는다(카드에도 같은 핸들러)
trigger.addEventListener('pointerenter', function () {
clearTimeout(openT); clearTimeout(closeT);
openT = setTimeout(function () { card.hidden = false; }, 300);
});
function scheduleClose() {
clearTimeout(openT); clearTimeout(closeT);
closeT = setTimeout(function () { card.hidden = true; }, 200);
}
trigger.addEventListener('pointerleave', scheduleClose);
card.addEventListener('pointerenter', function () { clearTimeout(closeT); });
card.addEventListener('pointerleave', scheduleClose);
// 초점 — 지연 없이 열리고, blur·Escape 으로 닫힌다
trigger.addEventListener('focus', function () { clearTimeout(closeT); card.hidden = false; });
trigger.addEventListener('blur', function () { card.hidden = true; });
trigger.addEventListener('keydown', function (e) { if (e.key === 'Escape') card.hidden = true; });
// 이니셜은 표시 문자열에서 계산한다(하드코딩 금지) — 언어팩이 바뀌면 이니셜도 따라간다
function initialOf(name) {
var p = name.trim().split(/\s+/);
return /[A-Za-z]/.test(p[0]) ? p.slice(0, 2).map(function (w) { return w.charAt(0).toUpperCase(); }).join('') : p[0].charAt(0);
}
var name = root.querySelector('.uc__name').textContent;
trigger.querySelector('.uc__av').textContent = initialOf(name);
root.querySelector('.uc__bigav').textContent = initialOf(root.querySelector('.uc__cardName').textContent);
// 사람이 손을 대면 미리보기 루프를 뗀다
function stopLoop() { root.classList.remove('is-demo'); }
root.addEventListener('pointerdown', stopLoop);
root.addEventListener('focusin', stopLoop);
})();import { useEffect, useRef, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./AvatarHoverUserCard.module.scss";
export type AvatarHoverUserCardProps = {
label?: string;
name?: string;
ago?: string;
rest?: string;
job?: string;
statALabel?: string;
statAValue?: string;
statBLabel?: string;
statBValue?: string;
/** 포인터가 다가가 카드가 열리고 닫히는 한 바퀴(ms) */
duration?: number;
easing?: string;
/** 아바타·초점 테두리·채우는 막대·포인터 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type KeyEvt = { key: string };
// 이니셜은 표시 문자열에서 계산한다(하드코딩 금지) — 언어팩이 바뀌면 이니셜도 따라간다
function initialOf(name: string): string {
const p = name.trim().split(/\s+/);
return /[A-Za-z]/.test(p[0]) ? p.slice(0, 2).map((w) => w.charAt(0).toUpperCase()).join("") : p[0].charAt(0);
}
export default function AvatarHoverUserCard({
label = "사용자 카드 안내",
name = "편집자",
ago = "방금",
rest = "님이 문서를 고쳤습니다",
job = "문서 편집 담당",
statALabel = "고친 문서",
statAValue = "128",
statBLabel = "받은 감사",
statBValue = "36",
duration = 2000,
easing = "ease-out",
color = "#2a5fd8",
}: AvatarHoverUserCardProps): ReactNode {
const [loop, setLoop] = useState<boolean>(true);
const [open, setOpen] = useState<boolean>(true);
const openT = useRef(0) as Box<number>;
const closeT = useRef(0) as Box<number>;
useEffect(() => () => { window.clearTimeout(openT.current); window.clearTimeout(closeT.current); }, []);
// 호버 — 300ms 지연 뒤 열리고, 벗어나면 200ms 유예 뒤 닫힌다.
// 유예 동안 카드 위로 옮겨 가면 닫히지 않는다(카드에도 같은 핸들러)
function enter() {
window.clearTimeout(openT.current);
window.clearTimeout(closeT.current);
openT.current = window.setTimeout(() => setOpen(true), 300);
}
function leave() {
window.clearTimeout(openT.current);
window.clearTimeout(closeT.current);
closeT.current = window.setTimeout(() => setOpen(false), 200);
}
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section
className={loop ? `${styles.uc} ${styles.isDemo}` : styles.uc}
style={vars}
aria-label={label}
onPointerDown={() => setLoop(false)}
onFocus={() => setLoop(false)}
>
{/* 가짜 포인터 — 미리보기 루프에서만 보인다 */}
<span className={styles.pointer} aria-hidden="true" />
<p className={styles.line}>
<span className={styles.ago}>{ago}</span>
<button
className={styles.trigger}
type="button"
aria-describedby="uc-card"
onPointerEnter={enter}
onPointerLeave={leave}
/* 초점 — 지연 없이 열리고, blur·Escape 으로 닫힌다 */
onFocus={() => { window.clearTimeout(closeT.current); setOpen(true); }}
onBlur={() => setOpen(false)}
onKeyDown={(e: KeyEvt) => { if (e.key === "Escape") setOpen(false); }}
>
<span className={styles.av} aria-hidden="true">{initialOf(name)}</span>
<span className={styles.name}>{name}</span>
</button>
<span className={styles.rest}>{rest}</span>
</p>
<div
className={styles.card}
id="uc-card"
role="tooltip"
hidden={!open}
onPointerEnter={() => window.clearTimeout(closeT.current)}
onPointerLeave={leave}
>
<div className={styles.head}>
<span className={styles.bigav} aria-hidden="true">{initialOf(name)}</span>
<p className={styles.who}>
<span className={styles.cardName}>{name}</span>
<span className={styles.job}>{job}</span>
</p>
</div>
<div className={styles.stats}>
<p className={styles.stat}><span className={styles.val}>{statAValue}</span><span className={styles.lab}>{statALabel}</span></p>
<p className={styles.stat}><span className={styles.val}>{statBValue}</span><span className={styles.lab}>{statBLabel}</span></p>
</div>
<span className={styles.fill} aria-hidden="true"><span className={styles.fillIn} /></span>
</div>
</section>
);
}08 호버 사용자 카드 — 측정 누적면적 15.22% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 아바타 UI 9종 — 사진 파일 없이 만드는 사용자 표시


