03읽음 값이 켜지면 고리로 바뀌는 점 표시
각 항목의 읽음 값이 꺼져 있으면 채워진 점을 그리고, 값이 켜지는 순간 같은 크기의 테두리만 남은 고리 모양으로 반지름 값을 다시 그린다.
쓰임메시지 앱 알림 목록의 읽음 표시
SpringtickmarkRead

<main class="stage">
<div class="ud is-demo" id="ud">
<div class="ud__head">
<span class="ud__badge" id="udBadge">
<span class="ud__dotFill ud__badgeFill" id="udBadgeFill"></span>
<span class="ud__dotRing ud__badgeRing" id="udBadgeRing"></span>
</span>
<span class="ud__headLabel">알림</span>
<span class="ud__sr" id="udSr" aria-live="polite">4개 안 읽음</span>
</div>
<ul class="ud__list" id="udList" role="list"></ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html Spring() 이 매 프레임 위치·속도를 직접 적분하는 감쇠 스프링으로
// 채운 점(투명도 1) → 빈 원(테두리 굵기 2.4px) 전환을 만든다. CSS 트랜지션이 아니라
// requestAnimationFrame 루프가 값을 계산한다.
.stage { @include stage($stage-orange); }
.ud {
width: min(100%, 300px);
background: $subject-cream;
border-radius: $r-card;
padding: $sp-4;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: $sp-3;
}
.ud__head { display: flex; align-items: center; gap: $sp-2; }
.ud__headLabel { font-size: 13px; font-weight: 700; color: $subject-ink; }
.ud__badge { position: relative; width: 14px; height: 14px; display: inline-block; }
.ud__sr {
position: absolute; width: 1px; height: 1px; overflow: hidden;
clip: rect(0 0 0 0); margin: -1px; padding: 0; border: 0;
}
.ud__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: $sp-1; }
.ud__row {
position: relative;
width: 100%;
display: flex;
align-items: center;
gap: $sp-2;
border: none;
background: transparent;
padding: $sp-2 $sp-1;
border-radius: $r-sm;
cursor: pointer;
text-align: left;
}
.ud__row:hover { background: rgba($subject-ink, .05); }
// 계산 겹: index.html Spring() 이 점을 채우는 같은 c 값으로 이 행 톤의 opacity 도 함께 보간한다
// (안 읽음일수록 진하게) — 점 하나가 아니라 행 전체가 톤으로 안 읽음을 드러낸다.
.ud__rowTint {
position: absolute;
inset: 0;
border-radius: $r-sm;
background: $subject-blue-deep;
opacity: 0;
pointer-events: none;
}
.ud__dot { position: relative; z-index: 1; width: 13px; height: 13px; flex: 0 0 auto; }
.ud__dotFill,
.ud__dotRing {
position: absolute;
inset: 0;
border-radius: 50%;
box-sizing: border-box;
}
.ud__dotFill { background: $subject-blue-deep; opacity: 1; }
.ud__dotRing { border: 0 solid $subject-blue-deep; }
.ud__name { position: relative; z-index: 1; font-size: 12px; color: $subject-ink; }
@media (max-width: 340px) {
.ud { width: 100%; }
.ud__list { max-height: 105px; overflow-y: auto; }
}
@media (prefers-reduced-motion: reduce) {
.ud__row { transition: none; }
}.stage html, .stage body {
margin: 0;
overflow: hidden;
}
.stage .stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ud {
width: min(100%, 300px);
background: #fff7e6;
border-radius: 16px;
padding: 16px;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 12px;
}
.ud__head {
display: flex;
align-items: center;
gap: 8px;
}
.ud__headLabel {
font-size: 13px;
font-weight: 700;
color: #17141a;
}
.ud__badge {
position: relative;
width: 14px;
height: 14px;
display: inline-block;
}
.ud__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
margin: -1px;
padding: 0;
border: 0;
}
.ud__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.ud__row {
position: relative;
width: 100%;
display: flex;
align-items: center;
gap: 8px;
border: none;
background: transparent;
padding: 8px 4px;
border-radius: 8px;
cursor: pointer;
text-align: left;
}
.ud__row:hover {
background: rgba(23, 20, 26, 0.05);
}
.ud__rowTint {
position: absolute;
inset: 0;
border-radius: 8px;
background: #0a4fe8;
opacity: 0;
pointer-events: none;
}
.ud__dot {
position: relative;
z-index: 1;
width: 13px;
height: 13px;
flex: 0 0 auto;
}
.ud__dotFill,
.ud__dotRing {
position: absolute;
inset: 0;
border-radius: 50%;
box-sizing: border-box;
}
.ud__dotFill {
background: #0a4fe8;
opacity: 1;
}
.ud__dotRing {
border: 0 solid #0a4fe8;
}
.ud__name {
position: relative;
z-index: 1;
font-size: 12px;
color: #17141a;
}
@media (max-width: 340px) {
.ud {
width: 100%;
}
.ud__list {
max-height: 105px;
overflow-y: auto;
}
}
@media (prefers-reduced-motion: reduce) {
.ud__row {
transition: none;
}
}(function () {
"use strict";
var NAMES = [
"결제가 승인됐어요", "새 댓글이 달렸어요", "회의 10분 전이에요", "업로드가 끝났어요"
];
var root = document.getElementById("ud");
var list = document.getElementById("udList");
var sr = document.getElementById("udSr");
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var synthetic = false;
function human() {
if (synthetic) return;
if (root.classList.contains("is-demo")) root.classList.remove("is-demo");
}
// 계산 겹: CSS 트랜지션이 아니라 매 프레임 위치·속도를 직접 적분하는 감쇠 스프링으로
// 채운 점 → 빈 원 전환을 만든다. target 을 0 또는 1 로 바꾸면 그 순간의 속도를 그대로
// 이어받아 다음 목표로 다시 움직인다(끊기지 않는다).
function Spring(tintEl, fillEl, ringEl, stiffness, damping) {
var pos = 0, vel = 0, target = 0, raf = 0, last = null;
function apply(p) {
var c = Math.max(0, Math.min(1, p));
var over = Math.max(0, p - 1);
fillEl.style.opacity = String(1 - c);
ringEl.style.borderWidth = (c * 2.4).toFixed(2) + "px";
var bump = 1 + over * 0.85;
fillEl.parentNode.style.transform = "scale(" + bump.toFixed(3) + ")";
// 같은 스프링 값(c)으로 행 전체 톤도 함께 보간한다 — 안 읽음일수록 진하고, 읽으면 투명해진다
if (tintEl) tintEl.style.opacity = (0.10 * (1 - c)).toFixed(3);
}
function tick(ts) {
if (last === null) last = ts;
var dt = Math.min((ts - last) / 1000, 0.032);
last = ts;
var acc = (-stiffness * (pos - target) - damping * vel);
vel += acc * dt;
pos += vel * dt;
apply(pos);
if (Math.abs(pos - target) > 0.002 || Math.abs(vel) > 0.01) {
raf = requestAnimationFrame(tick);
} else {
pos = target;
apply(pos);
raf = 0;
last = null;
}
}
this.to = function (t, immediate) {
target = t;
if (immediate) {
pos = t; vel = 0; apply(pos);
if (raf) { cancelAnimationFrame(raf); raf = 0; }
last = null;
return;
}
if (!raf) { last = null; raf = requestAnimationFrame(tick); }
};
apply(0);
}
var badgeSpring = new Spring(
null,
document.getElementById("udBadgeFill"),
document.getElementById("udBadgeRing"),
reduce ? 999 : 220, reduce ? 999 : 17
);
var rows = [];
var unread = NAMES.length;
function updateSr() {
sr.textContent = unread + "개 안 읽음";
}
function markRead(row) {
if (row.read) return;
row.read = true;
unread--;
row.spring.to(1);
if (unread <= 0) badgeSpring.to(1);
updateSr();
}
function markUnread(row) {
if (!row.read) return;
row.read = false;
unread++;
row.spring.to(0);
badgeSpring.to(0);
updateSr();
}
NAMES.forEach(function (name, i) {
var li = document.createElement("li");
li.className = "ud__item";
li.setAttribute("role", "listitem");
var btn = document.createElement("button");
btn.type = "button";
btn.className = "ud__row";
btn.setAttribute("aria-pressed", "false");
var tint = document.createElement("span");
tint.className = "ud__rowTint";
tint.setAttribute("aria-hidden", "true");
var dot = document.createElement("span");
dot.className = "ud__dot";
var fill = document.createElement("span");
fill.className = "ud__dotFill";
var ring = document.createElement("span");
ring.className = "ud__dotRing";
dot.appendChild(fill);
dot.appendChild(ring);
var nameEl = document.createElement("span");
nameEl.className = "ud__name";
nameEl.textContent = name;
btn.appendChild(tint);
btn.appendChild(dot);
btn.appendChild(nameEl);
li.appendChild(btn);
list.appendChild(li);
var row = { btn: btn, read: false, spring: new Spring(tint, fill, ring, reduce ? 999 : 220, reduce ? 999 : 17) };
rows.push(row);
btn.addEventListener("click", function () {
human();
if (row.read) markUnread(row); else markRead(row);
btn.setAttribute("aria-pressed", row.read ? "true" : "false");
});
});
if (reduce) {
rows.forEach(function (row) { markRead(row); });
} else {
var idx = 0;
var phase = "read";
function loop() {
if (!root.classList.contains("is-demo")) return;
synthetic = true;
if (phase === "read") {
var row = rows[idx];
markRead(row);
row.btn.setAttribute("aria-pressed", "true");
idx++;
if (idx >= rows.length) { phase = "reset"; }
} else {
rows.forEach(function (r) {
r.read = false;
r.spring.to(0, true);
r.btn.setAttribute("aria-pressed", "false");
});
unread = NAMES.length;
badgeSpring.to(0, true);
updateSr();
idx = 0;
phase = "read";
}
synthetic = false;
window.setTimeout(loop, phase === "reset" ? 900 : 480);
}
window.setTimeout(loop, 250);
}
})();// NotifUnreadDotToHollow — vanilla 판(run/405/demo/03-.../index.html) 의 React 포트.
// 계산 겹: CSS 트랜지션이 아니라 매 프레임 위치·속도를 직접 적분하는 감쇠 스프링으로 채운
// 점 → 빈 원 전환을 만든다. target 을 0 또는 1 로 바꾸면 그 순간의 속도를 그대로 이어받아
// 다음 목표로 다시 움직인다(끊기지 않는다) — vanilla 와 동일 동작.
import { useEffect, useRef, useState } from "react";
import styles from "./NotifUnreadDotToHollow.module.scss";
type Box<T> = { current: T };
const NAMES = ["결제가 승인됐어요", "새 댓글이 달렸어요", "회의 10분 전이에요", "업로드가 끝났어요"];
class Spring {
private pos = 0;
private vel = 0;
private target = 0;
private raf = 0;
private last: number | null = null;
constructor(private tintEl: HTMLElement | null, private fillEl: HTMLElement, private ringEl: HTMLElement, private stiffness: number, private damping: number) {
this.apply(0);
}
private apply(p: number) {
const c = Math.max(0, Math.min(1, p));
const over = Math.max(0, p - 1);
this.fillEl.style.opacity = String(1 - c);
this.ringEl.style.borderWidth = (c * 2.4).toFixed(2) + "px";
const bump = 1 + over * 0.85;
const parent = this.fillEl.parentNode as HTMLElement | null;
if (parent) parent.style.transform = "scale(" + bump.toFixed(3) + ")";
// 같은 스프링 값(c)으로 행 전체 톤도 함께 보간한다 — 안 읽음일수록 진하고, 읽으면 투명해진다
if (this.tintEl) this.tintEl.style.opacity = (0.10 * (1 - c)).toFixed(3);
}
private tick = (ts: number) => {
if (this.last === null) this.last = ts;
const dt = Math.min((ts - this.last) / 1000, 0.032);
this.last = ts;
const acc = -this.stiffness * (this.pos - this.target) - this.damping * this.vel;
this.vel += acc * dt;
this.pos += this.vel * dt;
this.apply(this.pos);
if (Math.abs(this.pos - this.target) > 0.002 || Math.abs(this.vel) > 0.01) {
this.raf = requestAnimationFrame(this.tick);
} else {
this.pos = this.target;
this.apply(this.pos);
this.raf = 0;
this.last = null;
}
};
to(t: number, immediate?: boolean) {
this.target = t;
if (immediate) {
this.pos = t; this.vel = 0; this.apply(this.pos);
if (this.raf) { cancelAnimationFrame(this.raf); this.raf = 0; }
this.last = null;
return;
}
if (!this.raf) { this.last = null; this.raf = requestAnimationFrame(this.tick); }
}
}
type Row = { btn: HTMLButtonElement; read: boolean; spring: Spring };
export default function NotifUnreadDotToHollow() {
const [isDemo, setIsDemo] = useState(true);
const isDemoRef = useRef(true) as unknown as Box<boolean>;
const synthRef = useRef(false) as unknown as Box<boolean>;
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const listRef = useRef(null) as unknown as Box<HTMLUListElement>;
const srRef = useRef(null) as unknown as Box<HTMLParagraphElement>;
const badgeFillRef = useRef(null) as unknown as Box<HTMLSpanElement>;
const badgeRingRef = useRef(null) as unknown as Box<HTMLSpanElement>;
const badgeSpringRef = useRef(null) as unknown as Box<Spring | null>;
const rowsRef = useRef([] as Row[]) as unknown as Box<Row[]>;
const unreadRef = useRef(NAMES.length) as unknown as Box<number>;
const loopTimerRef = useRef(0) as unknown as Box<number>;
function human() {
if (synthRef.current) return;
if (isDemoRef.current) { isDemoRef.current = false; setIsDemo(false); }
}
function updateSr() {
if (srRef.current) srRef.current.textContent = unreadRef.current + "개 안 읽음";
}
function markRead(row: Row) {
if (row.read) return;
row.read = true;
unreadRef.current--;
row.spring.to(1);
if (unreadRef.current <= 0 && badgeSpringRef.current) badgeSpringRef.current.to(1);
updateSr();
}
function markUnread(row: Row) {
if (!row.read) return;
row.read = false;
unreadRef.current++;
row.spring.to(0);
if (badgeSpringRef.current) badgeSpringRef.current.to(0);
updateSr();
}
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const list = listRef.current;
if (!list || !badgeFillRef.current || !badgeRingRef.current) return;
badgeSpringRef.current = new Spring(null, badgeFillRef.current, badgeRingRef.current, reduce ? 999 : 220, reduce ? 999 : 17);
const rows: Row[] = [];
NAMES.forEach((name) => {
const li = document.createElement("li");
li.className = styles.item;
li.setAttribute("role", "listitem");
const btn = document.createElement("button");
btn.type = "button";
btn.className = styles.row;
btn.setAttribute("aria-pressed", "false");
const tint = document.createElement("span");
tint.className = styles.rowTint;
tint.setAttribute("aria-hidden", "true");
const dot = document.createElement("span");
dot.className = styles.dot;
const fill = document.createElement("span");
fill.className = styles.dotFill;
const ring = document.createElement("span");
ring.className = styles.dotRing;
dot.appendChild(fill);
dot.appendChild(ring);
const nameEl = document.createElement("span");
nameEl.className = styles.name;
nameEl.textContent = name;
btn.appendChild(tint);
btn.appendChild(dot);
btn.appendChild(nameEl);
li.appendChild(btn);
list.appendChild(li);
const row: Row = { btn, read: false, spring: new Spring(tint, fill, ring, reduce ? 999 : 220, reduce ? 999 : 17) };
rows.push(row);
btn.addEventListener("click", () => {
human();
if (row.read) markUnread(row); else markRead(row);
btn.setAttribute("aria-pressed", row.read ? "true" : "false");
});
});
rowsRef.current = rows;
if (reduce) {
rows.forEach((row) => markRead(row));
} else {
let idx = 0;
let phase = "read";
function loop() {
if (!isDemoRef.current) return;
synthRef.current = true;
if (phase === "read") {
const row = rowsRef.current[idx];
markRead(row);
row.btn.setAttribute("aria-pressed", "true");
idx++;
if (idx >= rowsRef.current.length) { phase = "reset"; }
} else {
rowsRef.current.forEach((r) => {
r.read = false;
r.spring.to(0, true);
r.btn.setAttribute("aria-pressed", "false");
});
unreadRef.current = NAMES.length;
if (badgeSpringRef.current) badgeSpringRef.current.to(0, true);
updateSr();
idx = 0;
phase = "read";
}
synthRef.current = false;
loopTimerRef.current = window.setTimeout(loop, phase === "reset" ? 900 : 480);
}
loopTimerRef.current = window.setTimeout(loop, 250);
}
return () => { window.clearTimeout(loopTimerRef.current); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div className={styles.ud + " " + (isDemo ? styles.isDemo : "")} ref={rootRef}>
<div className={styles.head}>
<span className={styles.badge}>
<span className={styles.dotFill + " " + styles.badgeFill} ref={badgeFillRef} />
<span className={styles.dotRing + " " + styles.badgeRing} ref={badgeRingRef} />
</span>
<span className={styles.headLabel}>알림</span>
<p className={styles.sr} aria-live="polite" ref={srRef}>4개 안 읽음</p>
</div>
<ul className={styles.list} role="list" ref={listRef} />
</div>
</main>
);
}03 읽음 값이 켜지면 고리로 바뀌는 점 표시 — 측정 누적면적 23.068% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · css 알림 애니메이션 9가지 — 알림 패널 뱃지 정리
이 데모가 속한 글
css 알림 애니메이션 9가지 — 알림 패널 뱃지 정리css 알림 애니메이션 아홉 가지를 한 패널에 모았다. 「알림이 몇 개 남았나요?」 라는 질문에 이 패널은 미확인 값 하나로 답한다. 3번은 그 값이 0이 되는 순간 뱃지를 고리 모양으로 되돌리고, 6번은 같은 값이 이 글의 데모 전부 보기


