08Hover user card
The small card rises only after the pointer has rested on the avatar and waits a moment before closing, so it does not vanish while the pointer travels onto it.
Best forScreens where only a name shows and readers need to check who it is
aria-describedbytranslateY:focus-visible
<main class="stage">
<section class="uc is-demo" aria-label="User card demo">
<!-- 가짜 포인터 — 미리보기 루프에서만 보인다 -->
<span class="uc__pointer" aria-hidden="true"></span>
<p class="uc__line">
<span class="uc__ago">Just now</span>
<button class="uc__trigger" type="button" aria-describedby="uc-card">
<span class="uc__av" aria-hidden="true"></span>
<span class="uc__name">Editor</span>
</button>
<span class="uc__rest">fixed a document</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">Editor</span>
<span class="uc__job">Document editor</span>
</p>
</div>
<div class="uc__stats">
<p class="uc__stat"><span class="uc__val">128</span><span class="uc__lab">Docs edited</span></p>
<p class="uc__stat"><span class="uc__val">36</span><span class="uc__lab">Thanks received</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 Hover user card — Measured area 15.22% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Avatar UI Design: 9 Parts, No Image Files
The post this demo lives in
If you like this effect - similar demos
Auth UI05 Password change that opens when three fields agree9 account security settings UI patterns
Auth UI06 Password reset request — a form that scores email validity to set its button state9 Login UI Design Patterns (Copy-Paste)
Auth UI01 Glassmorphic login card9 Login Form Design Patterns You Can Copy-Paste