알림 목록 ui 9가지 — 사이드바에 박히는 활동 피드와 이력 위젯
알림 목록 ui는 떴다 사라지는 토스트가 아니라 화면 한구석에 자리를 잡고 계속 쌓이는 목록을 말합니다. 사이드바에 박히는 이 아홉 개 위젯은 읽음 표시, 고치기 전 값, 로그 수준, 오늘의 건수까지 전부 실제 상태를 바꾸며 돌아갑니다.
01 feed-team-activity-stream
02 feed-change-history-diff
03 feed-notification-center-list
04 feed-social-mention-roll
05 feed-comment-thread-mini
06 feed-status-badge-timeline
07 feed-log-level-filter
08 feed-empty-first-activity
09 feed-summary-counter-bars
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 새 활동이 위에서 등장하는 팀 활동 스트림
- 02 고친 값을 되돌리는 변경 이력 diff
- 03 읽음과 안 읽음이 갈리는 알림 목록
- 04 옆에서 굴러 들어오는 소셜 언급 카드
- 05 답글이 접히는 댓글 스레드 미니
- 06 단계마다 불이 들어오는 상태 배지 타임라인
- 07 수준 칩으로 거르는 시스템 로그
- 08 빈 피드가 첫 활동으로 바뀌는 순간
- 09 오늘의 수와 시간대 막대를 세는 요약 카운터
순서는 위젯을 만드는 사람이 아니라 목록이 겪는 순서입니다. 먼저 팀의 일이 위에서부터 쌓이고(01), 고친 값의 앞뒤가 남고(02), 읽은 것과 안 읽은 것이 갈라집니다(03). 밖에서 언급이 굴러 들어오고(04), 댓글이 답글 뭉치로 접히며(05), 서류가 제출에서 승인까지 단계를 지나갑니다(06). 여기까지가 쌓이는 일입니다. 다음은 고르는 일입니다. 로그에서 수준을 고르고(07), 막 생긴 서비스의 빈 피드가 첫 활동으로 깨어납니다(08). 마지막으로 쌓인 것을 한눈에 접어 요약 카운터가 오늘을 셉니다(09). 푸시 알림 ui 와 갈리는 지점이 여기인데, 한 번 보여 주고 사라지는 쪽이 아니라 지나간 것까지 남겨 두고 다시 읽게 하는 쪽입니다. 같은 사이드바 한 칸을 차지하는 다른 붙박이로는 코드 블록 UI와 날씨 위젯이 있고, 세 편 모두 여기와 같은 320px 칸을 기준으로 재단했습니다. 다만 저 둘은 한 번 그려 놓으면 값이 잘 바뀌지 않는 붙박이인 반면, 여기 아홉 개는 줄이 계속 늘어난다는 전제 위에서 설계했습니다.
01새 활동이 위에서 등장하는 팀 활동 스트림
무엇이 보이나
누가 무엇을 언제 했는지가 아바타와 문장과 시간 한 줄로 쌓이는 목록입니다.

<main class="stage">
<section class="ts is-demo" id="ts" aria-label="팀 활동 스트림">
<header class="ts__h">
<span class="ts__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><path d="M160 216h-.4a8.1 8.1 0 0 1-7.1-5.2l-57.2-150l-32 70.5A8 8 0 0 1 56 136H24a8 8 0 0 1 0-16h26.8l37.9-83.3a8.2 8.2 0 0 1 7.6-4.7a8 8 0 0 1 7.2 5.2L161 188.1l31.8-63.7a8.2 8.2 0 0 1 7.2-4.4h32a8 8 0 0 1 0 16h-27.1l-37.7 75.6a8.2 8.2 0 0 1-7.2 4.4Z"/></svg></span>
<h2 class="ts__t">팀 활동</h2>
<span class="ts__n" id="ts-n" data-t="{n}건" aria-live="polite">4건</span>
</header>
<ul class="ts__list" role="log" aria-live="polite" id="ts-list">
<li class="ts__row ts__row--new" data-row="new">
<div class="ts__fold"><div class="ts__cell">
<span class="ts__av" style="--a:#93a4ca">김</span>
<p class="ts__line"><b>김하늘</b>이 가을 캠페인 문서를 수정했습니다</p>
<time class="ts__tm">방금</time>
<span class="ts__bar" aria-hidden="true"></span>
</div></div>
</li>
<li class="ts__row" data-row="1">
<div class="ts__fold"><div class="ts__cell">
<span class="ts__av" style="--a:#90c8b5">박</span>
<p class="ts__line"><b>박서아</b>가 주간 보고를 승인했습니다</p>
<time class="ts__tm">8분 전</time>
</div></div>
</li>
<li class="ts__row" data-row="2">
<div class="ts__fold"><div class="ts__cell">
<span class="ts__av" style="--a:#ff9ebb">이</span>
<p class="ts__line"><b>이준</b>이 재고 목록에 댓글을 남겼습니다</p>
<time class="ts__tm">26분 전</time>
</div></div>
</li>
<li class="ts__row ts__row--old" data-row="3">
<div class="ts__fold"><div class="ts__cell">
<span class="ts__av" style="--a:#c3d6e2">최</span>
<p class="ts__line"><b>최도윤</b>이 발주서를 업로드했습니다</p>
<time class="ts__tm">1시간 전</time>
</div></div>
</li>
</ul>
<button class="ts__add" type="button" id="ts-add">
<span class="ts__plus" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><path d="M228 128a100 100 0 1 1-100-100a100 100 0 0 1 100 100" opacity=".2"/><path d="M208 120h-72V48a8 8 0 0 0-16 0v72H48a8 8 0 0 0 0 16h72v72a8 8 0 0 0 16 0v-72h72a8 8 0 0 0 0-16"/></svg></span>
활동 추가
</button>
</section>
</main>
<template id="ts-tpl"><li class="ts__row is-in"><div class="ts__fold"><div class="ts__cell">
<span class="ts__av" style="--a:#b9a0ff">한</span>
<p class="ts__line"><b>한별</b>이 가을 캠페인 문서를 수정했습니다</p>
<time class="ts__tm">방금</time>
<span class="ts__bar" aria-hidden="true"></span>
</div></div></li></template>@use "sass:color";
@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-ink);
$line: color.mix($subject-cream, $stage-ink, 16%);
$soft: color.mix($subject-cream, $stage-ink, 8%);
.ts {
position: relative;
box-sizing: border-box;
width: min(304px, 92vw);
padding: $sp-3;
border: 1px solid $line;
border-radius: $r-card;
background: $stage-ink;
color: $subject-cream;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
overflow: hidden;
}
.ts__h {
display: flex;
align-items: center;
gap: $sp-1;
margin: 0 0 $sp-2;
}
.ts__ic { display: inline-flex; width: 14px; height: 14px; color: $subject-mint; }
.ts__t {
margin: 0;
font-size: 12px;
font-weight: 700;
letter-spacing: .02em;
}
.ts__n {
position: relative;
margin-left: auto;
min-width: 34px;
padding: 0 $sp-2;
border-radius: $r-pill;
background: $subject-blue;
color: #ffffff;
font-size: 11px;
font-weight: 700;
line-height: 1.7;
text-align: center;
}
.ts__list {
display: grid;
gap: $sp-1;
margin: 0;
padding: 0;
list-style: none;
}
// ── 활동 한 줄 ────────────────────────────────────
.ts__fold {
display: grid;
grid-template-rows: 1fr;
transition: grid-template-rows $duration $easing;
}
.ts__row.is-shut .ts__fold { grid-template-rows: 0fr; }
.ts__cell {
position: relative;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: $sp-2;
min-height: 0;
overflow: hidden;
padding: $sp-1 $sp-2;
border: 1px solid transparent;
border-radius: $r-sm;
background: $soft;
}
.ts__row--new .ts__fold { grid-template-rows: 0fr; }
.ts.is-demo .ts__row--new .ts__fold { grid-template-rows: 1fr; } // 루프에서는 펼친 상태를 키로 만든다
.ts__av {
display: inline-grid;
place-items: center;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--a);
color: $stage-ink;
font-size: 11px;
font-weight: 700;
}
.ts__line { margin: 0; color: rgba(255, 255, 255, .78); }
.ts__line b { color: $subject-cream; font-weight: 700; }
.ts__tm { color: rgba(255, 255, 255, .72); font-size: 10px; white-space: nowrap; }
// 새 줄의 생생함 표시 — 왼쪽 막대 (루프에서만 보임)
.ts__bar {
position: absolute;
left: 0;
top: 6px;
bottom: 6px;
width: 4px;
border-radius: $r-pill;
background: $subject-mint;
transform-origin: center;
visibility: hidden;
}
// 사람이 눌러 넣은 줄: 등장 애니메이션 (is-in 동안만)
.ts__row.is-in .ts__cell { animation: ts-rise $dur-base $ease-pop both; }
@keyframes ts-rise {
from { transform: translateY(-10px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.ts__add {
display: inline-flex;
align-items: center;
gap: $sp-1;
margin: $sp-2 0 0;
padding: $sp-1 $sp-3;
border: 1px solid $line;
border-radius: $r-pill;
background: transparent;
color: rgba(255, 255, 255, .72);
font-family: inherit;
font-size: 12px;
font-weight: 700;
line-height: 1.6;
cursor: pointer;
}
.ts__add:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
.ts__plus { display: inline-flex; width: 12px; height: 12px; }
// ── 격자 미리보기 전용 2초 루프 ─────────────────────
// 새 줄의 펼침은 컷·성장으로, 프레임 수는 막대 숨·들숨과 상승 팝이 채운다
@keyframes ts-fold-in {
0%, 19% { grid-template-rows: 0fr; }
26%, 87% { grid-template-rows: 1fr; }
88%, 100% { grid-template-rows: 0fr; }
}
@keyframes ts-new-rise {
0%, 20% { transform: translateY(-10px); }
28%, 87% { transform: translateY(0); }
88%, 100% { transform: translateY(-10px); }
}
@keyframes ts-bar-on {
0%, 19% { visibility: hidden; }
20%, 87% { visibility: visible; }
88%, 100% { visibility: hidden; }
}
@keyframes ts-bar-breath {
0%, 100% { transform: scaleY(.55); }
50% { transform: scaleY(1); }
}
@keyframes ts-av-pop {
0%, 20% { transform: scale(.4); }
26%, 87% { transform: scale(1); }
88%, 100% { transform: scale(.4); }
}
@keyframes ts-n-swap {
0%, 19% { transform: translateY(0); }
20%, 87% { transform: translateY(-3px); }
88%, 100% { transform: translateY(0); }
}
.ts.is-demo {
.ts__row--new .ts__fold {
animation-name: ts-fold-in;
animation-duration: $dur-loop;
animation-timing-function: $ease-pop;
animation-iteration-count: infinite;
}
.ts__row--new .ts__cell {
animation-name: ts-new-rise;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.ts__row--new .ts__bar {
visibility: visible;
animation-name: ts-bar-on, ts-bar-breath;
animation-duration: $dur-loop, 600ms;
animation-timing-function: steps(1, end), ease-in-out;
animation-iteration-count: infinite, infinite;
}
.ts__row--new .ts__av {
animation-name: ts-av-pop;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.ts__n {
animation-name: ts-n-swap;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
// 루프의 새 줄 배경은 한 프레임에 밝게 (반투명 겹침 금지)
.ts__row--new .ts__cell { background: rgba(255, 255, 255, .12); }
}
@media (max-width: 340px) {
.ts { padding: $sp-2; font-size: 11px; }
.ts__h { margin-bottom: $sp-1; }
.ts__cell { padding: 0 $sp-2; }
.ts__av { width: 16px; height: 16px; font-size: 10px; }
.ts__row--old { display: none; } // 좁은 화면에서는 오래된 줄부터 비운다
.ts__add { margin-top: $sp-1; padding: 0 $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.ts__fold { transition: none; }
.ts__row.is-in .ts__cell { animation: none !important; }
.ts.is-demo .ts__row--new .ts__fold,
.ts.is-demo .ts__row--new .ts__cell,
.ts.is-demo .ts__row--new .ts__av,
.ts.is-demo .ts__n { animation: none !important; transform: none !important; }
.ts.is-demo .ts__row--new .ts__bar { animation: none !important; }
// 움직임만 끄고 결과 상태는 남긴다 — 새 줄은 펼쳐진 채
.ts.is-demo .ts__row--new .ts__fold { grid-template-rows: 1fr !important; }
.ts.is-demo .ts__row--new .ts__bar { visibility: visible !important; }
.ts.is-demo .ts__row--new .ts__av { transform: scale(1) !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ts {
position: relative;
box-sizing: border-box;
width: min(304px, 92vw);
padding: 12px;
border: 1px solid rgb(23.5764705882%, 22.0862745098%, 22.9960784314%);
border-radius: 16px;
background: #17141a;
color: #fff7e6;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
overflow: hidden;
}
.ts__h {
display: flex;
align-items: center;
gap: 4px;
margin: 0 0 8px;
}
.ts__ic {
display: inline-flex;
width: 14px;
height: 14px;
color: #4cd4a6;
}
.ts__t {
margin: 0;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.02em;
}
.ts__n {
position: relative;
margin-left: auto;
min-width: 34px;
padding: 0 8px;
border-radius: 999px;
background: #2f6df6;
color: #ffffff;
font-size: 11px;
font-weight: 700;
line-height: 1.7;
text-align: center;
}
.ts__list {
display: grid;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
}
.ts__fold {
display: grid;
grid-template-rows: 1fr;
transition: grid-template-rows 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ts__row.is-shut .ts__fold {
grid-template-rows: 0fr;
}
.ts__cell {
position: relative;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 8px;
min-height: 0;
overflow: hidden;
padding: 4px 8px;
border: 1px solid transparent;
border-radius: 8px;
background: rgb(16.2980392157%, 14.9647058824%, 16.5960784314%);
}
.ts__row--new .ts__fold {
grid-template-rows: 0fr;
}
.ts.is-demo .ts__row--new .ts__fold {
grid-template-rows: 1fr;
}
.ts__av {
display: inline-grid;
place-items: center;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--a);
color: #17141a;
font-size: 11px;
font-weight: 700;
}
.ts__line {
margin: 0;
color: rgba(255, 255, 255, 0.78);
}
.ts__line b {
color: #fff7e6;
font-weight: 700;
}
.ts__tm {
color: rgba(255, 255, 255, 0.72);
font-size: 10px;
white-space: nowrap;
}
.ts__bar {
position: absolute;
left: 0;
top: 6px;
bottom: 6px;
width: 4px;
border-radius: 999px;
background: #4cd4a6;
transform-origin: center;
visibility: hidden;
}
.ts__row.is-in .ts__cell {
animation: ts-rise 300ms cubic-bezier(0.2, 1.4, 0.4, 1) both;
}
@keyframes ts-rise {
from {
transform: translateY(-10px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.ts__add {
display: inline-flex;
align-items: center;
gap: 4px;
margin: 8px 0 0;
padding: 4px 12px;
border: 1px solid rgb(23.5764705882%, 22.0862745098%, 22.9960784314%);
border-radius: 999px;
background: transparent;
color: rgba(255, 255, 255, 0.72);
font-family: inherit;
font-size: 12px;
font-weight: 700;
line-height: 1.6;
cursor: pointer;
}
.ts__add:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.ts__plus {
display: inline-flex;
width: 12px;
height: 12px;
}
@keyframes ts-fold-in {
0%, 19% {
grid-template-rows: 0fr;
}
26%, 87% {
grid-template-rows: 1fr;
}
88%, 100% {
grid-template-rows: 0fr;
}
}
@keyframes ts-new-rise {
0%, 20% {
transform: translateY(-10px);
}
28%, 87% {
transform: translateY(0);
}
88%, 100% {
transform: translateY(-10px);
}
}
@keyframes ts-bar-on {
0%, 19% {
visibility: hidden;
}
20%, 87% {
visibility: visible;
}
88%, 100% {
visibility: hidden;
}
}
@keyframes ts-bar-breath {
0%, 100% {
transform: scaleY(0.55);
}
50% {
transform: scaleY(1);
}
}
@keyframes ts-av-pop {
0%, 20% {
transform: scale(0.4);
}
26%, 87% {
transform: scale(1);
}
88%, 100% {
transform: scale(0.4);
}
}
@keyframes ts-n-swap {
0%, 19% {
transform: translateY(0);
}
20%, 87% {
transform: translateY(-3px);
}
88%, 100% {
transform: translateY(0);
}
}
.ts.is-demo .ts__row--new .ts__fold {
animation-name: ts-fold-in;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.ts.is-demo .ts__row--new .ts__cell {
animation-name: ts-new-rise;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.ts.is-demo .ts__row--new .ts__bar {
visibility: visible;
animation-name: ts-bar-on, ts-bar-breath;
animation-duration: 2s, 600ms;
animation-timing-function: steps(1, end), ease-in-out;
animation-iteration-count: infinite, infinite;
}
.ts.is-demo .ts__row--new .ts__av {
animation-name: ts-av-pop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.ts.is-demo .ts__n {
animation-name: ts-n-swap;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ts.is-demo .ts__row--new .ts__cell {
background: rgba(255, 255, 255, 0.12);
}
@media (max-width: 340px) {
.ts {
padding: 8px;
font-size: 11px;
}
.ts__h {
margin-bottom: 4px;
}
.ts__cell {
padding: 0 8px;
}
.ts__av {
width: 16px;
height: 16px;
font-size: 10px;
}
.ts__row--old {
display: none;
}
.ts__add {
margin-top: 4px;
padding: 0 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.ts__fold {
transition: none;
}
.ts__row.is-in .ts__cell {
animation: none !important;
}
.ts.is-demo .ts__row--new .ts__fold,
.ts.is-demo .ts__row--new .ts__cell,
.ts.is-demo .ts__row--new .ts__av,
.ts.is-demo .ts__n {
animation: none !important;
transform: none !important;
}
.ts.is-demo .ts__row--new .ts__bar {
animation: none !important;
}
.ts.is-demo .ts__row--new .ts__fold {
grid-template-rows: 1fr !important;
}
.ts.is-demo .ts__row--new .ts__bar {
visibility: visible !important;
}
.ts.is-demo .ts__row--new .ts__av {
transform: scale(1) !important;
}
}// 실제 동작: 활동 추가를 누르면 목록 맨 위에 새 줄이 자라 나타난다(등장만).
// 목록은 role="log" + aria-live="polite" 라 새 줄이 낭독기에도 전해진다.
var ts = document.getElementById('ts');
var tsList = document.getElementById('ts-list');
var tsN = document.getElementById('ts-n');
var tsCount = 4;
function tsCountUp() {
tsCount++;
tsN.textContent = tsN.getAttribute('data-t').replace('{n}', tsCount);
}
document.getElementById('ts-add').addEventListener('click', function () {
ts.classList.remove('is-demo');
var row = document.getElementById('ts-tpl').content.firstElementChild.cloneNode(true);
row.classList.add('is-shut');
tsList.insertBefore(row, tsList.firstChild);
void row.offsetWidth; // 두 단계로 나눠야 grid 전환이 애니메이션으로 잡힌다
row.classList.remove('is-shut');
tsCountUp();
});import { useEffect, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./TeamActivityStream.module.scss";
export type TeamActivityStreamProps = {
heading?: string;
groupLabel?: string;
addLabel?: string;
nowLabel?: string;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Row = { key: number; name: string; initial: string; av: string; text: string };
const INITIAL: Row[] = [
{ key: 1, name: "Park Seo-a", initial: "P", av: "#4cd4a6", text: " approved the weekly report" },
{ key: 2, name: "Lee Jun", initial: "L", av: "#ff9ebb", text: " left a comment on the stock list" },
{ key: 3, name: "Choi Do-yun", initial: "C", av: "#6ec8ff", text: " uploaded the purchase order" },
];
function ActivityIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<path d="M160 216h-.4a8.1 8.1 0 0 1-7.1-5.2l-57.2-150l-32 70.5A8 8 0 0 1 56 136H24a8 8 0 0 1 0-16h26.8l37.9-83.3a8.2 8.2 0 0 1 7.6-4.7a8 8 0 0 1 7.2 5.2L161 188.1l31.8-63.7a8.2 8.2 0 0 1 7.2-4.4h32a8 8 0 0 1 0 16h-27.1l-37.7 75.6a8.2 8.2 0 0 1-7.2 4.4Z" />
</svg>
);
}
function PlusIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<g>
<path d="M228 128a100 100 0 1 1-100-100a100 100 0 0 1 100 100" opacity=".2" />
<path d="M208 120h-72V48a8 8 0 0 0-16 0v72H48a8 8 0 0 0 0 16h72v72a8 8 0 0 0 16 0v-72h72a8 8 0 0 0 0-16" />
</g>
</svg>
);
}
export default function TeamActivityStream({
heading = "Team activity",
groupLabel = "Team activity stream",
addLabel = "Add activity",
nowLabel = "now",
demo = true,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: TeamActivityStreamProps) {
const [live, setLive] = useState(demo);
const [rows, setRows] = useState<Row[]>(INITIAL);
const [grown, setGrown] = useState<number[]>([]);
const [count, setCount] = useState(4);
// 방금 넣은 줄은 두 단계(shut → grow)로 렌더해야 grid 전환이 애니메이션으로 잡힌다
useEffect(() => {
const top = rows[0];
if (top && !grown.includes(top.key)) {
const raf = requestAnimationFrame(() => setGrown((g) => [...g, top.key]));
return () => cancelAnimationFrame(raf);
}
}, [rows, grown]);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function add() {
setLive(false);
const key = Date.now();
setRows((r) => [{ key, name: "Han Byeol", initial: "H", av: "#b9a0ff", text: " edited the autumn campaign doc" }, ...r]);
setCount((c) => c + 1);
}
const top = rows[0];
const cls = [styles.ts, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<header className={styles.h}>
<span className={styles.ic} aria-hidden="true"><ActivityIcon /></span>
<h2 className={styles.t}>{heading}</h2>
<span className={styles.n} aria-live="polite">
{live ? (
<>
<i className={`${styles.nA} ${styles.nLayer}`}>{count - 1}</i>
<i className={`${styles.nB} ${styles.nLayer}`}>{count}</i>
</>
) : (
count
)}
</span>
</header>
<ul className={styles.list} role="log" aria-live="polite">
<li className={styles.rowNew}>
<div className={styles.fold}>
<div className={styles.cell}>
<span className={styles.av} style={{ background: color }}>K</span>
<p className={styles.line}><b>Kim Haneul</b> edited the autumn campaign doc</p>
<time className={styles.tm}>{nowLabel}</time>
<span className={styles.bar} aria-hidden="true"></span>
</div>
</div>
</li>
{rows.map((r) => {
const isNew = r === top && !grown.includes(r.key);
const rowCls = [styles.row, isNew ? styles.shut : "", r === top ? styles.in : ""].filter(Boolean).join(" ");
return (
<li key={r.key} className={rowCls}>
<div className={styles.fold}>
<div className={styles.cell}>
<span className={styles.av} style={{ background: r.av }}>{r.initial}</span>
<p className={styles.line}><b>{r.name}</b>{r.text}</p>
<time className={styles.tm}>{r === top ? nowLabel : "26m ago"}</time>
</div>
</div>
</li>
);
})}
</ul>
<button className={styles.add} type="button" onClick={add}>
<span className={styles.plus} aria-hidden="true"><PlusIcon /></span>
{addLabel}
</button>
</section>
);
}01 · 새 활동이 위에서 등장하는 팀 활동 스트림 — 측정 누적면적 22.602% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
목록 전체에 role="log" 과 aria-live 를 붙여 두었고, 활동 추가를 누르면 새 줄이 접혔던 자리에서 펼쳐지며 위에 얹히고 나머지가 한 칸씩 내려갑니다.
document.getElementById('ts-add').addEventListener('click', function () {
ts.classList.remove('is-demo');
var row = document.getElementById('ts-tpl').content.firstElementChild.cloneNode(true);
row.classList.add('is-shut');
tsList.insertBefore(row, tsList.firstChild);
void row.offsetWidth; // 두 단계로 나눠야 grid 전환이 애니메이션으로 잡힌다
row.classList.remove('is-shut');
tsCountUp();
});
02고친 값을 되돌리는 변경 이력 diff
타임라인 ui 에서 변경 이력은 화면에서 어떻게 보이나
수정 전 값은 del 에 줄이 그어지고 수정 후 값은 ins 에 밑줄이 그어져 한눈에 앞뒤가 보입니다.

<main class="stage">
<section class="dh is-demo" id="dh" aria-label="견적 변경 이력">
<header class="dh__h">
<span class="dh__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M224 128a96 96 0 0 1-94.71 96H128a95.38 95.38 0 0 1-65.9-26.2a8 8 0 0 1 11-11.63a80 80 0 1 0-1.67-114.78a3 3 0 0 1-.26.25L44.59 96H72a8 8 0 0 1 0 16H24a8 8 0 0 1-8-8V56a8 8 0 0 1 16 0v29.8L60.25 60A96 96 0 0 1 224 128"/></g></svg></span>
<h2 class="dh__t">변경 이력</h2>
<span class="dh__n" id="dh-n" data-t="{n}곳 수정됨" aria-live="polite">3곳 수정됨</span>
</header>
<ul class="dh__list" id="dh-list">
<li class="dh__row" data-row="1">
<span class="dh__f">납기일</span>
<p class="dh__diff">
<span class="dh__a"><del>10월 15일</del> → <ins>10월 8일</ins></span>
<span class="dh__b"><b>10월 15일</b><i class="dh__tag">되돌림</i></span>
</p>
<button class="dh__undo" type="button" aria-pressed="false" data-f="납기일">
<span class="dh__u" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M224 128a96 96 0 0 1-94.71 96H128a95.38 95.38 0 0 1-65.9-26.2a8 8 0 0 1 11-11.63a80 80 0 1 0-1.67-114.78a3 3 0 0 1-.26.25L44.59 96H72a8 8 0 0 1 0 16H24a8 8 0 0 1-8-8V56a8 8 0 0 1 16 0v29.8L60.25 60A96 96 0 0 1 224 128"/></g></svg></span>
되돌리기
</button>
</li>
<li class="dh__row" data-row="2">
<span class="dh__f">담당자</span>
<p class="dh__diff">
<span class="dh__a"><del>이준</del> → <ins>박서아</ins></span>
<span class="dh__b"><b>이준</b><i class="dh__tag">되돌림</i></span>
</p>
<button class="dh__undo" type="button" aria-pressed="false" data-f="담당자">
<span class="dh__u" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M224 128a96 96 0 0 1-94.71 96H128a95.38 95.38 0 0 1-65.9-26.2a8 8 0 0 1 11-11.63a80 80 0 1 0-1.67-114.78a3 3 0 0 1-.26.25L44.59 96H72a8 8 0 0 1 0 16H24a8 8 0 0 1-8-8V56a8 8 0 0 1 16 0v29.8L60.25 60A96 96 0 0 1 224 128"/></g></svg></span>
되돌리기
</button>
</li>
<li class="dh__row" data-row="3">
<span class="dh__f">수량</span>
<p class="dh__diff">
<span class="dh__a"><del>120</del> → <ins>150</ins></span>
<span class="dh__b"><b>120</b><i class="dh__tag">되돌림</i></span>
</p>
<button class="dh__undo" type="button" aria-pressed="false" data-f="수량">
<span class="dh__u" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M224 128a96 96 0 0 1-94.71 96H128a95.38 95.38 0 0 1-65.9-26.2a8 8 0 0 1 11-11.63a80 80 0 1 0-1.67-114.78a3 3 0 0 1-.26.25L44.59 96H72a8 8 0 0 1 0 16H24a8 8 0 0 1-8-8V56a8 8 0 0 1 16 0v29.8L60.25 60A96 96 0 0 1 224 128"/></g></svg></span>
되돌리기
</button>
</li>
</ul>
<p class="dh__live" aria-live="polite" id="dh-live">항목을 되돌리면 여기에 알립니다</p>
</section>
</main>@use "sass:color";
@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
$line: color.mix($subject-ink, $stage-paper, 14%);
$soft: color.mix($subject-blue, $stage-paper, 7%);
.dh {
box-sizing: border-box;
width: min(304px, 92vw);
padding: $sp-3;
border: 1px solid $line;
border-radius: $r-card;
background: $stage-paper;
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
}
.dh__h {
display: flex;
align-items: center;
gap: $sp-1;
margin: 0 0 $sp-2;
}
.dh__ic { display: inline-flex; width: 14px; height: 14px; color: $color; }
.dh__t { margin: 0; font-size: 12px; font-weight: 700; }
.dh__n {
margin-left: auto;
padding: 0 $sp-2;
border-radius: $r-pill;
background: $soft;
color: $subject-blue-deep;
font-size: 11px;
font-weight: 700;
line-height: 1.7;
}
.dh__list {
display: grid;
gap: $sp-1;
margin: 0;
padding: 0;
list-style: none;
}
// ── 이력 한 줄 ────────────────────────────────────
.dh__row {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: $sp-2;
padding: $sp-1 $sp-2;
border: 1px solid $line;
border-radius: $r-sm;
background: $stage-paper;
}
.dh__f { color: rgba(23, 20, 26, .72); font-weight: 700; }
// 두 상태를 겹쳐 두고 steps 로 자른다 — 반투명 유령 프레임 금지
.dh__diff { position: relative; margin: 0; min-height: 18px; }
.dh__a, .dh__b { position: absolute; inset: 0; display: inline-flex; align-items: center; gap: $sp-1; }
.dh__b { visibility: hidden; }
.dh__row.is-back .dh__a { visibility: hidden; }
.dh__row.is-back .dh__b { visibility: visible; }
.dh__diff del {
color: $error;
font-size: 11px;
text-decoration: line-through;
text-decoration-thickness: 1.5px;
}
.dh__diff ins {
color: $color;
font-weight: 700;
text-decoration: underline;
text-decoration-thickness: 2px;
text-underline-offset: 3px;
}
.dh__b b { color: $subject-ink; font-weight: 700; }
.dh__tag {
padding: 0 4px;
border-radius: $r-xs;
background: $subject-mint;
color: $subject-ink;
font-size: 10px;
font-style: normal;
font-weight: 700;
}
.dh__undo {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0 $sp-2;
border: 1px solid $line;
border-radius: $r-pill;
background: $stage-paper;
color: $subject-ink;
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.7;
cursor: pointer;
}
.dh__undo:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.dh__u { display: inline-flex; width: 11px; height: 11px; }
.dh__row.is-back .dh__undo { background: $soft; color: $color; }
.dh__row.is-back .dh__u { transform: rotate(-360deg); transition: transform $dur-base $easing; }
.dh__live {
margin: $sp-2 0 0;
color: rgba(23, 20, 26, .72);
font-size: 10px;
}
// ── 격자 미리보기 전용 2초 루프 ─────────────────────
// 되돌림은 steps 컷으로 한 프레임에 자르고, 프레임 수는 아이콘 회전과 칩 팝이 채운다
@keyframes dh-a-1 { 0%, 24% { visibility: visible; } 25%, 87% { visibility: hidden; } 88%, 100% { visibility: visible; } }
@keyframes dh-b-1 { 0%, 24% { visibility: hidden; } 25%, 87% { visibility: visible; } 88%, 100% { visibility: hidden; } }
@keyframes dh-a-2 { 0%, 54% { visibility: visible; } 55%, 87% { visibility: hidden; } 88%, 100% { visibility: visible; } }
@keyframes dh-b-2 { 0%, 54% { visibility: hidden; } 55%, 87% { visibility: visible; } 88%, 100% { visibility: hidden; } }
@keyframes dh-spin-1 {
0%, 24% { transform: rotate(0deg); }
30%, 87% { transform: rotate(-360deg); }
88%, 100% { transform: rotate(0deg); }
}
@keyframes dh-spin-2 {
0%, 54% { transform: rotate(0deg); }
60%, 87% { transform: rotate(-360deg); }
88%, 100% { transform: rotate(0deg); }
}
@keyframes dh-pop-1 {
0%, 24% { transform: scale(1); }
27% { transform: scale(1.14); }
31%, 87% { transform: scale(1); }
88%, 100% { transform: scale(1); }
}
@keyframes dh-pop-2 {
0%, 54% { transform: scale(1); }
57% { transform: scale(1.14); }
61%, 100% { transform: scale(1); }
}
@keyframes dh-n-tick {
0%, 24% { transform: translateX(0); }
25%, 54% { transform: translateX(-2px); }
55%, 87% { transform: translateX(-4px); }
88%, 100% { transform: translateX(0); }
}
.dh.is-demo {
.dh__row[data-row="1"] .dh__a { animation-name: dh-a-1; }
.dh__row[data-row="1"] .dh__b { animation-name: dh-b-1; }
.dh__row[data-row="2"] .dh__a { animation-name: dh-a-2; }
.dh__row[data-row="2"] .dh__b { animation-name: dh-b-2; }
.dh__a, .dh__b {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.dh__row[data-row="1"] .dh__u { animation-name: dh-spin-1; }
.dh__row[data-row="2"] .dh__u { animation-name: dh-spin-2; }
.dh__u {
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.dh__row[data-row="1"] .dh__undo { animation-name: dh-pop-1; }
.dh__row[data-row="2"] .dh__undo { animation-name: dh-pop-2; }
.dh__undo {
animation-duration: $dur-loop;
animation-timing-function: $ease-pop;
animation-iteration-count: infinite;
}
.dh__n {
animation-name: dh-n-tick;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
}
@media (max-width: 340px) {
.dh { padding: $sp-2; font-size: 11px; }
.dh__h { margin-bottom: $sp-1; }
.dh__row { padding: 0 $sp-2; }
.dh__f { font-size: 10px; }
.dh__undo { font-size: 10px; }
.dh__live { margin-top: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.dh__row.is-back .dh__u { transform: none; transition: none; }
.dh.is-demo .dh__a,
.dh.is-demo .dh__b,
.dh.is-demo .dh__u,
.dh.is-demo .dh__undo,
.dh.is-demo .dh__n { animation: none !important; transform: none !important; }
// 움직임만 끄고 판정 결과는 남긴다 — 1·2번 줄은 되돌린 상태
.dh.is-demo .dh__row[data-row="1"] .dh__a,
.dh.is-demo .dh__row[data-row="2"] .dh__a { visibility: hidden !important; }
.dh.is-demo .dh__row[data-row="1"] .dh__b,
.dh.is-demo .dh__row[data-row="2"] .dh__b { visibility: visible !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);
}
.dh {
box-sizing: border-box;
width: min(304px, 92vw);
padding: 12px;
border: 1px solid rgb(87.262745098%, 87.0980392157%, 87.4274509804%);
border-radius: 16px;
background: #ffffff;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
}
.dh__h {
display: flex;
align-items: center;
gap: 4px;
margin: 0 0 8px;
}
.dh__ic {
display: inline-flex;
width: 14px;
height: 14px;
color: #2f6df6;
}
.dh__t {
margin: 0;
font-size: 12px;
font-weight: 700;
}
.dh__n {
margin-left: auto;
padding: 0 8px;
border-radius: 999px;
background: rgb(94.2901960784%, 95.9921568627%, 99.7529411765%);
color: #0a4fe8;
font-size: 11px;
font-weight: 700;
line-height: 1.7;
}
.dh__list {
display: grid;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
}
.dh__row {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 8px;
padding: 4px 8px;
border: 1px solid rgb(87.262745098%, 87.0980392157%, 87.4274509804%);
border-radius: 8px;
background: #ffffff;
}
.dh__f {
color: rgba(23, 20, 26, 0.72);
font-weight: 700;
}
.dh__diff {
position: relative;
margin: 0;
min-height: 18px;
}
.dh__a, .dh__b {
position: absolute;
inset: 0;
display: inline-flex;
align-items: center;
gap: 4px;
}
.dh__b {
visibility: hidden;
}
.dh__row.is-back .dh__a {
visibility: hidden;
}
.dh__row.is-back .dh__b {
visibility: visible;
}
.dh__diff del {
color: #c2361a;
font-size: 11px;
text-decoration: line-through;
text-decoration-thickness: 1.5px;
}
.dh__diff ins {
color: #2f6df6;
font-weight: 700;
text-decoration: underline;
text-decoration-thickness: 2px;
text-underline-offset: 3px;
}
.dh__b b {
color: #17141a;
font-weight: 700;
}
.dh__tag {
padding: 0 4px;
border-radius: 4px;
background: #4cd4a6;
color: #17141a;
font-size: 10px;
font-style: normal;
font-weight: 700;
}
.dh__undo {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0 8px;
border: 1px solid rgb(87.262745098%, 87.0980392157%, 87.4274509804%);
border-radius: 999px;
background: #ffffff;
color: #17141a;
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.7;
cursor: pointer;
}
.dh__undo:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.dh__u {
display: inline-flex;
width: 11px;
height: 11px;
}
.dh__row.is-back .dh__undo {
background: rgb(94.2901960784%, 95.9921568627%, 99.7529411765%);
color: #2f6df6;
}
.dh__row.is-back .dh__u {
transform: rotate(-360deg);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.dh__live {
margin: 8px 0 0;
color: rgba(23, 20, 26, 0.72);
font-size: 10px;
}
@keyframes dh-a-1 {
0%, 24% {
visibility: visible;
}
25%, 87% {
visibility: hidden;
}
88%, 100% {
visibility: visible;
}
}
@keyframes dh-b-1 {
0%, 24% {
visibility: hidden;
}
25%, 87% {
visibility: visible;
}
88%, 100% {
visibility: hidden;
}
}
@keyframes dh-a-2 {
0%, 54% {
visibility: visible;
}
55%, 87% {
visibility: hidden;
}
88%, 100% {
visibility: visible;
}
}
@keyframes dh-b-2 {
0%, 54% {
visibility: hidden;
}
55%, 87% {
visibility: visible;
}
88%, 100% {
visibility: hidden;
}
}
@keyframes dh-spin-1 {
0%, 24% {
transform: rotate(0deg);
}
30%, 87% {
transform: rotate(-360deg);
}
88%, 100% {
transform: rotate(0deg);
}
}
@keyframes dh-spin-2 {
0%, 54% {
transform: rotate(0deg);
}
60%, 87% {
transform: rotate(-360deg);
}
88%, 100% {
transform: rotate(0deg);
}
}
@keyframes dh-pop-1 {
0%, 24% {
transform: scale(1);
}
27% {
transform: scale(1.14);
}
31%, 87% {
transform: scale(1);
}
88%, 100% {
transform: scale(1);
}
}
@keyframes dh-pop-2 {
0%, 54% {
transform: scale(1);
}
57% {
transform: scale(1.14);
}
61%, 100% {
transform: scale(1);
}
}
@keyframes dh-n-tick {
0%, 24% {
transform: translateX(0);
}
25%, 54% {
transform: translateX(-2px);
}
55%, 87% {
transform: translateX(-4px);
}
88%, 100% {
transform: translateX(0);
}
}
.dh.is-demo .dh__row[data-row="1"] .dh__a {
animation-name: dh-a-1;
}
.dh.is-demo .dh__row[data-row="1"] .dh__b {
animation-name: dh-b-1;
}
.dh.is-demo .dh__row[data-row="2"] .dh__a {
animation-name: dh-a-2;
}
.dh.is-demo .dh__row[data-row="2"] .dh__b {
animation-name: dh-b-2;
}
.dh.is-demo .dh__a, .dh.is-demo .dh__b {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.dh.is-demo .dh__row[data-row="1"] .dh__u {
animation-name: dh-spin-1;
}
.dh.is-demo .dh__row[data-row="2"] .dh__u {
animation-name: dh-spin-2;
}
.dh.is-demo .dh__u {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.dh.is-demo .dh__row[data-row="1"] .dh__undo {
animation-name: dh-pop-1;
}
.dh.is-demo .dh__row[data-row="2"] .dh__undo {
animation-name: dh-pop-2;
}
.dh.is-demo .dh__undo {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.dh.is-demo .dh__n {
animation-name: dh-n-tick;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.dh {
padding: 8px;
font-size: 11px;
}
.dh__h {
margin-bottom: 4px;
}
.dh__row {
padding: 0 8px;
}
.dh__f {
font-size: 10px;
}
.dh__undo {
font-size: 10px;
}
.dh__live {
margin-top: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.dh__row.is-back .dh__u {
transform: none;
transition: none;
}
.dh.is-demo .dh__a,
.dh.is-demo .dh__b,
.dh.is-demo .dh__u,
.dh.is-demo .dh__undo,
.dh.is-demo .dh__n {
animation: none !important;
transform: none !important;
}
.dh.is-demo .dh__row[data-row="1"] .dh__a,
.dh.is-demo .dh__row[data-row="2"] .dh__a {
visibility: hidden !important;
}
.dh.is-demo .dh__row[data-row="1"] .dh__b,
.dh.is-demo .dh__row[data-row="2"] .dh__b {
visibility: visible !important;
}
}// 실제 동작: 되돌리기를 누른 항목만 이전 값으로 돌아간다 (화면 표시와 aria 상태 둘 다).
var dh = document.getElementById('dh');
var dhN = document.getElementById('dh-n');
var dhLive = document.getElementById('dh-live');
var dhLeft = 3;
function dhCount() {
dhN.textContent = dhN.getAttribute('data-t').replace('{n}', dhLeft);
}
document.querySelectorAll('.dh__undo').forEach(function (b) {
b.addEventListener('click', function () {
dh.classList.remove('is-demo');
var row = b.closest('.dh__row');
var back = row.classList.toggle('is-back');
b.setAttribute('aria-pressed', back ? 'true' : 'false');
dhLeft += back ? -1 : 1;
dhCount();
dhLive.textContent = back
? b.getAttribute('data-f') + '을(를) 이전 값으로 되돌렸습니다'
: b.getAttribute('data-f') + '을(를) 다시 수정했습니다';
});
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ChangeHistoryDiff.module.scss";
export type ChangeHistoryDiffProps = {
heading?: string;
groupLabel?: string;
undoLabel?: string;
revertedTag?: string;
liveHint?: string;
restoredMsg?: string;
editedAgainMsg?: string;
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Field = { key: string; f: string; oldV: string; newV: string };
const FIELDS: Field[] = [
{ key: "due", f: "Due date", oldV: "Oct 15", newV: "Oct 8" },
{ key: "owner", f: "Owner", oldV: "Lee Jun", newV: "Park Seo-a" },
{ key: "qty", f: "Qty", oldV: "120", newV: "150" },
];
function UndoIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<g>
<path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2" />
<path d="M224 128a96 96 0 0 1-94.71 96H128a95.38 95.38 0 0 1-65.9-26.2a8 8 0 0 1 11-11.63a80 80 0 1 0-1.67-114.78a3 3 0 0 1-.26.25L44.59 96H72a8 8 0 0 1 0 16H24a8 8 0 0 1-8-8V56a8 8 0 0 1 16 0v29.8L60.25 60A96 96 0 0 1 224 128" />
</g>
</svg>
);
}
export default function ChangeHistoryDiff({
heading = "Change history",
groupLabel = "Quote change history",
undoLabel = "Undo",
revertedTag = "Reverted",
liveHint = "Undoing an item reports here",
restoredMsg = " was restored to the previous value",
editedAgainMsg = " was edited again",
demo = true,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: ChangeHistoryDiffProps) {
const [live, setLive] = useState(demo);
const [back, setBack] = useState<Record<string, boolean>>({});
const [msg, setMsg] = useState(liveHint);
const left = FIELDS.filter((x) => !back[x.key]).length;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function undo(k: string, f: string) {
setLive(false);
const nextVal = !back[k];
setBack({ ...back, [k]: nextVal });
setMsg(nextVal ? f + restoredMsg : f + editedAgainMsg);
}
const cls = [styles.dh, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<header className={styles.h}>
<span className={styles.ic} aria-hidden="true"><UndoIcon /></span>
<h2 className={styles.t}>{heading}</h2>
<span className={styles.n} aria-live="polite">
{left} edited
</span>
</header>
<ul className={styles.list}>
{FIELDS.map((x, i) => (
<li key={x.key} className={[styles.row, i + 1 <= 2 ? styles[`r${i + 1}`] : "", back[x.key] ? styles.isBack : ""].filter(Boolean).join(" ")}>
<span className={styles.f}>{x.f}</span>
<p className={styles.diff}>
<span className={styles.a}>
<del>{x.oldV}</del> → <ins>{x.newV}</ins>
</span>
<span className={styles.b}>
<b>{x.oldV}</b>
<i className={styles.tag}>{revertedTag}</i>
</span>
</p>
<button className={styles.undoBtn} type="button" aria-pressed={!!back[x.key]} onClick={() => undo(x.key, x.f)}>
<span className={styles.u} aria-hidden="true"><UndoIcon /></span>
{undoLabel}
</button>
</li>
))}
</ul>
<p className={styles.live} aria-live="polite">{msg}</p>
</section>
);
}02 · 고친 값을 되돌리는 변경 이력 diff — 측정 누적면적 2.813% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
항목마다 붙은 되돌리기 단추를 누르면 그 항목만 이전 값으로 돌아가고 남은 수정 수가 갱신됩니다.
b.addEventListener('click', function () {
dh.classList.remove('is-demo');
var row = b.closest('.dh__row');
var back = row.classList.toggle('is-back');
b.setAttribute('aria-pressed', back ? 'true' : 'false');
dhLeft += back ? -1 : 1;
dhCount();
});
03읽음과 안 읽음이 갈리는 알림 목록
어떤 움직임인가
안 읽은 항목은 흰 카드에 파란 점이 맥박치고, 열면 점이 사라지며 읽은 색으로 한 프레임에 바뀝니다.

<main class="stage">
<section class="nc is-demo" id="nc" aria-label="알림 목록">
<header class="nc__h">
<span class="nc__bellw">
<span class="nc__bell" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2"/><path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z"/></g></svg></span>
<span class="nc__badge" id="nc-badge" data-t="안 읽은 알림 {n}건" aria-live="polite">안 읽은 알림 3건</span>
</span>
<button class="nc__all" type="button" id="nc-all">
<span class="nc__ck" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg></span>
모두 읽음
</button>
</header>
<ul class="nc__list" id="nc-list">
<li class="nc__row" data-row="1">
<button class="nc__it" type="button">
<span class="nc__dot" aria-hidden="true"></span>
<span class="nc__msg">주간 보고가 승인되었습니다</span>
<span class="nc__meta"><span class="nc__read" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg></span>5분 전</span>
</button>
</li>
<li class="nc__row" data-row="2">
<button class="nc__it" type="button">
<span class="nc__dot" aria-hidden="true"></span>
<span class="nc__msg">이준님이 댓글을 남겼습니다</span>
<span class="nc__meta"><span class="nc__read" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg></span>20분 전</span>
</button>
</li>
<li class="nc__row" data-row="3">
<button class="nc__it" type="button">
<span class="nc__dot" aria-hidden="true"></span>
<span class="nc__msg">발주서 파일이 도착했습니다</span>
<span class="nc__meta"><span class="nc__read" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg></span>1시간 전</span>
</button>
</li>
<li class="nc__row is-read nc__row--old" data-row="4">
<button class="nc__it" type="button">
<span class="nc__dot" aria-hidden="true"></span>
<span class="nc__msg">재고 목록이 갱신되었습니다</span>
<span class="nc__meta"><span class="nc__read" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg></span>어제</span>
</button>
</li>
</ul>
</section>
</main>@use "sass:color";
@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-yellow);
$ink: $subject-ink;
$soft: color.mix($subject-cream, $stage-yellow, 78%);
.nc {
box-sizing: border-box;
width: min(304px, 92vw);
padding: $sp-3;
border: 1px solid $ink;
border-radius: $r-card;
background: $stage-yellow;
color: $ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
}
.nc__h {
display: flex;
align-items: center;
gap: $sp-2;
margin: 0 0 $sp-2;
}
.nc__bellw { display: inline-flex; align-items: center; gap: $sp-1; }
.nc__bell { display: inline-flex; width: 15px; height: 15px; color: $ink; }
.nc__badge {
padding: 0 $sp-2;
border-radius: $r-pill;
background: $soft;
color: $ink;
font-size: 10px;
font-weight: 700;
line-height: 1.7;
white-space: nowrap;
}
.nc__all {
display: inline-flex;
align-items: center;
gap: 4px;
margin: 0 0 0 auto;
padding: 0 $sp-2;
border: 0;
border-radius: $r-pill;
background: $ink;
color: $stage-yellow;
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.7;
cursor: pointer;
}
.nc__all:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.nc__all:active { transform: scale(.94); }
.nc__ck { display: inline-flex; width: 11px; height: 11px; }
.nc__list {
display: grid;
gap: $sp-1;
margin: 0;
padding: 0;
list-style: none;
}
// ── 알림 한 줄 ────────────────────────────────────
.nc__it {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: $sp-2;
width: 100%;
margin: 0;
padding: $sp-1 $sp-2;
border: 1px solid rgba(23, 20, 26, .18);
border-radius: $r-sm;
background: #ffffff; // 안 읽음 = 흰 카드 (노랑 무대 위에서 밝게)
color: $ink;
font-family: inherit;
font-size: 12px;
line-height: 1.3;
text-align: left;
cursor: pointer;
}
.nc__it:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.nc__dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: $color;
}
.nc__msg { font-weight: 700; }
.nc__meta {
display: inline-flex;
align-items: center;
gap: 4px;
color: rgba(23, 20, 26, .72);
font-size: 10px;
white-space: nowrap;
}
.nc__read { display: none; color: $subject-mint; width: 11px; height: 11px; }
// 읽음 상태 — 사람이 클릭한 정적 경로
.nc__row.is-read .nc__it {
border-color: transparent;
background: rgba(23, 20, 26, .08);
}
.nc__row.is-read .nc__dot { display: none; }
.nc__row.is-read .nc__msg { color: rgba(23, 20, 26, .72); font-weight: 400; }
.nc__row.is-read .nc__read { display: inline-flex; }
// ── 격자 미리보기 전용 2초 루프 ─────────────────────
// 읽음 전환은 steps 컷으로 한 프레임에 자르고, 프레임 수는 체크 슬라이드와 점 맥박이 채운다
@keyframes nc-read-1 {
0%, 14% { border-color: rgba(23, 20, 26, .18); background: #ffffff; }
15%, 87% { border-color: transparent; background: rgba(23, 20, 26, .08); }
88%, 100% { border-color: rgba(23, 20, 26, .18); background: #ffffff; }
}
@keyframes nc-read-2 {
0%, 42% { border-color: rgba(23, 20, 26, .18); background: #ffffff; }
43%, 87% { border-color: transparent; background: rgba(23, 20, 26, .08); }
88%, 100% { border-color: rgba(23, 20, 26, .18); background: #ffffff; }
}
@keyframes nc-read-3 {
0%, 69% { border-color: rgba(23, 20, 26, .18); background: #ffffff; }
70%, 87% { border-color: transparent; background: rgba(23, 20, 26, .08); }
88%, 100% { border-color: rgba(23, 20, 26, .18); background: #ffffff; }
}
@keyframes nc-dot-cut-1 { 0%, 14% { opacity: 1; } 15%, 100% { opacity: 0; } }
@keyframes nc-dot-cut-2 { 0%, 42% { opacity: 1; } 43%, 100% { opacity: 0; } }
@keyframes nc-dot-cut-3 { 0%, 69% { opacity: 1; } 70%, 100% { opacity: 0; } }
@keyframes nc-dot-pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.5); }
}
@keyframes nc-msg-dim-1 { 0%, 14% { color: $ink; } 15%, 87% { color: rgba(23, 20, 26, .72); } 88%, 100% { color: $ink; } }
@keyframes nc-msg-dim-2 { 0%, 42% { color: $ink; } 43%, 87% { color: rgba(23, 20, 26, .72); } 88%, 100% { color: $ink; } }
@keyframes nc-msg-dim-3 { 0%, 69% { color: $ink; } 70%, 87% { color: rgba(23, 20, 26, .72); } 88%, 100% { color: $ink; } }
@keyframes nc-chk-in-1 { 0%, 14% { visibility: hidden; } 15% { visibility: visible; transform: translateX(6px); } 21%, 87% { visibility: visible; transform: translateX(0); } 88%, 100% { visibility: hidden; transform: translateX(6px); } }
@keyframes nc-chk-in-2 { 0%, 42% { visibility: hidden; } 43% { visibility: visible; transform: translateX(6px); } 49%, 87% { visibility: visible; transform: translateX(0); } 88%, 100% { visibility: hidden; transform: translateX(6px); } }
@keyframes nc-chk-in-3 { 0%, 69% { visibility: hidden; } 70% { visibility: visible; transform: translateX(6px); } 76%, 87% { visibility: visible; transform: translateX(0); } 88%, 100% { visibility: hidden; transform: translateX(6px); } }
@keyframes nc-all-press {
0%, 68% { transform: scale(1); }
70% { transform: scale(.9); }
74%, 87% { transform: scale(1); }
88%, 100% { transform: scale(1); }
}
@keyframes nc-badge-shrink {
0%, 14% { transform: scale(1); }
15%, 42% { transform: scale(.92); }
43%, 69% { transform: scale(.84); }
70%, 87% { transform: scale(.76); }
88%, 100% { transform: scale(1); }
}
.nc.is-demo {
.nc__row[data-row="1"] .nc__it { animation-name: nc-read-1; }
.nc__row[data-row="2"] .nc__it { animation-name: nc-read-2; }
.nc__row[data-row="3"] .nc__it { animation-name: nc-read-3; }
.nc__it {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.nc__row[data-row="1"] .nc__dot { animation-name: nc-dot-cut-1, nc-dot-pulse; }
.nc__row[data-row="2"] .nc__dot { animation-name: nc-dot-cut-2, nc-dot-pulse; }
.nc__row[data-row="3"] .nc__dot { animation-name: nc-dot-cut-3, nc-dot-pulse; }
.nc__dot {
animation-duration: $dur-loop, 700ms;
animation-timing-function: steps(1, end), ease-in-out;
animation-iteration-count: infinite, infinite;
}
.nc__row[data-row="1"] .nc__msg { animation-name: nc-msg-dim-1; }
.nc__row[data-row="2"] .nc__msg { animation-name: nc-msg-dim-2; }
.nc__row[data-row="3"] .nc__msg { animation-name: nc-msg-dim-3; }
.nc__msg {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.nc__row[data-row="1"] .nc__read { animation-name: nc-chk-in-1; }
.nc__row[data-row="2"] .nc__read { animation-name: nc-chk-in-2; }
.nc__row[data-row="3"] .nc__read { animation-name: nc-chk-in-3; }
.nc__read {
display: inline-flex;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.nc__all {
animation-name: nc-all-press;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.nc__badge {
animation-name: nc-badge-shrink;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
}
@media (max-width: 340px) {
.nc { padding: $sp-2; font-size: 11px; }
.nc__h { margin-bottom: $sp-1; }
.nc__it { padding: 0 $sp-2; font-size: 11px; }
.nc__msg { font-size: 11px; }
.nc__row--old { display: none; } // 좁은 화면에서는 읽은 항목부터 비운다
.nc__badge { font-size: 9px; }
}
@media (prefers-reduced-motion: reduce) {
.nc__all:active { transform: none; }
.nc.is-demo .nc__it,
.nc.is-demo .nc__dot,
.nc.is-demo .nc__msg,
.nc.is-demo .nc__read,
.nc.is-demo .nc__all,
.nc.is-demo .nc__badge { animation: none !important; transform: none !important; }
// 움직임만 끄고 판정 결과는 남긴다 — 세 줄 전부 읽은 상태
.nc.is-demo .nc__row[data-row="1"] .nc__it,
.nc.is-demo .nc__row[data-row="2"] .nc__it,
.nc.is-demo .nc__row[data-row="3"] .nc__it { background: rgba(23, 20, 26, .08) !important; border-color: transparent !important; }
.nc.is-demo .nc__row[data-row="1"] .nc__dot,
.nc.is-demo .nc__row[data-row="2"] .nc__dot,
.nc.is-demo .nc__row[data-row="3"] .nc__dot { opacity: 0 !important; }
.nc.is-demo .nc__row[data-row="1"] .nc__msg,
.nc.is-demo .nc__row[data-row="2"] .nc__msg,
.nc.is-demo .nc__row[data-row="3"] .nc__msg { color: rgba(23, 20, 26, .72) !important; }
.nc.is-demo .nc__row[data-row="1"] .nc__read,
.nc.is-demo .nc__row[data-row="2"] .nc__read,
.nc.is-demo .nc__row[data-row="3"] .nc__read { display: inline-flex !important; transform: 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);
}
.nc {
box-sizing: border-box;
width: min(304px, 92vw);
padding: 12px;
border: 1px solid #17141a;
border-radius: 16px;
background: #ffd23f;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
}
.nc__h {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 8px;
}
.nc__bellw {
display: inline-flex;
align-items: center;
gap: 4px;
}
.nc__bell {
display: inline-flex;
width: 15px;
height: 15px;
color: #17141a;
}
.nc__badge {
padding: 0 8px;
border-radius: 999px;
background: rgb(100%, 93.6705882353%, 75.7882352941%);
color: #17141a;
font-size: 10px;
font-weight: 700;
line-height: 1.7;
white-space: nowrap;
}
.nc__all {
display: inline-flex;
align-items: center;
gap: 4px;
margin: 0 0 0 auto;
padding: 0 8px;
border: 0;
border-radius: 999px;
background: #17141a;
color: #ffd23f;
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.7;
cursor: pointer;
}
.nc__all:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.nc__all:active {
transform: scale(0.94);
}
.nc__ck {
display: inline-flex;
width: 11px;
height: 11px;
}
.nc__list {
display: grid;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
}
.nc__it {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 8px;
width: 100%;
margin: 0;
padding: 4px 8px;
border: 1px solid rgba(23, 20, 26, 0.18);
border-radius: 8px;
background: #ffffff;
color: #17141a;
font-family: inherit;
font-size: 12px;
line-height: 1.3;
text-align: left;
cursor: pointer;
}
.nc__it:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.nc__dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #2f6df6;
}
.nc__msg {
font-weight: 700;
}
.nc__meta {
display: inline-flex;
align-items: center;
gap: 4px;
color: rgba(23, 20, 26, 0.72);
font-size: 10px;
white-space: nowrap;
}
.nc__read {
display: none;
color: #4cd4a6;
width: 11px;
height: 11px;
}
.nc__row.is-read .nc__it {
border-color: transparent;
background: rgba(23, 20, 26, 0.08);
}
.nc__row.is-read .nc__dot {
display: none;
}
.nc__row.is-read .nc__msg {
color: rgba(23, 20, 26, 0.72);
font-weight: 400;
}
.nc__row.is-read .nc__read {
display: inline-flex;
}
@keyframes nc-read-1 {
0%, 14% {
border-color: rgba(23, 20, 26, 0.18);
background: #ffffff;
}
15%, 87% {
border-color: transparent;
background: rgba(23, 20, 26, 0.08);
}
88%, 100% {
border-color: rgba(23, 20, 26, 0.18);
background: #ffffff;
}
}
@keyframes nc-read-2 {
0%, 42% {
border-color: rgba(23, 20, 26, 0.18);
background: #ffffff;
}
43%, 87% {
border-color: transparent;
background: rgba(23, 20, 26, 0.08);
}
88%, 100% {
border-color: rgba(23, 20, 26, 0.18);
background: #ffffff;
}
}
@keyframes nc-read-3 {
0%, 69% {
border-color: rgba(23, 20, 26, 0.18);
background: #ffffff;
}
70%, 87% {
border-color: transparent;
background: rgba(23, 20, 26, 0.08);
}
88%, 100% {
border-color: rgba(23, 20, 26, 0.18);
background: #ffffff;
}
}
@keyframes nc-dot-cut-1 {
0%, 14% {
opacity: 1;
}
15%, 100% {
opacity: 0;
}
}
@keyframes nc-dot-cut-2 {
0%, 42% {
opacity: 1;
}
43%, 100% {
opacity: 0;
}
}
@keyframes nc-dot-cut-3 {
0%, 69% {
opacity: 1;
}
70%, 100% {
opacity: 0;
}
}
@keyframes nc-dot-pulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.5);
}
}
@keyframes nc-msg-dim-1 {
0%, 14% {
color: #17141a;
}
15%, 87% {
color: rgba(23, 20, 26, 0.72);
}
88%, 100% {
color: #17141a;
}
}
@keyframes nc-msg-dim-2 {
0%, 42% {
color: #17141a;
}
43%, 87% {
color: rgba(23, 20, 26, 0.72);
}
88%, 100% {
color: #17141a;
}
}
@keyframes nc-msg-dim-3 {
0%, 69% {
color: #17141a;
}
70%, 87% {
color: rgba(23, 20, 26, 0.72);
}
88%, 100% {
color: #17141a;
}
}
@keyframes nc-chk-in-1 {
0%, 14% {
visibility: hidden;
}
15% {
visibility: visible;
transform: translateX(6px);
}
21%, 87% {
visibility: visible;
transform: translateX(0);
}
88%, 100% {
visibility: hidden;
transform: translateX(6px);
}
}
@keyframes nc-chk-in-2 {
0%, 42% {
visibility: hidden;
}
43% {
visibility: visible;
transform: translateX(6px);
}
49%, 87% {
visibility: visible;
transform: translateX(0);
}
88%, 100% {
visibility: hidden;
transform: translateX(6px);
}
}
@keyframes nc-chk-in-3 {
0%, 69% {
visibility: hidden;
}
70% {
visibility: visible;
transform: translateX(6px);
}
76%, 87% {
visibility: visible;
transform: translateX(0);
}
88%, 100% {
visibility: hidden;
transform: translateX(6px);
}
}
@keyframes nc-all-press {
0%, 68% {
transform: scale(1);
}
70% {
transform: scale(0.9);
}
74%, 87% {
transform: scale(1);
}
88%, 100% {
transform: scale(1);
}
}
@keyframes nc-badge-shrink {
0%, 14% {
transform: scale(1);
}
15%, 42% {
transform: scale(0.92);
}
43%, 69% {
transform: scale(0.84);
}
70%, 87% {
transform: scale(0.76);
}
88%, 100% {
transform: scale(1);
}
}
.nc.is-demo .nc__row[data-row="1"] .nc__it {
animation-name: nc-read-1;
}
.nc.is-demo .nc__row[data-row="2"] .nc__it {
animation-name: nc-read-2;
}
.nc.is-demo .nc__row[data-row="3"] .nc__it {
animation-name: nc-read-3;
}
.nc.is-demo .nc__it {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.nc.is-demo .nc__row[data-row="1"] .nc__dot {
animation-name: nc-dot-cut-1, nc-dot-pulse;
}
.nc.is-demo .nc__row[data-row="2"] .nc__dot {
animation-name: nc-dot-cut-2, nc-dot-pulse;
}
.nc.is-demo .nc__row[data-row="3"] .nc__dot {
animation-name: nc-dot-cut-3, nc-dot-pulse;
}
.nc.is-demo .nc__dot {
animation-duration: 2s, 700ms;
animation-timing-function: steps(1, end), ease-in-out;
animation-iteration-count: infinite, infinite;
}
.nc.is-demo .nc__row[data-row="1"] .nc__msg {
animation-name: nc-msg-dim-1;
}
.nc.is-demo .nc__row[data-row="2"] .nc__msg {
animation-name: nc-msg-dim-2;
}
.nc.is-demo .nc__row[data-row="3"] .nc__msg {
animation-name: nc-msg-dim-3;
}
.nc.is-demo .nc__msg {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.nc.is-demo .nc__row[data-row="1"] .nc__read {
animation-name: nc-chk-in-1;
}
.nc.is-demo .nc__row[data-row="2"] .nc__read {
animation-name: nc-chk-in-2;
}
.nc.is-demo .nc__row[data-row="3"] .nc__read {
animation-name: nc-chk-in-3;
}
.nc.is-demo .nc__read {
display: inline-flex;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.nc.is-demo .nc__all {
animation-name: nc-all-press;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.nc.is-demo .nc__badge {
animation-name: nc-badge-shrink;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.nc {
padding: 8px;
font-size: 11px;
}
.nc__h {
margin-bottom: 4px;
}
.nc__it {
padding: 0 8px;
font-size: 11px;
}
.nc__msg {
font-size: 11px;
}
.nc__row--old {
display: none;
}
.nc__badge {
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.nc__all:active {
transform: none;
}
.nc.is-demo .nc__it,
.nc.is-demo .nc__dot,
.nc.is-demo .nc__msg,
.nc.is-demo .nc__read,
.nc.is-demo .nc__all,
.nc.is-demo .nc__badge {
animation: none !important;
transform: none !important;
}
.nc.is-demo .nc__row[data-row="1"] .nc__it,
.nc.is-demo .nc__row[data-row="2"] .nc__it,
.nc.is-demo .nc__row[data-row="3"] .nc__it {
background: rgba(23, 20, 26, 0.08) !important;
border-color: transparent !important;
}
.nc.is-demo .nc__row[data-row="1"] .nc__dot,
.nc.is-demo .nc__row[data-row="2"] .nc__dot,
.nc.is-demo .nc__row[data-row="3"] .nc__dot {
opacity: 0 !important;
}
.nc.is-demo .nc__row[data-row="1"] .nc__msg,
.nc.is-demo .nc__row[data-row="2"] .nc__msg,
.nc.is-demo .nc__row[data-row="3"] .nc__msg {
color: rgba(23, 20, 26, 0.72) !important;
}
.nc.is-demo .nc__row[data-row="1"] .nc__read,
.nc.is-demo .nc__row[data-row="2"] .nc__read,
.nc.is-demo .nc__row[data-row="3"] .nc__read {
display: inline-flex !important;
transform: none !important;
}
}// 실제 동작: 항목을 열면 그 항목만 읽음이 되고, 모두 읽음은 남은 항목 전부를 읽음으로 바꾼다.
// 남은 수는 data-t 의 {n} 자리에 실제 숫자를 넣어 갱신한다.
var nc = document.getElementById('nc');
var ncBadge = document.getElementById('nc-badge');
var ncRows = [].slice.call(nc.querySelectorAll('.nc__row'));
function ncSync() {
var left = ncRows.filter(function (r) { return !r.classList.contains('is-read'); }).length;
ncBadge.textContent = ncBadge.getAttribute('data-t').replace('{n}', left);
}
ncRows.forEach(function (r) {
r.querySelector('.nc__it').addEventListener('click', function () {
nc.classList.remove('is-demo');
r.classList.add('is-read');
ncSync();
});
});
document.getElementById('nc-all').addEventListener('click', function () {
nc.classList.remove('is-demo');
ncRows.forEach(function (r) { r.classList.add('is-read'); });
ncSync();
});
ncSync();import { useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./NotificationCenterList.module.scss";
export type NotificationCenterListProps = {
groupLabel?: string;
markAllLabel?: string;
unreadMsg?: (n: number) => string;
items?: { key: string; msg: string; tm: string; read?: boolean }[];
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
function BellIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<g>
<path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2" />
<path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z" />
</g>
</svg>
);
}
function CheckIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<g>
<path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2" />
<path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32" />
</g>
</svg>
);
}
const DEFAULT_ITEMS = [
{ key: "n1", msg: "The weekly report was approved", tm: "5m ago" },
{ key: "n2", msg: "Lee Jun left a comment", tm: "20m ago" },
{ key: "n3", msg: "A purchase order file arrived", tm: "1h ago" },
{ key: "n4", msg: "The stock list was refreshed", tm: "yesterday", read: true },
];
export default function NotificationCenterList({
groupLabel = "Notification list",
markAllLabel = "Mark all read",
unreadMsg = (n) => `${n} unread`,
items = DEFAULT_ITEMS,
demo = true,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: NotificationCenterListProps) {
const [live, setLive] = useState(demo);
const [read, setRead] = useState<Record<string, boolean>>(
Object.fromEntries(items.map((x) => [x.key, !!x.read]))
);
const left = items.filter((x) => !read[x.key]).length;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function open(k: string) {
setLive(false);
setRead((r) => ({ ...r, [k]: true }));
}
function all() {
setLive(false);
setRead(Object.fromEntries(items.map((x) => [x.key, true])));
}
const cls = [styles.nc, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<header className={styles.h}>
<span className={styles.bellw}>
<span className={styles.bell} aria-hidden="true"><BellIcon /></span>
<span className={styles.badge} aria-live="polite">{unreadMsg(left)}</span>
</span>
<button className={styles.all} type="button" onClick={all}>
<span className={styles.ck} aria-hidden="true"><CheckIcon /></span>
{markAllLabel}
</button>
</header>
<ul className={styles.list}>
{items.map((x, i) => (
<li key={x.key} className={[styles.row, i + 1 <= 3 ? styles[`r${i + 1}`] : "", read[x.key] ? styles.isRead : ""].filter(Boolean).join(" ")}>
<button className={styles.it} type="button" onClick={() => open(x.key)}>
<span className={styles.dot} aria-hidden="true"></span>
<span className={styles.msg}>{x.msg}</span>
<span className={styles.meta}>
<span className={styles.readIc} aria-hidden="true"><CheckIcon /></span>
{x.tm}
</span>
</button>
</li>
))}
</ul>
</section>
);
}03 · 읽음과 안 읽음이 갈리는 알림 목록 — 측정 누적면적 15.917% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
모두 읽음을 누르면 남은 항목 전부가 읽음이 되고 남은 수가 갱신되어 읽힙니다.
function ncSync() {
var left = ncRows.filter(function (r) {
return !r.classList.contains('is-read');
}).length;
ncBadge.textContent = ncBadge.getAttribute('data-t')
.replace('{n}', left);
}
document.getElementById('nc-all').addEventListener('click', function () {
nc.classList.remove('is-demo');
ncRows.forEach(function (r) { r.classList.add('is-read'); });
ncSync();
});
04옆에서 굴러 들어오는 소셜 언급 카드
무엇이 보이나
누군가 언급할 때마다 카드가 오른쪽에서 굴러 들어와 살짝 틀어졌다 바로 섭니다.

<main class="stage">
<section class="mn is-demo" id="mn" aria-label="실시간 언급">
<header class="mn__h">
<span class="mn__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M128 24a104 104 0 0 0 0 208c21.51 0 44.1-6.48 60.43-17.33a8 8 0 0 0-8.86-13.33C166 210.38 146.21 216 128 216a88 88 0 1 1 88-88c0 26.45-10.88 32-20 32s-20-5.55-20-32V88a8 8 0 0 0-16 0v4.26a48 48 0 1 0 5.93 65.1c6 12 16.35 18.64 30.07 18.64c22.54 0 36-17.94 36-48A104.11 104.11 0 0 0 128 24m0 136a32 32 0 1 1 32-32a32 32 0 0 1-32 32"/></g></svg></span>
<h2 class="mn__t">실시간 언급</h2>
<span class="mn__live"><span class="mn__pdot" aria-hidden="true"></span>지금</span>
</header>
<ul class="mn__list" role="log" aria-live="polite" id="mn-list">
<li class="mn__row mn__row--new" data-row="new">
<div class="mn__fold"><div class="mn__card">
<p class="mn__txt">사이드바에 넣었는데 팀이 바로 알아봤어요</p>
<p class="mn__who">@sora.codes <time class="mn__tm">방금</time></p>
</div></div>
</li>
<li class="mn__row" data-row="1">
<div class="mn__fold"><div class="mn__card">
<p class="mn__txt">이 결제 화면 그대로 베껴도 되나요</p>
<p class="mn__who">@yuna.builds <time class="mn__tm">2분 전</time></p>
</div></div>
</li>
<li class="mn__row mn__row--old" data-row="2">
<div class="mn__fold"><div class="mn__card">
<p class="mn__txt">답글 기다리는 위젯 진짜 예쁘네요</p>
<p class="mn__who">@minho.dev <time class="mn__tm">9분 전</time></p>
</div></div>
</li>
</ul>
<button class="mn__more" type="button" id="mn-more">언급 불러오기</button>
</section>
</main>
<template id="mn-tpl"><li class="mn__row is-in"><div class="mn__fold"><div class="mn__card">
<p class="mn__txt">활동 피드 강의 글 저장했습니다</p>
<p class="mn__who">@dali.works <time class="mn__tm">방금</time></p>
</div></div></li></template>@use "sass:color";
@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-orange);
$card: $subject-cream;
$ink: $stage-orange;
.mn {
box-sizing: border-box;
width: min(304px, 92vw);
padding: $sp-3;
border: 1px solid rgba(255, 247, 230, .35);
border-radius: $r-card;
background: rgba(23, 20, 26, .08);
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
overflow: hidden; // 굴러 들어오는 카드가 무대에 삐져나오지 않게
}
.mn__h {
display: flex;
align-items: center;
gap: $sp-1;
margin: 0 0 $sp-2;
}
.mn__ic { display: inline-flex; width: 14px; height: 14px; color: $color; }
.mn__t { margin: 0; font-size: 12px; font-weight: 700; }
.mn__live {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: auto;
color: $subject-ink;
font-size: 10px;
font-weight: 700;
}
.mn__pdot {
width: 7px;
height: 7px;
border-radius: 50%;
background: $stage-yellow;
}
.mn__list {
display: grid;
gap: $sp-2;
margin: 0;
padding: 0;
list-style: none;
}
// ── 언급 카드 ─────────────────────────────────────
.mn__fold { display: grid; grid-template-rows: 1fr; }
.mn__card {
min-height: 0;
padding: $sp-2;
border-radius: $r-sm;
background: $card;
color: $subject-ink;
}
.mn__txt { margin: 0; font-size: 12px; font-weight: 700; line-height: 1.35; }
.mn__who {
display: flex;
align-items: center;
gap: $sp-1;
margin: $sp-1 0 0;
color: $subject-blue-deep;
font-size: 11px;
font-weight: 700;
}
.mn__tm { color: rgba(23, 20, 26, .72); font-size: 10px; }
.mn__row--new .mn__fold { grid-template-rows: 0fr; }
// 사람이 눌러 넣은 카드: 옆에서 굴러 들어와 살짝 틀어졌다 바로 선다 (is-in 동안만)
.mn__row.is-in .mn__card { animation: mn-roll $dur-enter $ease-pop both; }
@keyframes mn-roll {
from { transform: translateX(118%) rotate(6deg); }
60% { transform: translateX(-4%) rotate(-2deg); }
to { transform: translateX(0) rotate(0deg); }
}
.mn__more {
display: inline-flex;
align-items: center;
margin: $sp-2 0 0;
padding: $sp-1 $sp-3;
border: 1px solid rgba(255, 247, 230, .5);
border-radius: $r-pill;
background: transparent;
color: $subject-ink;
font-family: inherit;
font-size: 12px;
font-weight: 700;
line-height: 1.6;
cursor: pointer;
}
.mn__more:focus-visible { outline: 2px solid $stage-yellow; outline-offset: 2px; }
.mn__more:active { transform: scale(.94); }
// ── 격자 미리보기 전용 2초 루프 ─────────────────────
// 펼침은 컷으로 자르고, 카드는 옆에서 굴러 들어온다 — 퇴장은 재현하지 않는다
@keyframes mn-fold-in {
0%, 17% { grid-template-rows: 0fr; }
18% { grid-template-rows: 1fr; }
18.1%, 86% { grid-template-rows: 1fr; }
87%, 100% { grid-template-rows: 0fr; }
}
@keyframes mn-card-roll {
0%, 18% { transform: translateX(118%) rotate(6deg); }
30%, 86% { transform: translateX(0) rotate(0deg); }
87%, 100% { transform: translateX(118%) rotate(6deg); }
}
@keyframes mn-bump-1 {
0%, 18% { transform: translateY(0); }
23% { transform: translateY(3px); }
29%, 86% { transform: translateY(0); }
87%, 100% { transform: translateY(0); }
}
@keyframes mn-pdot {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.45); opacity: .6; }
}
@keyframes mn-btn-breath {
0%, 17% { transform: translateY(0); }
22% { transform: translateY(2px); }
27%, 86% { transform: translateY(0); }
87%, 100% { transform: translateY(0); }
}
.mn.is-demo {
.mn__row--new .mn__fold {
animation-name: mn-fold-in;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.mn__row--new .mn__card {
animation-name: mn-card-roll;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.mn__row[data-row="1"] .mn__card {
animation-name: mn-bump-1;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.mn__pdot {
animation-name: mn-pdot;
animation-duration: 700ms;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
.mn__more {
animation-name: mn-btn-breath;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
}
@media (max-width: 340px) {
.mn { padding: $sp-2; font-size: 11px; }
.mn__h { margin-bottom: $sp-1; }
.mn__card { padding: $sp-2; }
.mn__txt { font-size: 11px; }
.mn__row--old { display: none; } // 좁은 화면에서는 오래된 언급부터 비운다
.mn__more { margin-top: $sp-1; padding: 0 $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.mn__row.is-in .mn__card { animation: none !important; }
.mn__more:active { transform: none; }
.mn.is-demo .mn__row--new .mn__fold,
.mn.is-demo .mn__row--new .mn__card,
.mn.is-demo .mn__row[data-row="1"] .mn__card,
.mn.is-demo .mn__pdot,
.mn.is-demo .mn__more { animation: none !important; transform: none !important; }
// 움직임만 끄고 결과 상태는 남긴다 — 새 언급은 펼쳐진 채
.mn.is-demo .mn__row--new .mn__fold { grid-template-rows: 1fr !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.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);
}
.mn {
box-sizing: border-box;
width: min(304px, 92vw);
padding: 12px;
border: 1px solid rgba(255, 247, 230, 0.35);
border-radius: 16px;
background: rgba(23, 20, 26, 0.08);
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
overflow: hidden;
}
.mn__h {
display: flex;
align-items: center;
gap: 4px;
margin: 0 0 8px;
}
.mn__ic {
display: inline-flex;
width: 14px;
height: 14px;
color: #fff7e6;
}
.mn__t {
margin: 0;
font-size: 12px;
font-weight: 700;
}
.mn__live {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: auto;
color: #17141a;
font-size: 10px;
font-weight: 700;
}
.mn__pdot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #ffd23f;
}
.mn__list {
display: grid;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.mn__fold {
display: grid;
grid-template-rows: 1fr;
}
.mn__card {
min-height: 0;
padding: 8px;
border-radius: 8px;
background: #fff7e6;
color: #17141a;
}
.mn__txt {
margin: 0;
font-size: 12px;
font-weight: 700;
line-height: 1.35;
}
.mn__who {
display: flex;
align-items: center;
gap: 4px;
margin: 4px 0 0;
color: #0a4fe8;
font-size: 11px;
font-weight: 700;
}
.mn__tm {
color: rgba(23, 20, 26, 0.72);
font-size: 10px;
}
.mn__row--new .mn__fold {
grid-template-rows: 0fr;
}
.mn__row.is-in .mn__card {
animation: mn-roll 600ms cubic-bezier(0.2, 1.4, 0.4, 1) both;
}
@keyframes mn-roll {
from {
transform: translateX(118%) rotate(6deg);
}
60% {
transform: translateX(-4%) rotate(-2deg);
}
to {
transform: translateX(0) rotate(0deg);
}
}
.mn__more {
display: inline-flex;
align-items: center;
margin: 8px 0 0;
padding: 4px 12px;
border: 1px solid rgba(255, 247, 230, 0.5);
border-radius: 999px;
background: transparent;
color: #17141a;
font-family: inherit;
font-size: 12px;
font-weight: 700;
line-height: 1.6;
cursor: pointer;
}
.mn__more:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.mn__more:active {
transform: scale(0.94);
}
@keyframes mn-fold-in {
0%, 17% {
grid-template-rows: 0fr;
}
18% {
grid-template-rows: 1fr;
}
18.1%, 86% {
grid-template-rows: 1fr;
}
87%, 100% {
grid-template-rows: 0fr;
}
}
@keyframes mn-card-roll {
0%, 18% {
transform: translateX(118%) rotate(6deg);
}
30%, 86% {
transform: translateX(0) rotate(0deg);
}
87%, 100% {
transform: translateX(118%) rotate(6deg);
}
}
@keyframes mn-bump-1 {
0%, 18% {
transform: translateY(0);
}
23% {
transform: translateY(3px);
}
29%, 86% {
transform: translateY(0);
}
87%, 100% {
transform: translateY(0);
}
}
@keyframes mn-pdot {
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.45);
opacity: 0.6;
}
}
@keyframes mn-btn-breath {
0%, 17% {
transform: translateY(0);
}
22% {
transform: translateY(2px);
}
27%, 86% {
transform: translateY(0);
}
87%, 100% {
transform: translateY(0);
}
}
.mn.is-demo .mn__row--new .mn__fold {
animation-name: mn-fold-in;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.mn.is-demo .mn__row--new .mn__card {
animation-name: mn-card-roll;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.mn.is-demo .mn__row[data-row="1"] .mn__card {
animation-name: mn-bump-1;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.mn.is-demo .mn__pdot {
animation-name: mn-pdot;
animation-duration: 700ms;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
.mn.is-demo .mn__more {
animation-name: mn-btn-breath;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.mn {
padding: 8px;
font-size: 11px;
}
.mn__h {
margin-bottom: 4px;
}
.mn__card {
padding: 8px;
}
.mn__txt {
font-size: 11px;
}
.mn__row--old {
display: none;
}
.mn__more {
margin-top: 4px;
padding: 0 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.mn__row.is-in .mn__card {
animation: none !important;
}
.mn__more:active {
transform: none;
}
.mn.is-demo .mn__row--new .mn__fold,
.mn.is-demo .mn__row--new .mn__card,
.mn.is-demo .mn__row[data-row="1"] .mn__card,
.mn.is-demo .mn__pdot,
.mn.is-demo .mn__more {
animation: none !important;
transform: none !important;
}
.mn.is-demo .mn__row--new .mn__fold {
grid-template-rows: 1fr !important;
}
}// 실제 동작: 언급 불러오기를 누르면 다음 언급이 카드로 굴러 들어와 맨 위에 놓인다 (등장만).
// 목록은 role="log" + aria-live="polite" — 새 언급이 낭독기에도 전해진다.
var mn = document.getElementById('mn');
var mnList = document.getElementById('mn-list');
document.getElementById('mn-more').addEventListener('click', function () {
mn.classList.remove('is-demo');
var row = document.getElementById('mn-tpl').content.firstElementChild.cloneNode(true);
mnList.insertBefore(row, mnList.firstChild);
row.addEventListener('animationend', function () { row.classList.remove('is-in'); }, { once: true });
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SocialMentionRoll.module.scss";
export type SocialMentionRollProps = {
heading?: string;
groupLabel?: string;
liveLabel?: string;
moreLabel?: string;
nowLabel?: string;
cards?: { key: string; handle: string; text: string; tm: string }[];
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
function AtIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<g>
<path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" />
<path d="M128 24a104 104 0 0 0 0 208c21.51 0 44.1-6.48 60.43-17.33a8 8 0 0 0-8.86-13.33C166 210.38 146.21 216 128 216a88 88 0 1 1 88-88c0 26.45-10.88 32-20 32s-20-5.55-20-32V88a8 8 0 0 0-16 0v4.26a48 48 0 1 0 5.93 65.1c6 12 16.35 18.64 30.07 18.64c22.54 0 36-17.94 36-48A104.11 104.11 0 0 0 128 24m0 136a32 32 0 1 1 32-32a32 32 0 0 1-32 32" />
</g>
</svg>
);
}
const DEFAULT_CARDS = [
{ key: "m1", handle: "@yuna.builds", text: "Can I copy this checkout screen as is", tm: "2m ago" },
{ key: "m2", handle: "@minho.dev", text: "This waiting-for-reply widget is really pretty", tm: "9m ago" },
];
export default function SocialMentionRoll({
heading = "Live mentions",
groupLabel = "Live mentions",
liveLabel = "live",
moreLabel = "Fetch mentions",
nowLabel = "now",
cards = DEFAULT_CARDS,
demo = true,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: SocialMentionRollProps) {
const [live, setLive] = useState(demo);
const [rows, setRows] = useState(cards);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function more() {
setLive(false);
setRows((r) => [{ key: String(Date.now()), handle: "@dali.works", text: "Saved your activity feed article", tm: nowLabel }, ...r]);
}
const cls = [styles.mn, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<header className={styles.h}>
<span className={styles.ic} aria-hidden="true"><AtIcon /></span>
<h2 className={styles.t}>{heading}</h2>
<span className={styles.liveTag}>
<span className={styles.pdot} aria-hidden="true"></span>
{liveLabel}
</span>
</header>
<ul className={styles.list} role="log" aria-live="polite">
<li className={styles.rowNew}>
<div className={styles.fold}>
<div className={styles.card}>
<p className={styles.txt}>Put it in the sidebar and the team got it right away</p>
<p className={styles.who}>@sora.codes <time className={styles.tm}>{nowLabel}</time></p>
</div>
</div>
</li>
{rows.map((r, i) => (
<li key={r.key} className={[styles.row, i === 0 ? styles.row1 : "", i === rows.length - 1 ? styles.rowOld : ""].filter(Boolean).join(" ")}>
<div className={styles.fold}>
<div className={i === 0 ? styles.cardIn : styles.card}>
<p className={styles.txt}>{r.text}</p>
<p className={styles.who}>{r.handle} <time className={styles.tm}>{r.tm}</time></p>
</div>
</div>
</li>
))}
</ul>
<button className={styles.more} type="button" onClick={more}>{moreLabel}</button>
</section>
);
}04 · 옆에서 굴러 들어오는 소셜 언급 카드 — 측정 누적면적 30.385% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
새 언급은 등장만 하고 사라지지 않으며, 목록이 role="log" 라 언급이 차례로 읽힙니다.
document.getElementById('mn-more').addEventListener('click', function () {
mn.classList.remove('is-demo');
var row = document.getElementById('mn-tpl').content.firstElementChild.cloneNode(true);
mnList.insertBefore(row, mnList.firstChild);
row.addEventListener('animationend', function () {
row.classList.remove('is-in');
}, { once: true });
});
05답글이 접히는 댓글 스레드 미니
화면에서 일어나는 일
원댓글 아래 답글 뭉치가 grid-template-rows 의 0fr 과 1fr 사이에서 접혔다 펼쳐지고, 토글에는 aria-expanded 가 붙습니다.

<main class="stage">
<section class="ct is-demo" id="ct" aria-label="댓글 스레드">
<article class="ct__top">
<span class="ct__av" style="--a:#90c8b5">박</span>
<div class="ct__body">
<p class="ct__txt"><b>박서아</b> 새 배포 일정 어디서 볼 수 있나요?</p>
<time class="ct__tm">1시간 전</time>
</div>
</article>
<button class="ct__tg" type="button" id="ct-tg" aria-expanded="false" aria-controls="ct-re">
<span class="ct__caret" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="m208 96l-80 80l-80-80Z" opacity=".2"/><path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z"/></g></svg></span>
답글 2개
</button>
<div class="ct__fold" id="ct-re">
<div class="ct__inner">
<ul class="ct__list" role="log" aria-live="polite" id="ct-list">
<li class="ct__rep" data-row="1">
<span class="ct__av ct__av--s" style="--a:#ff9ebb">이</span>
<p class="ct__txt"><b>이준</b> 릴리즈 노트 하단에 있어요</p>
<time class="ct__tm">50분 전</time>
</li>
<li class="ct__rep ct__rep--old" data-row="2">
<span class="ct__av ct__av--s" style="--a:#c3d6e2">최</span>
<p class="ct__txt"><b>최도윤</b> 대시보드 공지에도 올라왔습니다</p>
<time class="ct__tm">42분 전</time>
</li>
<li class="ct__rep ct__rep--new" data-row="new">
<div class="ct__rfold"><div class="ct__rcell">
<span class="ct__av ct__av--s" style="--a:#93a4ca">김</span>
<p class="ct__txt"><b>김하늘</b> 일정표 링크도 같이 공유드릴게요</p>
<time class="ct__tm">방금</time>
</div></div>
</li>
</ul>
<button class="ct__add" type="button" id="ct-add">댓글 달기</button>
</div>
</div>
</section>
</main>
<template id="ct-tpl"><li class="ct__rep is-in"><div class="ct__rfold"><div class="ct__rcell">
<span class="ct__av ct__av--s" style="--a:#b9a0ff">한</span>
<p class="ct__txt"><b>한별</b> 발표 자료도 곧 올릴게요</p>
<time class="ct__tm">방금</time>
</div></div></li></template>@use "sass:color";
@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
$line: color.mix($subject-ink, $stage-paper, 14%);
$soft: color.mix($subject-blue, $stage-paper, 6%);
.ct {
box-sizing: border-box;
width: min(304px, 92vw);
padding: $sp-3;
border: 1px solid $line;
border-radius: $r-card;
background: $stage-paper;
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
}
// ── 원댓글 ────────────────────────────────────────
.ct__top {
display: grid;
grid-template-columns: auto 1fr;
gap: $sp-2;
padding: $sp-1 $sp-2;
border: 1px solid $line;
border-radius: $r-sm;
background: $soft;
}
.ct__av {
display: inline-grid;
place-items: center;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--a);
color: $subject-ink;
font-size: 11px;
font-weight: 700;
}
.ct__av--s { width: 16px; height: 16px; font-size: 10px; }
.ct__body { min-width: 0; }
.ct__txt { margin: 0; font-size: 12px; line-height: 1.35; }
.ct__txt b { font-weight: 700; }
.ct__tm { display: block; margin-top: 0; color: rgba(23, 20, 26, .72); font-size: 10px; }
// ── 답글 토글 ─────────────────────────────────────
.ct__tg {
display: inline-flex;
align-items: center;
gap: 4px;
margin: $sp-1 0 0 $sp-3;
padding: 0 $sp-2;
border: 0;
border-radius: $r-pill;
background: transparent;
color: $color;
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.7;
cursor: pointer;
}
.ct__tg:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.ct__caret { display: inline-flex; width: 11px; height: 11px; transform: rotate(-90deg); transition: transform $duration $easing; }
.ct__tg.is-open .ct__caret { transform: rotate(0deg); }
// ── 답글 뭉치 — grid-template-rows 로 접는다 ────────
.ct__fold {
display: grid;
grid-template-rows: 0fr;
margin-left: $sp-3;
transition: grid-template-rows $dur-base $easing;
}
.ct__fold.is-open { grid-template-rows: 1fr; }
.ct__inner { min-height: 0; overflow: hidden; }
.ct__list {
display: grid;
gap: $sp-1;
margin: $sp-1 0 0;
padding: 0;
list-style: none;
}
.ct__rep {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: $sp-2;
padding: $sp-1 $sp-2;
border: 1px dashed $line;
border-radius: $r-sm;
background: $stage-paper;
}
.ct__rep .ct__tm { margin: 0; }
// 새 답글의 자리 — 접혀 있다가 자란다
.ct__rep--new, .ct__rep.is-in { display: block; }
.ct__rfold { display: grid; grid-template-rows: 1fr; }
.ct__rep--new .ct__rfold,
.ct__rep.is-in .ct__rfold { grid-template-rows: 0fr; }
.ct__rep.is-grow .ct__rfold { grid-template-rows: 1fr; transition: grid-template-rows $dur-base $easing; }
.ct__rcell {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: $sp-2;
min-height: 0;
overflow: hidden;
}
// 사람이 눌러 넣은 답글: 아래에서 살짝 떠오른다 (is-in 동안만)
.ct__rep.is-in { border-style: solid; border-color: $color; }
.ct__rep.is-in .ct__rcell { animation: ct-rise $dur-base $ease-pop both; }
@keyframes ct-rise {
from { transform: translateY(8px); }
to { transform: translateY(0); }
}
.ct__add {
display: inline-flex;
align-items: center;
margin: $sp-2 0 0;
padding: $sp-1 $sp-3;
border: 1px solid $line;
border-radius: $r-pill;
background: $stage-paper;
color: $subject-ink;
font-family: inherit;
font-size: 12px;
font-weight: 700;
line-height: 1.6;
cursor: pointer;
}
.ct__add:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// ── 격자 미리보기 전용 2초 루프 ─────────────────────
@keyframes ct-fold-open {
0%, 14% { grid-template-rows: 0fr; }
22%, 86% { grid-template-rows: 1fr; }
87%, 100% { grid-template-rows: 0fr; }
}
@keyframes ct-caret-turn {
0%, 14% { transform: rotate(-90deg); }
19%, 86% { transform: rotate(0deg); }
87%, 100% { transform: rotate(-90deg); }
}
@keyframes ct-new-grow {
0%, 54% { grid-template-rows: 0fr; }
55% { grid-template-rows: 1fr; }
55.1%, 86% { grid-template-rows: 1fr; }
87%, 100% { grid-template-rows: 0fr; }
}
@keyframes ct-new-rise {
0%, 55% { transform: translateY(8px); }
62%, 86% { transform: translateY(0); }
87%, 100% { transform: translateY(8px); }
}
@keyframes ct-new-pop {
0%, 55% { transform: scale(.4); }
61%, 86% { transform: scale(1); }
87%, 100% { transform: scale(.4); }
}
@keyframes ct-add-breath {
0%, 54% { transform: translateY(0); }
59% { transform: translateY(-2px); }
64%, 86% { transform: translateY(0); }
87%, 100% { transform: translateY(0); }
}
.ct.is-demo {
.ct__fold {
animation-name: ct-fold-open;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.ct__caret {
animation-name: ct-caret-turn;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.ct__rep--new .ct__rfold {
animation-name: ct-new-grow;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ct__rep--new .ct__rcell {
animation-name: ct-new-rise;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.ct__rep--new .ct__av {
animation-name: ct-new-pop;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.ct__rep--new { border-style: solid; border-color: $color; }
.ct__add {
animation-name: ct-add-breath;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
}
@media (max-width: 340px) {
.ct { padding: $sp-2; font-size: 11px; }
.ct__txt { font-size: 11px; }
.ct__rep { padding: 0 $sp-2; }
.ct__rep--old { display: none; } // 좁은 화면에서는 오래된 답글부터 비운다
.ct__fold { margin-left: $sp-2; }
.ct__tg { margin-left: $sp-2; }
.ct__add { margin-top: $sp-1; padding: 0 $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.ct__fold { transition: none; }
.ct__caret { transition: none; }
.ct__rep.is-in .ct__rcell { animation: none !important; }
.ct.is-demo .ct__fold,
.ct.is-demo .ct__caret,
.ct.is-demo .ct__rep--new .ct__rfold,
.ct.is-demo .ct__rep--new .ct__rcell,
.ct.is-demo .ct__rep--new .ct__av,
.ct.is-demo .ct__add { animation: none !important; transform: none !important; }
// 움직임만 끄고 결과 상태는 남긴다 — 답글은 펼쳐진 채 새 답글까지
.ct.is-demo .ct__fold { grid-template-rows: 1fr !important; }
.ct.is-demo .ct__caret { transform: rotate(0deg) !important; }
.ct.is-demo .ct__rep--new .ct__rfold { grid-template-rows: 1fr !important; }
.ct.is-demo .ct__rep--new .ct__av { transform: scale(1) !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);
}
.ct {
box-sizing: border-box;
width: min(304px, 92vw);
padding: 12px;
border: 1px solid rgb(87.262745098%, 87.0980392157%, 87.4274509804%);
border-radius: 16px;
background: #ffffff;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
}
.ct__top {
display: grid;
grid-template-columns: auto 1fr;
gap: 8px;
padding: 4px 8px;
border: 1px solid rgb(87.262745098%, 87.0980392157%, 87.4274509804%);
border-radius: 8px;
background: rgb(95.1058823529%, 96.5647058824%, 99.7882352941%);
}
.ct__av {
display: inline-grid;
place-items: center;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--a);
color: #17141a;
font-size: 11px;
font-weight: 700;
}
.ct__av--s {
width: 16px;
height: 16px;
font-size: 10px;
}
.ct__body {
min-width: 0;
}
.ct__txt {
margin: 0;
font-size: 12px;
line-height: 1.35;
}
.ct__txt b {
font-weight: 700;
}
.ct__tm {
display: block;
margin-top: 0;
color: rgba(23, 20, 26, 0.72);
font-size: 10px;
}
.ct__tg {
display: inline-flex;
align-items: center;
gap: 4px;
margin: 4px 0 0 12px;
padding: 0 8px;
border: 0;
border-radius: 999px;
background: transparent;
color: #2f6df6;
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.7;
cursor: pointer;
}
.ct__tg:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.ct__caret {
display: inline-flex;
width: 11px;
height: 11px;
transform: rotate(-90deg);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ct__tg.is-open .ct__caret {
transform: rotate(0deg);
}
.ct__fold {
display: grid;
grid-template-rows: 0fr;
margin-left: 12px;
transition: grid-template-rows 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ct__fold.is-open {
grid-template-rows: 1fr;
}
.ct__inner {
min-height: 0;
overflow: hidden;
}
.ct__list {
display: grid;
gap: 4px;
margin: 4px 0 0;
padding: 0;
list-style: none;
}
.ct__rep {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 8px;
padding: 4px 8px;
border: 1px dashed rgb(87.262745098%, 87.0980392157%, 87.4274509804%);
border-radius: 8px;
background: #ffffff;
}
.ct__rep .ct__tm {
margin: 0;
}
.ct__rep--new, .ct__rep.is-in {
display: block;
}
.ct__rfold {
display: grid;
grid-template-rows: 1fr;
}
.ct__rep--new .ct__rfold,
.ct__rep.is-in .ct__rfold {
grid-template-rows: 0fr;
}
.ct__rep.is-grow .ct__rfold {
grid-template-rows: 1fr;
transition: grid-template-rows 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ct__rcell {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 8px;
min-height: 0;
overflow: hidden;
}
.ct__rep.is-in {
border-style: solid;
border-color: #2f6df6;
}
.ct__rep.is-in .ct__rcell {
animation: ct-rise 300ms cubic-bezier(0.2, 1.4, 0.4, 1) both;
}
@keyframes ct-rise {
from {
transform: translateY(8px);
}
to {
transform: translateY(0);
}
}
.ct__add {
display: inline-flex;
align-items: center;
margin: 8px 0 0;
padding: 4px 12px;
border: 1px solid rgb(87.262745098%, 87.0980392157%, 87.4274509804%);
border-radius: 999px;
background: #ffffff;
color: #17141a;
font-family: inherit;
font-size: 12px;
font-weight: 700;
line-height: 1.6;
cursor: pointer;
}
.ct__add:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
@keyframes ct-fold-open {
0%, 14% {
grid-template-rows: 0fr;
}
22%, 86% {
grid-template-rows: 1fr;
}
87%, 100% {
grid-template-rows: 0fr;
}
}
@keyframes ct-caret-turn {
0%, 14% {
transform: rotate(-90deg);
}
19%, 86% {
transform: rotate(0deg);
}
87%, 100% {
transform: rotate(-90deg);
}
}
@keyframes ct-new-grow {
0%, 54% {
grid-template-rows: 0fr;
}
55% {
grid-template-rows: 1fr;
}
55.1%, 86% {
grid-template-rows: 1fr;
}
87%, 100% {
grid-template-rows: 0fr;
}
}
@keyframes ct-new-rise {
0%, 55% {
transform: translateY(8px);
}
62%, 86% {
transform: translateY(0);
}
87%, 100% {
transform: translateY(8px);
}
}
@keyframes ct-new-pop {
0%, 55% {
transform: scale(0.4);
}
61%, 86% {
transform: scale(1);
}
87%, 100% {
transform: scale(0.4);
}
}
@keyframes ct-add-breath {
0%, 54% {
transform: translateY(0);
}
59% {
transform: translateY(-2px);
}
64%, 86% {
transform: translateY(0);
}
87%, 100% {
transform: translateY(0);
}
}
.ct.is-demo .ct__fold {
animation-name: ct-fold-open;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.ct.is-demo .ct__caret {
animation-name: ct-caret-turn;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.ct.is-demo .ct__rep--new .ct__rfold {
animation-name: ct-new-grow;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ct.is-demo .ct__rep--new .ct__rcell {
animation-name: ct-new-rise;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.ct.is-demo .ct__rep--new .ct__av {
animation-name: ct-new-pop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.ct.is-demo .ct__rep--new {
border-style: solid;
border-color: #2f6df6;
}
.ct.is-demo .ct__add {
animation-name: ct-add-breath;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.ct {
padding: 8px;
font-size: 11px;
}
.ct__txt {
font-size: 11px;
}
.ct__rep {
padding: 0 8px;
}
.ct__rep--old {
display: none;
}
.ct__fold {
margin-left: 8px;
}
.ct__tg {
margin-left: 8px;
}
.ct__add {
margin-top: 4px;
padding: 0 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.ct__fold {
transition: none;
}
.ct__caret {
transition: none;
}
.ct__rep.is-in .ct__rcell {
animation: none !important;
}
.ct.is-demo .ct__fold,
.ct.is-demo .ct__caret,
.ct.is-demo .ct__rep--new .ct__rfold,
.ct.is-demo .ct__rep--new .ct__rcell,
.ct.is-demo .ct__rep--new .ct__av,
.ct.is-demo .ct__add {
animation: none !important;
transform: none !important;
}
.ct.is-demo .ct__fold {
grid-template-rows: 1fr !important;
}
.ct.is-demo .ct__caret {
transform: rotate(0deg) !important;
}
.ct.is-demo .ct__rep--new .ct__rfold {
grid-template-rows: 1fr !important;
}
.ct.is-demo .ct__rep--new .ct__av {
transform: scale(1) !important;
}
}// 실제 동작: 답글 2개 토글은 접힘·펼침을 바꾸고(aria-expanded),
// 댓글 달기는 답글 칸 맨 아래에 새 댓글을 자라나게 한다 (등장만).
var ct = document.getElementById('ct');
var ctTg = document.getElementById('ct-tg');
var ctRe = document.getElementById('ct-re');
var ctList = document.getElementById('ct-list');
function ctOpen(on) {
ctRe.classList.toggle('is-open', on);
ctTg.setAttribute('aria-expanded', on ? 'true' : 'false');
ctTg.classList.toggle('is-open', on);
}
ctTg.addEventListener('click', function () {
ct.classList.remove('is-demo');
ctOpen(!ctRe.classList.contains('is-open'));
});
document.getElementById('ct-add').addEventListener('click', function () {
ct.classList.remove('is-demo');
ctOpen(true);
var row = document.getElementById('ct-tpl').content.firstElementChild.cloneNode(true);
ctList.appendChild(row);
void row.offsetWidth; // 두 단계로 나눠야 grid 전환이 애니메이션으로 잡힌다
row.classList.add('is-grow'); // is-in 은 그대로 — 새 답글 표시(테두리)로 남긴다
});import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CommentThreadMini.module.scss";
export type CommentThreadMiniProps = {
groupLabel?: string;
topName?: string;
topText?: string;
topTm?: string;
repliesLabel?: string;
addLabel?: string;
nowLabel?: string;
replies?: { key: number; name: string; initial: string; av: string; text: string }[];
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
function CaretIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<g>
<path d="m208 96l-80 80l-80-80Z" opacity=".2" />
<path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z" />
</g>
</svg>
);
}
const DEFAULT_REPLIES = [
{ key: 1, name: "Lee Jun", initial: "L", av: "#ff9ebb", text: " It's at the bottom of the release notes" },
{ key: 2, name: "Choi Do-yun", initial: "C", av: "#6ec8ff", text: " It's on the dashboard notice too" },
];
export default function CommentThreadMini({
groupLabel = "Comment thread",
topName = "Park Seo-a",
topText = " Where can I see the new release schedule?",
topTm = "1h ago",
repliesLabel = "2 replies",
addLabel = "Reply",
nowLabel = "now",
replies = DEFAULT_REPLIES,
demo = true,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: CommentThreadMiniProps) {
const [live, setLive] = useState(demo);
const [open, setOpen] = useState(false);
const [rows, setRows] = useState(replies);
const [grown, setGrown] = useState<number[]>([]);
// 방금 넣은 답글은 두 단계(shut → grow)로 렌더해야 grid 전환이 애니메이션으로 잡힌다
const last = rows[rows.length - 1];
useEffect(() => {
if (last && !grown.includes(last.key)) {
const raf = requestAnimationFrame(() => setGrown((g) => [...g, last.key]));
return () => cancelAnimationFrame(raf);
}
}, [rows, grown, last]);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function toggle() {
setLive(false);
setOpen((o) => !o);
}
function add() {
setLive(false);
setOpen(true);
setRows((r) => [...r, { key: Date.now(), name: "Han Byeol", initial: "H", av: "#b9a0ff", text: " I'll post the deck soon too" }]);
}
const cls = [styles.ct, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<article className={styles.top}>
<span className={styles.av} style={{ background: "#4cd4a6" }}>P</span>
<div className={styles.body}>
<p className={styles.txt}><b>{topName}</b>{topText}</p>
<time className={styles.tm}>{topTm}</time>
</div>
</article>
<button
className={[styles.tg, open ? styles.tgOpen : ""].filter(Boolean).join(" ")}
type="button"
aria-expanded={open}
onClick={toggle}
>
<span className={styles.caret} aria-hidden="true"><CaretIcon /></span>
{repliesLabel}
</button>
<div className={[styles.fold, open ? styles.foldOpen : ""].filter(Boolean).join(" ")}>
<div className={styles.inner}>
<ul className={styles.list} role="log" aria-live="polite">
{rows.map((r, i) => {
const isNew = r === last && !grown.includes(r.key);
const rowCls = [
styles.rep,
i === 0 ? styles.rep1 : "",
i === 1 && rows.length > 2 ? styles.repOld : "",
!isNew ? styles.grow : "",
isNew ? styles.shut : "",
].filter(Boolean).join(" ");
return (
<li key={r.key} className={rowCls}>
<div className={styles.rfold}>
<div className={styles.rcell}>
<span className={styles.avS} style={{ background: r.av }}>{r.initial}</span>
<p className={styles.txt}><b>{r.name}</b>{r.text}</p>
<time className={styles.tm}>{r === last ? nowLabel : "50m ago"}</time>
</div>
</div>
</li>
);
})}
<li className={styles.repNew}>
<div className={styles.rfold}>
<div className={styles.rcell}>
<span className={styles.avS} style={{ background: "#2f6df6" }}>K</span>
<p className={styles.txt}><b>Kim Haneul</b> I'll share the schedule link too</p>
<time className={styles.tm}>{nowLabel}</time>
</div>
</div>
</li>
</ul>
<button className={styles.add} type="button" onClick={add}>{addLabel}</button>
</div>
</div>
</section>
);
}05 · 답글이 접히는 댓글 스레드 미니 — 측정 누적면적 16.678% · 움직인 프레임 7/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
댓글 달기를 누르면 새 답글이 아래에서 떠오릅니다.
function ctOpen(on) {
ctRe.classList.toggle('is-open', on);
ctTg.setAttribute('aria-expanded', on ? 'true' : 'false');
ctTg.classList.toggle('is-open', on);
}
ctTg.addEventListener('click', function () {
ct.classList.remove('is-demo');
ctOpen(!ctRe.classList.contains('is-open'));
});
06단계마다 불이 들어오는 상태 배지 타임라인
어떤 움직임인가
제출·검토·승인 세 단계가 세로로 이어지고 이음선이 현재 단계까지 차오릅니다.

<main class="stage">
<section class="st is-demo" id="st" aria-label="승인 진행 상태">
<header class="st__h">
<span class="st__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><path d="M160 216h-.4a8.1 8.1 0 0 1-7.1-5.2l-57.2-150l-32 70.5A8 8 0 0 1 56 136H24a8 8 0 0 1 0-16h26.8l37.9-83.3a8.2 8.2 0 0 1 7.6-4.7a8 8 0 0 1 7.2 5.2L161 188.1l31.8-63.7a8.2 8.2 0 0 1 7.2-4.4h32a8 8 0 0 1 0 16h-27.1l-37.7 75.6a8.2 8.2 0 0 1-7.2 4.4Z"/></svg></span>
<h2 class="st__t">가을 캠페인 기획안</h2>
<span class="st__phase" id="st-phase" data-at="0">
<i class="st__p st__p--1" aria-hidden="true">제출 단계</i>
<i class="st__p st__p--2" aria-hidden="true">검토 단계</i>
<i class="st__p st__p--3" aria-hidden="true">승인 단계</i>
</span>
<span class="st__sr" id="st-live" aria-live="polite">제출 단계</span>
</header>
<ol class="st__tl" id="st-tl">
<li class="st__row" data-row="1">
<span class="st__rail" aria-hidden="true"><i class="st__fill"></i></span>
<span class="st__dot" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="m223.69 42.18l-58.22 192a8 8 0 0 1-14.92 1.25L108 148l-87.42-42.55a8 8 0 0 1 1.25-14.92l192-58.22a8 8 0 0 1 9.86 9.87" opacity=".2"/><path d="M227.32 28.68a16 16 0 0 0-15.66-4.08h-.15L19.57 82.84a16 16 0 0 0-2.49 29.8L102 154l41.3 84.87a15.86 15.86 0 0 0 14.44 9.13q.69 0 1.38-.06a15.88 15.88 0 0 0 14-11.51l58.2-191.94v-.15a16 16 0 0 0-4-15.66m-69.49 203.17l-.05.14v-.07l-40.06-82.3l48-48a8 8 0 0 0-11.31-11.31l-48 48l-82.33-40.06h-.07h.14L216 40Z"/></g></svg></span>
<span class="st__lb">제출<time class="st__tm">09:12</time></span>
</li>
<li class="st__row" data-row="2">
<span class="st__rail" aria-hidden="true"><i class="st__fill"></i></span>
<span class="st__dot" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg></span>
<span class="st__lb">검토<time class="st__tm">10:05</time></span>
</li>
<li class="st__row" data-row="3">
<span class="st__rail st__rail--end" aria-hidden="true"><i class="st__fill"></i></span>
<span class="st__dot" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M174 47.75a254.19 254.19 0 0 0-41.45-38.3a8 8 0 0 0-9.18 0A254.36 254.36 0 0 0 82 47.75C54.51 79.32 40 112.6 40 144a88 88 0 0 0 176 0c0-31.4-14.51-64.68-42-96.25M128 216a72.08 72.08 0 0 1-72-72c0-57.23 55.47-99.58 72-110.93c16.53 11.35 72 53.7 72 110.93a72.08 72.08 0 0 1-72 72"/></g></svg></span>
<span class="st__lb">승인<time class="st__tm">대기 중</time></span>
</li>
</ol>
<button class="st__next" type="button" id="st-next">다음 단계</button>
</section>
</main>@use "sass:color";
@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
$line: color.mix($subject-cream, $stage-ink, 16%);
$soft: color.mix($subject-cream, $stage-ink, 8%);
.st {
box-sizing: border-box;
width: min(304px, 92vw);
padding: $sp-3;
border: 1px solid $line;
border-radius: $r-card;
background: $stage-ink;
color: $subject-cream;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
}
.st__h {
display: flex;
align-items: center;
gap: $sp-1;
margin: 0 0 $sp-2;
}
.st__ic { display: inline-flex; width: 14px; height: 14px; color: $subject-mint; }
.st__t {
margin: 0;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.st__phase {
position: relative;
margin-left: auto;
min-width: 52px;
padding: 0 $sp-2;
border-radius: $r-pill;
background: $color;
color: #ffffff;
font-size: 10px;
font-weight: 700;
font-style: normal;
line-height: 1.7;
text-align: center;
}
.st__p { position: absolute; inset: 0; visibility: hidden; font-style: normal; }
.st__phase[data-at="0"] .st__p--1,
.st__phase[data-at="1"] .st__p--2,
.st__phase[data-at="2"] .st__p--3 { visibility: visible; }
.st__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// ── 세로 타임라인 ─────────────────────────────────
.st__tl {
display: grid;
margin: 0;
padding: 0;
list-style: none;
}
.st__row {
display: grid;
grid-template-columns: 24px 1fr;
align-items: center;
column-gap: $sp-2;
}
.st__rail {
position: relative;
justify-self: center;
width: 2px;
height: 34px;
margin: $sp-1 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .16);
}
.st__rail--end { background: transparent; }
.st__fill {
position: absolute;
inset: 0;
border-radius: $r-pill;
background: $subject-mint;
transform-origin: top; // 규칙8: 세로 레일 위에서 채움
transform: scaleY(0);
}
.st__dot {
position: absolute;
left: 50%;
top: 50%;
z-index: 1;
display: inline-grid;
place-items: center;
width: 22px;
height: 22px;
border: 1px solid $line;
border-radius: 50%;
background: $stage-ink;
color: rgba(255, 255, 255, .4);
transform: translate(-50%, -50%);
}
.st__dot svg { width: 12px; height: 12px; }
.st__lb {
display: inline-flex;
align-items: baseline;
gap: $sp-2;
color: rgba(255, 255, 255, .45);
font-weight: 700;
}
.st__tm { color: rgba(255, 255, 255, .72); font-size: 10px; font-weight: 400; }
// 정적(사람이 조작하는) 경로 — 지금 단계와 끝난 단계
.st__row.is-now .st__dot { border-color: $color; background: $color; color: #ffffff; }
.st__row.is-now .st__dot svg { width: 12px; height: 12px; }
.st__row.is-now .st__lb { color: $subject-cream; }
.st__row.is-now .st__rail .st__fill { transform: scaleY(1); }
.st__row.is-done .st__dot { border-color: $subject-mint; color: $subject-mint; }
.st__row.is-done .st__lb { color: rgba(255, 255, 255, .75); }
.st__row.is-done .st__rail .st__fill { transform: scaleY(1); }
.st__next {
display: inline-flex;
align-items: center;
margin: $sp-2 0 0;
padding: $sp-1 $sp-3;
border: 1px solid $line;
border-radius: $r-pill;
background: transparent;
color: rgba(255, 255, 255, .72);
font-family: inherit;
font-size: 12px;
font-weight: 700;
line-height: 1.6;
cursor: pointer;
}
.st__next:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.st__next:active { transform: scale(.94); }
// ── 격자 미리보기 전용 2초 루프 ─────────────────────
// 단계 넘어감은 steps 컷으로 한 프레임에 자르고, 이음선 채움과 점 맥박이 프레임을 채운다
@keyframes st-fill-1 {
0% { transform: scaleY(0); }
28%, 87% { transform: scaleY(1); }
88%, 100% { transform: scaleY(0); }
}
@keyframes st-fill-2 {
0%, 27% { transform: scaleY(0); }
60%, 87% { transform: scaleY(1); }
88%, 100% { transform: scaleY(0); }
}
@keyframes st-dot-1 {
0% { border-color: $color; background: $color; color: #ffffff; }
28%, 100% { border-color: $subject-mint; background: $stage-ink; color: $subject-mint; }
}
@keyframes st-dot-2 {
0%, 27% { border-color: $line; background: $stage-ink; color: rgba(255, 255, 255, .4); }
28%, 59% { border-color: $color; background: $color; color: #ffffff; }
60%, 100% { border-color: $subject-mint; background: $stage-ink; color: $subject-mint; }
}
@keyframes st-dot-3 {
0%, 59% { border-color: $line; background: $stage-ink; color: rgba(255, 255, 255, .4); }
60%, 87% { border-color: $color; background: $color; color: #ffffff; }
88%, 100% { border-color: $line; background: $stage-ink; color: rgba(255, 255, 255, .4); }
}
@keyframes st-lb-1 {
0% { color: $subject-cream; }
28%, 100% { color: rgba(255, 255, 255, .75); }
}
@keyframes st-lb-2 {
0%, 27% { color: rgba(255, 255, 255, .45); }
28%, 59% { color: $subject-cream; }
60%, 100% { color: rgba(255, 255, 255, .75); }
}
@keyframes st-lb-3 {
0%, 59% { color: rgba(255, 255, 255, .45); }
60%, 87% { color: $subject-cream; }
88%, 100% { color: rgba(255, 255, 255, .45); }
}
@keyframes st-now-pulse {
0%, 100% { transform: translate(-50%, -50%) scale(1); }
50% { transform: translate(-50%, -50%) scale(1.12); }
}
@keyframes st-p-1 { 0%, 27% { visibility: visible; } 28%, 100% { visibility: hidden; } }
@keyframes st-p-2 { 0%, 27% { visibility: hidden; } 28%, 59% { visibility: visible; } 60%, 100% { visibility: hidden; } }
@keyframes st-p-3 { 0%, 59% { visibility: hidden; } 60%, 87% { visibility: visible; } 88%, 100% { visibility: hidden; } }
@keyframes st-next-breath {
0%, 26% { transform: translateY(0); }
30% { transform: translateY(2px); }
34%, 58% { transform: translateY(0); }
62% { transform: translateY(2px); }
66%, 86% { transform: translateY(0); }
87%, 100% { transform: translateY(0); }
}
.st.is-demo {
.st__row[data-row="1"] .st__fill { animation-name: st-fill-1; }
.st__row[data-row="2"] .st__fill { animation-name: st-fill-2; }
.st__fill {
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.st__row[data-row="1"] .st__dot { animation-name: st-dot-1; }
.st__row[data-row="2"] .st__dot { animation-name: st-dot-2; }
.st__row[data-row="3"] .st__dot { animation-name: st-dot-3; }
.st__dot {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.st__row[data-row="1"] .st__lb { animation-name: st-lb-1; }
.st__row[data-row="2"] .st__lb { animation-name: st-lb-2; }
.st__row[data-row="3"] .st__lb { animation-name: st-lb-3; }
.st__lb {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.st__p--1 { animation-name: st-p-1; }
.st__p--2 { animation-name: st-p-2; }
.st__p--3 { animation-name: st-p-3; }
.st__p {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.st__next {
animation-name: st-next-breath;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
}
@media (max-width: 340px) {
.st { padding: $sp-2; font-size: 11px; }
.st__h { margin-bottom: $sp-1; }
.st__t { font-size: 11px; }
.st__rail { height: 28px; }
.st__dot { width: 20px; height: 20px; }
.st__next { margin-top: $sp-1; padding: 0 $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.st__next:active { transform: none; }
.st.is-demo .st__fill,
.st.is-demo .st__lb,
.st.is-demo .st__p,
.st.is-demo .st__next { animation: none !important; transform: none !important; }
.st.is-demo .st__dot { animation: none !important; } // 점의 translate(-50%,-50%) 는 배치라 두고 색만 되돌린다
// 움직임만 끄고 결과 상태는 남긴다 — 검토 단계까지 진행
.st.is-demo .st__row[data-row="1"] .st__fill,
.st.is-demo .st__row[data-row="2"] .st__fill { transform: scaleY(1) !important; }
.st.is-demo .st__row[data-row="1"] .st__dot,
.st.is-demo .st__row[data-row="2"] .st__dot { border-color: $subject-mint !important; background: $stage-ink !important; color: $subject-mint !important; }
.st.is-demo .st__row[data-row="3"] .st__dot { border-color: $color !important; background: $color !important; color: #ffffff !important; }
.st.is-demo .st__row[data-row="1"] .st__lb,
.st.is-demo .st__row[data-row="2"] .st__lb { color: rgba(255, 255, 255, .75) !important; }
.st.is-demo .st__row[data-row="3"] .st__lb { color: $subject-cream !important; }
.st.is-demo .st__p--1, .st.is-demo .st__p--2 { visibility: hidden !important; }
.st.is-demo .st__p--3 { visibility: visible !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.st {
box-sizing: border-box;
width: min(304px, 92vw);
padding: 12px;
border: 1px solid rgb(23.5764705882%, 22.0862745098%, 22.9960784314%);
border-radius: 16px;
background: #17141a;
color: #fff7e6;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
}
.st__h {
display: flex;
align-items: center;
gap: 4px;
margin: 0 0 8px;
}
.st__ic {
display: inline-flex;
width: 14px;
height: 14px;
color: #4cd4a6;
}
.st__t {
margin: 0;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.st__phase {
position: relative;
margin-left: auto;
min-width: 52px;
padding: 0 8px;
border-radius: 999px;
background: #2f6df6;
color: #ffffff;
font-size: 10px;
font-weight: 700;
font-style: normal;
line-height: 1.7;
text-align: center;
}
.st__p {
position: absolute;
inset: 0;
visibility: hidden;
font-style: normal;
}
.st__phase[data-at="0"] .st__p--1,
.st__phase[data-at="1"] .st__p--2,
.st__phase[data-at="2"] .st__p--3 {
visibility: visible;
}
.st__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.st__tl {
display: grid;
margin: 0;
padding: 0;
list-style: none;
}
.st__row {
display: grid;
grid-template-columns: 24px 1fr;
align-items: center;
column-gap: 8px;
}
.st__rail {
position: relative;
justify-self: center;
width: 2px;
height: 34px;
margin: 4px 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.16);
}
.st__rail--end {
background: transparent;
}
.st__fill {
position: absolute;
inset: 0;
border-radius: 999px;
background: #4cd4a6;
transform-origin: top;
transform: scaleY(0);
}
.st__dot {
position: absolute;
left: 50%;
top: 50%;
z-index: 1;
display: inline-grid;
place-items: center;
width: 22px;
height: 22px;
border: 1px solid rgb(23.5764705882%, 22.0862745098%, 22.9960784314%);
border-radius: 50%;
background: #17141a;
color: rgba(255, 255, 255, 0.4);
transform: translate(-50%, -50%);
}
.st__dot svg {
width: 12px;
height: 12px;
}
.st__lb {
display: inline-flex;
align-items: baseline;
gap: 8px;
color: rgba(255, 255, 255, 0.45);
font-weight: 700;
}
.st__tm {
color: rgba(255, 255, 255, 0.72);
font-size: 10px;
font-weight: 400;
}
.st__row.is-now .st__dot {
border-color: #2f6df6;
background: #2f6df6;
color: #ffffff;
}
.st__row.is-now .st__dot svg {
width: 12px;
height: 12px;
}
.st__row.is-now .st__lb {
color: #fff7e6;
}
.st__row.is-now .st__rail .st__fill {
transform: scaleY(1);
}
.st__row.is-done .st__dot {
border-color: #4cd4a6;
color: #4cd4a6;
}
.st__row.is-done .st__lb {
color: rgba(255, 255, 255, 0.75);
}
.st__row.is-done .st__rail .st__fill {
transform: scaleY(1);
}
.st__next {
display: inline-flex;
align-items: center;
margin: 8px 0 0;
padding: 4px 12px;
border: 1px solid rgb(23.5764705882%, 22.0862745098%, 22.9960784314%);
border-radius: 999px;
background: transparent;
color: rgba(255, 255, 255, 0.72);
font-family: inherit;
font-size: 12px;
font-weight: 700;
line-height: 1.6;
cursor: pointer;
}
.st__next:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.st__next:active {
transform: scale(0.94);
}
@keyframes st-fill-1 {
0% {
transform: scaleY(0);
}
28%, 87% {
transform: scaleY(1);
}
88%, 100% {
transform: scaleY(0);
}
}
@keyframes st-fill-2 {
0%, 27% {
transform: scaleY(0);
}
60%, 87% {
transform: scaleY(1);
}
88%, 100% {
transform: scaleY(0);
}
}
@keyframes st-dot-1 {
0% {
border-color: #2f6df6;
background: #2f6df6;
color: #ffffff;
}
28%, 100% {
border-color: #4cd4a6;
background: #17141a;
color: #4cd4a6;
}
}
@keyframes st-dot-2 {
0%, 27% {
border-color: rgb(23.5764705882%, 22.0862745098%, 22.9960784314%);
background: #17141a;
color: rgba(255, 255, 255, 0.4);
}
28%, 59% {
border-color: #2f6df6;
background: #2f6df6;
color: #ffffff;
}
60%, 100% {
border-color: #4cd4a6;
background: #17141a;
color: #4cd4a6;
}
}
@keyframes st-dot-3 {
0%, 59% {
border-color: rgb(23.5764705882%, 22.0862745098%, 22.9960784314%);
background: #17141a;
color: rgba(255, 255, 255, 0.4);
}
60%, 87% {
border-color: #2f6df6;
background: #2f6df6;
color: #ffffff;
}
88%, 100% {
border-color: rgb(23.5764705882%, 22.0862745098%, 22.9960784314%);
background: #17141a;
color: rgba(255, 255, 255, 0.4);
}
}
@keyframes st-lb-1 {
0% {
color: #fff7e6;
}
28%, 100% {
color: rgba(255, 255, 255, 0.75);
}
}
@keyframes st-lb-2 {
0%, 27% {
color: rgba(255, 255, 255, 0.45);
}
28%, 59% {
color: #fff7e6;
}
60%, 100% {
color: rgba(255, 255, 255, 0.75);
}
}
@keyframes st-lb-3 {
0%, 59% {
color: rgba(255, 255, 255, 0.45);
}
60%, 87% {
color: #fff7e6;
}
88%, 100% {
color: rgba(255, 255, 255, 0.45);
}
}
@keyframes st-now-pulse {
0%, 100% {
transform: translate(-50%, -50%) scale(1);
}
50% {
transform: translate(-50%, -50%) scale(1.12);
}
}
@keyframes st-p-1 {
0%, 27% {
visibility: visible;
}
28%, 100% {
visibility: hidden;
}
}
@keyframes st-p-2 {
0%, 27% {
visibility: hidden;
}
28%, 59% {
visibility: visible;
}
60%, 100% {
visibility: hidden;
}
}
@keyframes st-p-3 {
0%, 59% {
visibility: hidden;
}
60%, 87% {
visibility: visible;
}
88%, 100% {
visibility: hidden;
}
}
@keyframes st-next-breath {
0%, 26% {
transform: translateY(0);
}
30% {
transform: translateY(2px);
}
34%, 58% {
transform: translateY(0);
}
62% {
transform: translateY(2px);
}
66%, 86% {
transform: translateY(0);
}
87%, 100% {
transform: translateY(0);
}
}
.st.is-demo .st__row[data-row="1"] .st__fill {
animation-name: st-fill-1;
}
.st.is-demo .st__row[data-row="2"] .st__fill {
animation-name: st-fill-2;
}
.st.is-demo .st__fill {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.st.is-demo .st__row[data-row="1"] .st__dot {
animation-name: st-dot-1;
}
.st.is-demo .st__row[data-row="2"] .st__dot {
animation-name: st-dot-2;
}
.st.is-demo .st__row[data-row="3"] .st__dot {
animation-name: st-dot-3;
}
.st.is-demo .st__dot {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.st.is-demo .st__row[data-row="1"] .st__lb {
animation-name: st-lb-1;
}
.st.is-demo .st__row[data-row="2"] .st__lb {
animation-name: st-lb-2;
}
.st.is-demo .st__row[data-row="3"] .st__lb {
animation-name: st-lb-3;
}
.st.is-demo .st__lb {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.st.is-demo .st__p--1 {
animation-name: st-p-1;
}
.st.is-demo .st__p--2 {
animation-name: st-p-2;
}
.st.is-demo .st__p--3 {
animation-name: st-p-3;
}
.st.is-demo .st__p {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.st.is-demo .st__next {
animation-name: st-next-breath;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.st {
padding: 8px;
font-size: 11px;
}
.st__h {
margin-bottom: 4px;
}
.st__t {
font-size: 11px;
}
.st__rail {
height: 28px;
}
.st__dot {
width: 20px;
height: 20px;
}
.st__next {
margin-top: 4px;
padding: 0 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.st__next:active {
transform: none;
}
.st.is-demo .st__fill,
.st.is-demo .st__lb,
.st.is-demo .st__p,
.st.is-demo .st__next {
animation: none !important;
transform: none !important;
}
.st.is-demo .st__dot {
animation: none !important;
}
.st.is-demo .st__row[data-row="1"] .st__fill,
.st.is-demo .st__row[data-row="2"] .st__fill {
transform: scaleY(1) !important;
}
.st.is-demo .st__row[data-row="1"] .st__dot,
.st.is-demo .st__row[data-row="2"] .st__dot {
border-color: #4cd4a6 !important;
background: #17141a !important;
color: #4cd4a6 !important;
}
.st.is-demo .st__row[data-row="3"] .st__dot {
border-color: #2f6df6 !important;
background: #2f6df6 !important;
color: #ffffff !important;
}
.st.is-demo .st__row[data-row="1"] .st__lb,
.st.is-demo .st__row[data-row="2"] .st__lb {
color: rgba(255, 255, 255, 0.75) !important;
}
.st.is-demo .st__row[data-row="3"] .st__lb {
color: #fff7e6 !important;
}
.st.is-demo .st__p--1, .st.is-demo .st__p--2 {
visibility: hidden !important;
}
.st.is-demo .st__p--3 {
visibility: visible !important;
}
}// 실제 동작: 다음 단계를 누르면 진행 단계가 실제로 넘어간다 —
// 지금 단계에만 aria-current="step" 이 붙고 안내 문구도 같이 바뀐다.
var st = document.getElementById('st');
var stRows = [].slice.call(document.querySelectorAll('.st__row'));
var stPhase = document.getElementById('st-phase');
var stLive = document.getElementById('st-live');
var stNames = ['제출', '검토', '승인'];
var stAt = 0;
function stGo(i) {
stAt = (i + stRows.length) % stRows.length;
stRows.forEach(function (r, n) {
r.classList.toggle('is-now', n === stAt);
r.classList.toggle('is-done', n < stAt);
if (n === stAt) { r.setAttribute('aria-current', 'step'); }
else { r.removeAttribute('aria-current'); }
});
stPhase.setAttribute('data-at', String(stAt));
stLive.textContent = stNames[stAt] + ' 단계';
}
document.getElementById('st-next').addEventListener('click', function () {
st.classList.remove('is-demo');
stGo(stAt + 1);
});
stGo(0);import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./StatusBadgeTimeline.module.scss";
export type StatusBadgeTimelineProps = {
groupLabel?: string;
docName?: string;
stages?: { key: string; label: string; tm: string }[];
nextLabel?: string;
phaseSuffix?: string;
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
function PulseIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<path d="M160 216h-.4a8.1 8.1 0 0 1-7.1-5.2l-57.2-150l-32 70.5A8 8 0 0 1 56 136H24a8 8 0 0 1 0-16h26.8l37.9-83.3a8.2 8.2 0 0 1 7.6-4.7a8 8 0 0 1 7.2 5.2L161 188.1l31.8-63.7a8.2 8.2 0 0 1 7.2-4.4h32a8 8 0 0 1 0 16h-27.1l-37.7 75.6a8.2 8.2 0 0 1-7.2 4.4Z" />
</svg>
);
}
function PlaneIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<g>
<path d="m223.69 42.18l-58.22 192a8 8 0 0 1-14.92 1.25L108 148l-87.42-42.55a8 8 0 0 1 1.25-14.92l192-58.22a8 8 0 0 1 9.86 9.87" opacity=".2" />
<path d="M227.32 28.68a16 16 0 0 0-15.66-4.08h-.15L19.57 82.84a16 16 0 0 0-2.49 29.8L102 154l41.3 84.87a15.86 15.86 0 0 0 14.44 9.13q.69 0 1.38-.06a15.88 15.88 0 0 0 14-11.51l58.2-191.94v-.15a16 16 0 0 0-4-15.66m-69.49 203.17l-.05.14v-.07l-40.06-82.3l48-48a8 8 0 0 0-11.31-11.31l-48 48l-82.33-40.06h-.07h.14L216 40Z" />
</g>
</svg>
);
}
function SearchIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<g>
<path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2" />
<path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72" />
</g>
</svg>
);
}
function SealIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<g>
<path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" />
<path d="M174 47.75a254.19 254.19 0 0 0-41.45-38.3a8 8 0 0 0-9.18 0A254.36 254.36 0 0 0 82 47.75C54.51 79.32 40 112.6 40 144a88 88 0 0 0 176 0c0-31.4-14.51-64.68-42-96.25M128 216a72.08 72.08 0 0 1-72-72c0-57.23 55.47-99.58 72-110.93c16.53 11.35 72 53.7 72 110.93a72.08 72.08 0 0 1-72 72" />
</g>
</svg>
);
}
const ICONS = [PlaneIcon, SearchIcon, SealIcon];
const DEFAULT_STAGES = [
{ key: "s1", label: "Submit", tm: "09:12" },
{ key: "s2", label: "Review", tm: "10:05" },
{ key: "s3", label: "Approve", tm: "waiting" },
];
export default function StatusBadgeTimeline({
groupLabel = "Approval progress",
docName = "Autumn campaign proposal",
stages = DEFAULT_STAGES,
nextLabel = "Next stage",
phaseSuffix = " stage",
demo = true,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: StatusBadgeTimelineProps) {
const [live, setLive] = useState(demo);
const [at, setAt] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function next() {
setLive(false);
setAt((a) => (a + 1) % stages.length);
}
const cls = [styles.st, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<header className={styles.h}>
<span className={styles.ic} aria-hidden="true"><PulseIcon /></span>
<h2 className={styles.t}>{docName}</h2>
<span className={styles.phase} data-at={at}>
{stages.map((s, i) => (
<i key={s.key} className={styles[`p${i + 1}`]} aria-hidden="true">{s.label + phaseSuffix}</i>
))}
</span>
</header>
<span className={styles.sr} aria-live="polite">{stages[at].label + phaseSuffix}</span>
<ol className={styles.tl}>
{stages.map((s, i) => {
const Icon = ICONS[i % ICONS.length];
const rowCls = [
styles.row,
styles[`row${i + 1}`],
i === at ? styles.isNow : "",
i < at ? styles.isDone : "",
].filter(Boolean).join(" ");
return (
<li key={s.key} className={rowCls} aria-current={i === at ? "step" : undefined}>
<span className={[styles.rail, i === stages.length - 1 ? styles.railEnd : ""].filter(Boolean).join(" ")} aria-hidden="true">
<i className={styles.fill}></i>
</span>
<span className={styles.dot} aria-hidden="true"><Icon /></span>
<span className={styles.lb}>{s.label}<time className={styles.tm}>{s.tm}</time></span>
</li>
);
})}
</ol>
<button className={styles.next} type="button" onClick={next}>{nextLabel}</button>
</section>
);
}06 · 단계마다 불이 들어오는 상태 배지 타임라인 — 측정 누적면적 1.622% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
다음 단계를 누르면 지금 단계만 aria-current 를 받고 단계 이름이 안내 문구에도 반영됩니다.
function stGo(i) {
stAt = (i + stRows.length) % stRows.length;
stRows.forEach(function (r, n) {
r.classList.toggle('is-now', n === stAt);
r.classList.toggle('is-done', n < stAt);
if (n === stAt) { r.setAttribute('aria-current', 'step'); }
else { r.removeAttribute('aria-current'); }
});
stPhase.setAttribute('data-at', String(stAt));
}
07수준 칩으로 거르는 시스템 로그
무엇이 보이나
정보·경고·오류 칩이 aria-pressed 로 눌린 상태를 기억하고, 눌린 수준의 줄만 남으며 나머지 줄은 hidden 으로 화면에서 빠집니다.

<main class="stage">
<section class="lg is-demo" id="lg" aria-label="시스템 로그">
<header class="lg__h">
<span class="lg__cursor" aria-hidden="true"></span>
<h2 class="lg__t">시스템 로그</h2>
<span class="lg__live" aria-live="polite" id="lg-live">칩으로 수준을 골라 보세요</span>
</header>
<div class="lg__chips" role="group" aria-label="수준 필터">
<button class="lg__chip" type="button" data-k="info" aria-pressed="true">
<span class="lg__ci" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M144 176a8 8 0 0 1-8 8a16 16 0 0 1-16-16v-40a8 8 0 0 1 0-16a16 16 0 0 1 16 16v40a8 8 0 0 1 8 8m88-48A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88m-92-32a12 12 0 1 0-12-12a12 12 0 0 0 12 12"/></g></svg></span>
정보 3
</button>
<button class="lg__chip" type="button" data-k="warn" aria-pressed="true">
<span class="lg__ci lg__ci--w" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M215.46 216H40.54c-12.62 0-20.54-13.21-14.41-23.91l87.46-151.87c6.3-11 22.52-11 28.82 0l87.46 151.87c6.13 10.7-1.79 23.91-14.41 23.91" opacity=".2"/><path d="M236.8 188.09L149.35 36.22a24.76 24.76 0 0 0-42.7 0L19.2 188.09a23.51 23.51 0 0 0 0 23.72A24.35 24.35 0 0 0 40.55 224h174.9a24.35 24.35 0 0 0 21.33-12.19a23.51 23.51 0 0 0 .02-23.72m-13.87 15.71a8.5 8.5 0 0 1-7.48 4.2H40.55a8.5 8.5 0 0 1-7.48-4.2a7.59 7.59 0 0 1 0-7.72l87.45-151.87a8.75 8.75 0 0 1 15 0l87.45 151.87a7.59 7.59 0 0 1-.04 7.72M120 144v-40a8 8 0 0 1 16 0v40a8 8 0 0 1-16 0m20 36a12 12 0 1 1-12-12a12 12 0 0 1 12 12"/></g></svg></span>
경고 2
</button>
<button class="lg__chip" type="button" data-k="error" aria-pressed="true">
<span class="lg__ci lg__ci--e" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M165.66 101.66L139.31 128l26.35 26.34a8 8 0 0 1-11.32 11.32L128 139.31l-26.34 26.35a8 8 0 0 1-11.32-11.32L116.69 128l-26.35-26.34a8 8 0 0 1 11.32-11.32L128 116.69l26.34-26.35a8 8 0 0 1 11.32 11.32M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg></span>
오류 1
</button>
</div>
<ul class="lg__list" id="lg-list">
<li class="lg__row" data-k="info" data-row="1">
<div class="lg__fold"><p class="lg__line"><i class="lg__lv lg__lv--i" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M144 176a8 8 0 0 1-8 8a16 16 0 0 1-16-16v-40a8 8 0 0 1 0-16a16 16 0 0 1 16 16v40a8 8 0 0 1 8 8m88-48A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88m-92-32a12 12 0 1 0-12-12a12 12 0 0 0 12 12"/></g></svg></i>배포 1.4.2 완료 <time class="lg__tm">09:12</time></p></div>
</li>
<li class="lg__row" data-k="warn" data-row="2">
<div class="lg__fold"><p class="lg__line"><i class="lg__lv lg__lv--w" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M215.46 216H40.54c-12.62 0-20.54-13.21-14.41-23.91l87.46-151.87c6.3-11 22.52-11 28.82 0l87.46 151.87c6.13 10.7-1.79 23.91-14.41 23.91" opacity=".2"/><path d="M236.8 188.09L149.35 36.22a24.76 24.76 0 0 0-42.7 0L19.2 188.09a23.51 23.51 0 0 0 0 23.72A24.35 24.35 0 0 0 40.55 224h174.9a24.35 24.35 0 0 0 21.33-12.19a23.51 23.51 0 0 0 .02-23.72m-13.87 15.71a8.5 8.5 0 0 1-7.48 4.2H40.55a8.5 8.5 0 0 1-7.48-4.2a7.59 7.59 0 0 1 0-7.72l87.45-151.87a8.75 8.75 0 0 1 15 0l87.45 151.87a7.59 7.59 0 0 1-.04 7.72M120 144v-40a8 8 0 0 1 16 0v40a8 8 0 0 1-16 0m20 36a12 12 0 1 1-12-12a12 12 0 0 1 12 12"/></g></svg></i>캐시 지연 840ms <time class="lg__tm">09:15</time></p></div>
</li>
<li class="lg__row" data-k="info" data-row="3">
<div class="lg__fold"><p class="lg__line"><i class="lg__lv lg__lv--i" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M144 176a8 8 0 0 1-8 8a16 16 0 0 1-16-16v-40a8 8 0 0 1 0-16a16 16 0 0 1 16 16v40a8 8 0 0 1 8 8m88-48A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88m-92-32a12 12 0 1 0-12-12a12 12 0 0 0 12 12"/></g></svg></i>예약 작업 등록 <time class="lg__tm">09:18</time></p></div>
</li>
<li class="lg__row" data-k="error" data-row="4">
<div class="lg__fold"><p class="lg__line"><i class="lg__lv lg__lv--e" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M165.66 101.66L139.31 128l26.35 26.34a8 8 0 0 1-11.32 11.32L128 139.31l-26.34 26.35a8 8 0 0 1-11.32-11.32L116.69 128l-26.35-26.34a8 8 0 0 1 11.32-11.32L128 116.69l26.34-26.35a8 8 0 0 1 11.32 11.32M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg></i>결제 웹훅 실패 (재시도 1/3) <time class="lg__tm">09:21</time></p></div>
</li>
<li class="lg__row" data-k="warn" data-row="5">
<div class="lg__fold"><p class="lg__line"><i class="lg__lv lg__lv--w" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M215.46 216H40.54c-12.62 0-20.54-13.21-14.41-23.91l87.46-151.87c6.3-11 22.52-11 28.82 0l87.46 151.87c6.13 10.7-1.79 23.91-14.41 23.91" opacity=".2"/><path d="M236.8 188.09L149.35 36.22a24.76 24.76 0 0 0-42.7 0L19.2 188.09a23.51 23.51 0 0 0 0 23.72A24.35 24.35 0 0 0 40.55 224h174.9a24.35 24.35 0 0 0 21.33-12.19a23.51 23.51 0 0 0 .02-23.72m-13.87 15.71a8.5 8.5 0 0 1-7.48 4.2H40.55a8.5 8.5 0 0 1-7.48-4.2a7.59 7.59 0 0 1 0-7.72l87.45-151.87a8.75 8.75 0 0 1 15 0l87.45 151.87a7.59 7.59 0 0 1-.04 7.72M120 144v-40a8 8 0 0 1 16 0v40a8 8 0 0 1-16 0m20 36a12 12 0 1 1-12-12a12 12 0 0 1 12 12"/></g></svg></i>디스크 여유 12% <time class="lg__tm">09:24</time></p></div>
</li>
<li class="lg__row" data-k="info" data-row="6">
<div class="lg__fold"><p class="lg__line"><i class="lg__lv lg__lv--i" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M144 176a8 8 0 0 1-8 8a16 16 0 0 1-16-16v-40a8 8 0 0 1 0-16a16 16 0 0 1 16 16v40a8 8 0 0 1 8 8m88-48A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88m-92-32a12 12 0 1 0-12-12a12 12 0 0 0 12 12"/></g></svg></i>상태 점검 통과 <time class="lg__tm">09:27</time></p></div>
</li>
</ul>
</section>
</main>@use "sass:color";
@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
$line: color.mix($subject-cream, $stage-ink, 16%);
$soft: color.mix($subject-cream, $stage-ink, 8%);
.lg {
box-sizing: border-box;
width: min(304px, 92vw);
padding: $sp-3;
border: 1px solid $line;
border-radius: $r-card;
background: $stage-ink;
color: $subject-cream;
font-family: "Cascadia Code", Consolas, -apple-system, "Segoe UI", sans-serif;
font-size: 12px;
line-height: 1.3;
}
.lg__h {
display: flex;
align-items: center;
gap: $sp-1;
margin: 0 0 $sp-2;
}
// 터미널 커서 — 로그가 살아 있음을 알리는 깜빡임
.lg__cursor {
width: 7px;
height: 13px;
border-radius: $r-xs;
background: $subject-mint;
}
.lg__t { margin: 0; font-size: 12px; font-weight: 700; }
.lg__live {
margin-left: auto;
color: rgba(255, 255, 255, .72);
font-size: 10px;
white-space: nowrap;
}
.lg__chips {
display: flex;
gap: $sp-1;
margin: 0 0 $sp-2;
}
.lg__chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0 $sp-2;
border: 1px solid $line;
border-radius: $r-pill;
background: $soft;
color: $subject-cream;
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.7;
cursor: pointer;
}
.lg__chip:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.lg__ci { display: inline-flex; width: 11px; height: 11px; color: $subject-sky; }
.lg__ci--w { color: $stage-yellow; }
.lg__ci--e { color: $error-soft; }
.lg__chip.is-off {
border-color: rgba(255, 255, 255, .14);
background: transparent;
color: rgba(255, 255, 255, .38);
}
.lg__list {
display: grid;
gap: $sp-1;
margin: 0;
padding: 0;
list-style: none;
}
.lg__row[hidden] { display: none; }
.lg__fold { display: grid; grid-template-rows: 1fr; }
.lg__line {
display: flex;
align-items: center;
gap: $sp-2;
margin: 0;
min-height: 0;
overflow: hidden;
padding: $sp-1 $sp-2;
border-radius: $r-xs;
background: rgba(255, 255, 255, .06);
color: rgba(255, 255, 255, .82);
font-size: 11px;
white-space: nowrap;
}
.lg__lv { display: inline-flex; flex: 0 0 auto; width: 12px; height: 12px; }
.lg__lv--i { color: $subject-sky; }
.lg__lv--w { color: $stage-yellow; }
.lg__lv--e { color: $error-soft; }
.lg__tm { margin-left: auto; color: rgba(255, 255, 255, .72); font-size: 10px; }
// ── 격자 미리보기 전용 2초 루프 ─────────────────────
// 칩 끄기와 줄 숨김은 grid 접힘으로 — 색이 바뀌는 순간은 steps 컷
@keyframes lg-warn-off {
0%, 24% { border-color: $line; background: $soft; color: $subject-cream; }
25%, 86% { border-color: rgba(255, 255, 255, .14); background: transparent; color: rgba(255, 255, 255, .38); }
87%, 100% { border-color: $line; background: $soft; color: $subject-cream; }
}
@keyframes lg-err-off {
0%, 54% { border-color: $line; background: $soft; color: $subject-cream; }
55%, 86% { border-color: rgba(255, 255, 255, .14); background: transparent; color: rgba(255, 255, 255, .38); }
87%, 100% { border-color: $line; background: $soft; color: $subject-cream; }
}
@keyframes lg-warn-fold {
0%, 24% { grid-template-rows: 1fr; }
31%, 86% { grid-template-rows: 0fr; }
87%, 100% { grid-template-rows: 1fr; }
}
@keyframes lg-err-fold {
0%, 54% { grid-template-rows: 1fr; }
61%, 86% { grid-template-rows: 0fr; }
87%, 100% { grid-template-rows: 1fr; }
}
@keyframes lg-chip-press-w {
0%, 24% { transform: scale(1); }
27% { transform: scale(.9); }
30%, 86% { transform: scale(1); }
87%, 100% { transform: scale(1); }
}
@keyframes lg-chip-press-e {
0%, 54% { transform: scale(1); }
57% { transform: scale(.9); }
60%, 100% { transform: scale(1); }
}
@keyframes lg-blink {
0%, 49% { opacity: 1; }
50%, 100% { opacity: 0; }
}
.lg.is-demo {
.lg__chip[data-k="warn"] { animation-name: lg-warn-off, lg-chip-press-w; }
.lg__chip[data-k="error"] { animation-name: lg-err-off, lg-chip-press-e; }
.lg__chip {
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: steps(1, end), $easing;
animation-iteration-count: infinite, infinite;
}
.lg__row[data-k="warn"] .lg__fold { animation-name: lg-warn-fold; }
.lg__row[data-k="error"] .lg__fold { animation-name: lg-err-fold; }
.lg__row[data-k="warn"] .lg__fold,
.lg__row[data-k="error"] .lg__fold {
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.lg__cursor {
animation-name: lg-blink;
animation-duration: 1s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
}
@media (max-width: 340px) {
.lg { padding: $sp-2; font-size: 11px; }
.lg__h { margin-bottom: $sp-1; }
.lg__chips { margin-bottom: $sp-1; }
.lg__chip { font-size: 10px; }
.lg__line { padding: 0 $sp-2; font-size: 10px; }
.lg__row[data-row="6"] { display: none; } // 좁은 화면에서는 마지막 줄부터 비운다
.lg__live { font-size: 9px; }
}
@media (prefers-reduced-motion: reduce) {
.lg.is-demo .lg__chip,
.lg.is-demo .lg__row .lg__fold,
.lg.is-demo .lg__cursor { animation: none !important; transform: none !important; opacity: 1 !important; }
// 움직임만 끄고 판정 결과는 남긴다 — 경고 칩은 꺼진 상태
.lg.is-demo .lg__chip[data-k="warn"] {
border-color: rgba(255, 255, 255, .14) !important;
background: transparent !important;
color: rgba(255, 255, 255, .38) !important;
}
.lg.is-demo .lg__row[data-k="warn"] .lg__fold { grid-template-rows: 0fr !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lg {
box-sizing: border-box;
width: min(304px, 92vw);
padding: 12px;
border: 1px solid rgb(23.5764705882%, 22.0862745098%, 22.9960784314%);
border-radius: 16px;
background: #17141a;
color: #fff7e6;
font-family: "Cascadia Code", Consolas, -apple-system, "Segoe UI", sans-serif;
font-size: 12px;
line-height: 1.3;
}
.lg__h {
display: flex;
align-items: center;
gap: 4px;
margin: 0 0 8px;
}
.lg__cursor {
width: 7px;
height: 13px;
border-radius: 4px;
background: #4cd4a6;
}
.lg__t {
margin: 0;
font-size: 12px;
font-weight: 700;
}
.lg__live {
margin-left: auto;
color: rgba(255, 255, 255, 0.72);
font-size: 10px;
white-space: nowrap;
}
.lg__chips {
display: flex;
gap: 4px;
margin: 0 0 8px;
}
.lg__chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0 8px;
border: 1px solid rgb(23.5764705882%, 22.0862745098%, 22.9960784314%);
border-radius: 999px;
background: rgb(16.2980392157%, 14.9647058824%, 16.5960784314%);
color: #fff7e6;
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.7;
cursor: pointer;
}
.lg__chip:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.lg__ci {
display: inline-flex;
width: 11px;
height: 11px;
color: #6ec8ff;
}
.lg__ci--w {
color: #ffd23f;
}
.lg__ci--e {
color: #ffe0d8;
}
.lg__chip.is-off {
border-color: rgba(255, 255, 255, 0.14);
background: transparent;
color: rgba(255, 255, 255, 0.38);
}
.lg__list {
display: grid;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
}
.lg__row[hidden] {
display: none;
}
.lg__fold {
display: grid;
grid-template-rows: 1fr;
}
.lg__line {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
min-height: 0;
overflow: hidden;
padding: 4px 8px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.82);
font-size: 11px;
white-space: nowrap;
}
.lg__lv {
display: inline-flex;
flex: 0 0 auto;
width: 12px;
height: 12px;
}
.lg__lv--i {
color: #6ec8ff;
}
.lg__lv--w {
color: #ffd23f;
}
.lg__lv--e {
color: #ffe0d8;
}
.lg__tm {
margin-left: auto;
color: rgba(255, 255, 255, 0.72);
font-size: 10px;
}
@keyframes lg-warn-off {
0%, 24% {
border-color: rgb(23.5764705882%, 22.0862745098%, 22.9960784314%);
background: rgb(16.2980392157%, 14.9647058824%, 16.5960784314%);
color: #fff7e6;
}
25%, 86% {
border-color: rgba(255, 255, 255, 0.14);
background: transparent;
color: rgba(255, 255, 255, 0.38);
}
87%, 100% {
border-color: rgb(23.5764705882%, 22.0862745098%, 22.9960784314%);
background: rgb(16.2980392157%, 14.9647058824%, 16.5960784314%);
color: #fff7e6;
}
}
@keyframes lg-err-off {
0%, 54% {
border-color: rgb(23.5764705882%, 22.0862745098%, 22.9960784314%);
background: rgb(16.2980392157%, 14.9647058824%, 16.5960784314%);
color: #fff7e6;
}
55%, 86% {
border-color: rgba(255, 255, 255, 0.14);
background: transparent;
color: rgba(255, 255, 255, 0.38);
}
87%, 100% {
border-color: rgb(23.5764705882%, 22.0862745098%, 22.9960784314%);
background: rgb(16.2980392157%, 14.9647058824%, 16.5960784314%);
color: #fff7e6;
}
}
@keyframes lg-warn-fold {
0%, 24% {
grid-template-rows: 1fr;
}
31%, 86% {
grid-template-rows: 0fr;
}
87%, 100% {
grid-template-rows: 1fr;
}
}
@keyframes lg-err-fold {
0%, 54% {
grid-template-rows: 1fr;
}
61%, 86% {
grid-template-rows: 0fr;
}
87%, 100% {
grid-template-rows: 1fr;
}
}
@keyframes lg-chip-press-w {
0%, 24% {
transform: scale(1);
}
27% {
transform: scale(0.9);
}
30%, 86% {
transform: scale(1);
}
87%, 100% {
transform: scale(1);
}
}
@keyframes lg-chip-press-e {
0%, 54% {
transform: scale(1);
}
57% {
transform: scale(0.9);
}
60%, 100% {
transform: scale(1);
}
}
@keyframes lg-blink {
0%, 49% {
opacity: 1;
}
50%, 100% {
opacity: 0;
}
}
.lg.is-demo .lg__chip[data-k=warn] {
animation-name: lg-warn-off, lg-chip-press-w;
}
.lg.is-demo .lg__chip[data-k=error] {
animation-name: lg-err-off, lg-chip-press-e;
}
.lg.is-demo .lg__chip {
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite, infinite;
}
.lg.is-demo .lg__row[data-k=warn] .lg__fold {
animation-name: lg-warn-fold;
}
.lg.is-demo .lg__row[data-k=error] .lg__fold {
animation-name: lg-err-fold;
}
.lg.is-demo .lg__row[data-k=warn] .lg__fold,
.lg.is-demo .lg__row[data-k=error] .lg__fold {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.lg.is-demo .lg__cursor {
animation-name: lg-blink;
animation-duration: 1s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.lg {
padding: 8px;
font-size: 11px;
}
.lg__h {
margin-bottom: 4px;
}
.lg__chips {
margin-bottom: 4px;
}
.lg__chip {
font-size: 10px;
}
.lg__line {
padding: 0 8px;
font-size: 10px;
}
.lg__row[data-row="6"] {
display: none;
}
.lg__live {
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.lg.is-demo .lg__chip,
.lg.is-demo .lg__row .lg__fold,
.lg.is-demo .lg__cursor {
animation: none !important;
transform: none !important;
opacity: 1 !important;
}
.lg.is-demo .lg__chip[data-k=warn] {
border-color: rgba(255, 255, 255, 0.14) !important;
background: transparent !important;
color: rgba(255, 255, 255, 0.38) !important;
}
.lg.is-demo .lg__row[data-k=warn] .lg__fold {
grid-template-rows: 0fr !important;
}
}// 실제 동작: 칩을 눌러 수준을 끄고 켠다 — 눌린 수준의 줄만 남고 나머지 줄은 hidden 로 화면에서 빠진다.
// 전부 꺼지면 아무 걸러짐이 없으므로 줄 전부를 다시 보여준다.
var lg = document.getElementById('lg');
var lgChips = [].slice.call(document.querySelectorAll('.lg__chip'));
var lgRows = [].slice.call(document.querySelectorAll('.lg__row'));
var lgLive = document.getElementById('lg-live');
function lgFilter() {
var on = lgChips.filter(function (c) { return c.getAttribute('aria-pressed') === 'true'; })
.map(function (c) { return c.getAttribute('data-k'); });
var showAll = on.length === 0 || on.length === lgChips.length;
lgRows.forEach(function (r) {
r.hidden = !(showAll || on.indexOf(r.getAttribute('data-k')) >= 0);
});
lgLive.textContent = showAll ? '모든 줄을 보여 줍니다' : on.length + '개 수준만 보여 줍니다';
}
lgChips.forEach(function (c) {
c.addEventListener('click', function () {
lg.classList.remove('is-demo');
var now = c.getAttribute('aria-pressed') === 'true';
c.setAttribute('aria-pressed', now ? 'false' : 'true');
c.classList.toggle('is-off', now);
lgFilter();
});
});
lgFilter();import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LogLevelFilter.module.scss";
export type LogLevelFilterProps = {
groupLabel?: string;
chipsLabel?: string;
heading?: string;
liveHint?: string;
showAllMsg?: string;
onlyMsg?: (n: number) => string;
chips?: { key: string; label: string }[];
rows?: { key: string; level: string; msg: string; tm: string }[];
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
function InfoIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<g>
<path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" />
<path d="M144 176a8 8 0 0 1-8 8a16 16 0 0 1-16-16v-40a8 8 0 0 1 0-16a16 16 0 0 1 16 16v40a8 8 0 0 1 8 8m88-48A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88m-92-32a12 12 0 1 0-12-12a12 12 0 0 0 12 12" />
</g>
</svg>
);
}
function WarnIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<g>
<path d="M215.46 216H40.54c-12.62 0-20.54-13.21-14.41-23.91l87.46-151.87c6.3-11 22.52-11 28.82 0l87.46 151.87c6.13 10.7-1.79 23.91-14.41 23.91" opacity=".2" />
<path d="M236.8 188.09L149.35 36.22a24.76 24.76 0 0 0-42.7 0L19.2 188.09a23.51 23.51 0 0 0 0 23.72A24.35 24.35 0 0 0 40.55 224h174.9a24.35 24.35 0 0 0 21.33-12.19a23.51 23.51 0 0 0 .02-23.72m-13.87 15.71a8.5 8.5 0 0 1-7.48 4.2H40.55a8.5 8.5 0 0 1-7.48-4.2a7.59 7.59 0 0 1 0-7.72l87.45-151.87a8.75 8.75 0 0 1 15 0l87.45 151.87a7.59 7.59 0 0 1-.04 7.72M120 144v-40a8 8 0 0 1 16 0v40a8 8 0 0 1-16 0m20 36a12 12 0 1 1-12-12a12 12 0 0 1 12 12" />
</g>
</svg>
);
}
function ErrIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<g>
<path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" />
<path d="M165.66 101.66L139.31 128l26.35 26.34a8 8 0 0 1-11.32 11.32L128 139.31l-26.34 26.35a8 8 0 0 1-11.32-11.32L116.69 128l-26.35-26.34a8 8 0 0 1 11.32-11.32L128 116.69l26.34-26.35a8 8 0 0 1 11.32 11.32M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88" />
</g>
</svg>
);
}
const LEVEL_ICONS: Record<string, typeof InfoIcon> = { info: InfoIcon, warn: WarnIcon, error: ErrIcon };
const DEFAULT_CHIPS = [
{ key: "info", label: "Info 3" },
{ key: "warn", label: "Warn 2" },
{ key: "error", label: "Error 1" },
];
const DEFAULT_ROWS = [
{ key: "l1", level: "info", msg: "Deploy 1.4.2 done", tm: "09:12" },
{ key: "l2", level: "warn", msg: "Cache latency 840ms", tm: "09:15" },
{ key: "l3", level: "info", msg: "Scheduled job registered", tm: "09:18" },
{ key: "l4", level: "error", msg: "Payment webhook failed (retry 1/3)", tm: "09:21" },
{ key: "l5", level: "warn", msg: "Disk free 12%", tm: "09:24" },
{ key: "l6", level: "info", msg: "Health check passed", tm: "09:27" },
];
export default function LogLevelFilter({
groupLabel = "System log",
chipsLabel = "Level filter",
heading = "System log",
liveHint = "Pick levels with the chips",
showAllMsg = "Showing every line",
onlyMsg = (n) => `${n} levels only`,
chips = DEFAULT_CHIPS,
rows = DEFAULT_ROWS,
demo = true,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: LogLevelFilterProps) {
const [live, setLive] = useState(demo);
const [on, setOn] = useState<Record<string, boolean>>({ info: true, warn: true, error: true });
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function toggle(k: string) {
setLive(false);
setOn((o) => ({ ...o, [k]: !o[k] }));
}
const onList = chips.filter((c) => on[c.key]).map((c) => c.key);
const showAll = onList.length === 0 || onList.length === chips.length;
const cls = [styles.lg, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<header className={styles.h}>
<span className={styles.cursor} aria-hidden="true"></span>
<h2 className={styles.t}>{heading}</h2>
<span className={styles.live} aria-live="polite">{showAll ? showAllMsg : onlyMsg(onList.length)}</span>
</header>
<div className={styles.chips} role="group" aria-label={chipsLabel}>
{chips.map((c) => {
const Icon = LEVEL_ICONS[c.key] || InfoIcon;
return (
<button
key={c.key}
className={[styles.chip, styles[`c_${c.key}`], on[c.key] ? "" : styles.isOff].filter(Boolean).join(" ")}
type="button"
aria-pressed={on[c.key]}
onClick={() => toggle(c.key)}
>
<span className={[styles.ci, c.key === "warn" ? styles.ciW : c.key === "error" ? styles.ciE : ""].filter(Boolean).join(" ")} aria-hidden="true"><Icon /></span>
{c.label}
</button>
);
})}
</div>
<ul className={styles.list}>
{rows.map((r) => {
const Icon = LEVEL_ICONS[r.level] || InfoIcon;
return (
<li key={r.key} className={[styles.row, styles[`lv_${r.level}`]].filter(Boolean).join(" ")} hidden={!(showAll || onList.indexOf(r.level) >= 0)}>
<div className={styles.fold}>
<p className={styles.line}>
<i className={[styles.lv, r.level === "warn" ? styles.lvW : r.level === "error" ? styles.lvE : styles.lvI].filter(Boolean).join(" ")} aria-hidden="true"><Icon /></i>
{r.msg}
<time className={styles.tm}>{r.tm}</time>
</p>
</div>
</li>
);
})}
</ul>
</section>
);
}07 · 수준 칩으로 거르는 시스템 로그 — 측정 누적면적 29.574% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
칩을 전부 끄면 걸러짐이 없으므로 줄 전부를 다시 보여 줍니다.
function lgFilter() {
var on = lgChips.filter(function (c) {
return c.getAttribute('aria-pressed') === 'true';
}).map(function (c) { return c.getAttribute('data-k'); });
var showAll = on.length === 0 || on.length === lgChips.length;
lgRows.forEach(function (r) {
r.hidden = !(showAll || on.indexOf(r.getAttribute('data-k')) >= 0);
});
}
08빈 피드가 첫 활동으로 바뀌는 순간
화면에서 일어나는 일
막 생긴 서비스의 피드는 비어 있는 시간이 더 깁니다.

<main class="stage">
<section class="ef is-demo" id="ef" aria-label="활동 피드">
<header class="ef__h">
<h2 class="ef__t">활동 피드</h2>
<span class="ef__phase" id="ef-phase" data-t1="활동 {n}건" data-t2="비어 있음" aria-live="polite">비어 있음</span>
</header>
<div class="ef__empty" id="ef-empty">
<span class="ef__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M216 48v112h-36.69a8 8 0 0 0-5.66 2.34l-19.31 19.32a8 8 0 0 1-5.66 2.34h-41.37a8 8 0 0 1-5.66-2.34l-19.31-19.32a8 8 0 0 0-5.66-2.34H40V48a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 32H48a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 16v104h-28.7a15.86 15.86 0 0 0-11.3 4.69L148.69 176h-41.38L88 156.68A15.9 15.9 0 0 0 76.69 152H48V48Zm0 160H48v-40h28.69L96 187.32a15.9 15.9 0 0 0 11.31 4.68h41.38a15.86 15.86 0 0 0 11.31-4.69L179.31 168H208z"/></g></svg></span>
<p class="ef__e1">아직 활동이 없습니다</p>
<p class="ef__e2">첫 활동이 생기면 여기에 쌓입니다</p>
</div>
<ul class="ef__list" role="log" aria-live="polite" id="ef-list">
<li class="ef__row ef__row--first" data-row="first">
<div class="ef__fold"><div class="ef__cell">
<span class="ef__av" style="--a:#93a4ca">김</span>
<p class="ef__line"><b>김하늘</b>이 팀에 들어왔습니다</p>
<time class="ef__tm">방금</time>
</div></div>
</li>
<li class="ef__row ef__row--second" data-row="second">
<div class="ef__fold"><div class="ef__cell">
<span class="ef__av" style="--a:#ff9ebb">이</span>
<p class="ef__line"><b>이준</b>이 시작 가이드를 열었습니다</p>
<time class="ef__tm">방금</time>
</div></div>
</li>
</ul>
<div class="ef__btns">
<button class="ef__b ef__b--main" type="button" id="ef-add">새 활동</button>
<button class="ef__b" type="button" id="ef-clear">비우기</button>
</div>
</section>
</main>
<template id="ef-tpl"><li class="ef__row is-in"><div class="ef__fold"><div class="ef__cell">
<span class="ef__av" style="--a:#90c8b5">박</span>
<p class="ef__line"><b>박서아</b>가 첫 문서를 만들었습니다</p>
<time class="ef__tm">방금</time>
</div></div></li></template>@use "sass:color";
@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-yellow);
$ink: $subject-ink;
$line: rgba(23, 20, 26, .22);
$soft: rgba(255, 255, 255, .55);
.ef {
box-sizing: border-box;
width: min(304px, 92vw);
padding: $sp-3;
border: 1px solid $ink;
border-radius: $r-card;
background: $stage-yellow;
color: $ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
}
.ef__h {
display: flex;
align-items: center;
gap: $sp-2;
margin: 0 0 $sp-2;
}
.ef__t { margin: 0; font-size: 12px; font-weight: 700; }
.ef__phase {
margin-left: auto;
padding: 0 $sp-2;
border-radius: $r-pill;
background: rgba(23, 20, 26, .1);
color: $ink;
font-size: 10px;
font-weight: 700;
line-height: 1.7;
}
// ── 빈 상태 ───────────────────────────────────────
.ef__empty {
display: grid;
justify-items: center;
gap: $sp-1;
padding: $sp-4 0 $sp-2;
}
.ef__ic {
display: inline-grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: 50%;
background: rgba(255, 255, 255, .6);
color: $ink;
}
.ef__ic svg { width: 24px; height: 24px; }
.ef__e1 { margin: 0; font-size: 12px; font-weight: 700; }
.ef__e2 { margin: 0; color: rgba(23, 20, 26, .72); font-size: 10px; }
// 정적 경로 — 활동이 생기면 빈 상태는 사라진다
.ef.has-act .ef__empty { display: none; }
// ── 활동 줄 ───────────────────────────────────────
.ef__list {
display: grid;
gap: $sp-1;
margin: 0;
padding: 0;
list-style: none;
}
.ef__fold { display: grid; grid-template-rows: 1fr; }
.ef__row--first .ef__fold,
.ef__row--second .ef__fold { grid-template-rows: 0fr; }
.ef.has-act .ef__row--first .ef__fold { grid-template-rows: 1fr; } // 첫 활동 자리
.ef__cell {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: $sp-2;
min-height: 0;
overflow: hidden;
padding: $sp-1 $sp-2;
border-radius: $r-sm;
background: #ffffff;
color: $ink;
}
.ef__av {
display: inline-grid;
place-items: center;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--a);
color: $ink;
font-size: 11px;
font-weight: 700;
}
.ef__line { margin: 0; }
.ef__line b { font-weight: 700; }
.ef__tm { color: rgba(23, 20, 26, .72); font-size: 10px; }
// 사람이 눌러 넣은 줄: 아래에서 떠오른다 (is-in 동안만)
.ef__row.is-in .ef__cell { animation: ef-rise $dur-base $ease-pop both; }
@keyframes ef-rise {
from { transform: translateY(8px); }
to { transform: translateY(0); }
}
.ef__btns {
display: flex;
gap: $sp-1;
margin: $sp-2 0 0;
}
.ef__b {
padding: $sp-1 $sp-3;
border: 1px solid $ink;
border-radius: $r-pill;
background: transparent;
color: $ink;
font-family: inherit;
font-size: 12px;
font-weight: 700;
line-height: 1.6;
cursor: pointer;
}
.ef__b:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.ef__b:active { transform: scale(.94); }
.ef__b--main { background: $ink; color: $stage-yellow; }
// ── 격자 미리보기 전용 2초 루프 ─────────────────────
// 빈 상태 → 첫 활동: 상태 교체는 steps 컷, 등장은 fold + translateY
@keyframes ef-empty-out {
0%, 17% { visibility: visible; }
18%, 86% { visibility: hidden; }
87%, 100% { visibility: visible; }
}
@keyframes ef-first-in {
0%, 17% { grid-template-rows: 0fr; }
18% { grid-template-rows: 1fr; }
18.1%, 86% { grid-template-rows: 1fr; }
87%, 100% { grid-template-rows: 0fr; }
}
@keyframes ef-first-rise {
0%, 18% { transform: translateY(8px); }
25%, 86% { transform: translateY(0); }
87%, 100% { transform: translateY(8px); }
}
@keyframes ef-av-pop {
0%, 18% { transform: scale(.4); }
24%, 86% { transform: scale(1); }
87%, 100% { transform: scale(.4); }
}
@keyframes ef-second-in {
0%, 54% { grid-template-rows: 0fr; }
55% { grid-template-rows: 1fr; }
55.1%, 86% { grid-template-rows: 1fr; }
87%, 100% { grid-template-rows: 0fr; }
}
@keyframes ef-second-rise {
0%, 55% { transform: translateY(8px); }
62%, 86% { transform: translateY(0); }
87%, 100% { transform: translateY(8px); }
}
@keyframes ef-ic-bob {
0%, 17% { transform: translateY(0); }
6% { transform: translateY(-3px); }
12% { transform: translateY(0); }
18%, 100% { transform: translateY(0); }
}
@keyframes ef-add-pop {
0%, 17% { transform: translateY(0); }
20% { transform: scale(.92); }
24%, 86% { transform: scale(1); }
87%, 100% { transform: scale(1); }
}
@keyframes ef-phase-cut {
0%, 17% { transform: scale(1); }
18%, 86% { transform: scale(1.08); }
87%, 100% { transform: scale(1); }
}
.ef.is-demo {
.ef__empty {
animation-name: ef-empty-out, ef-ic-bob;
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: steps(1, end), $easing;
animation-iteration-count: infinite, infinite;
}
.ef__row--first .ef__fold {
animation-name: ef-first-in;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ef__row--first .ef__cell {
animation-name: ef-first-rise;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.ef__row--first .ef__av {
animation-name: ef-av-pop;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.ef__row--second .ef__fold {
animation-name: ef-second-in;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ef__row--second .ef__cell {
animation-name: ef-second-rise;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.ef__b--main {
animation-name: ef-add-pop;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.ef__phase {
animation-name: ef-phase-cut;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
}
@media (max-width: 340px) {
.ef { padding: $sp-2; font-size: 11px; }
.ef__h { margin-bottom: $sp-1; }
.ef__empty { padding: $sp-3 0 $sp-1; }
.ef__ic { width: 36px; height: 36px; }
.ef__ic svg { width: 20px; height: 20px; }
.ef__cell { padding: 0 $sp-2; }
.ef__b { padding: 0 $sp-3; font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.ef__row.is-in .ef__cell { animation: none !important; }
.ef__b:active { transform: none; }
.ef.is-demo .ef__empty,
.ef.is-demo .ef__row .ef__fold,
.ef.is-demo .ef__row .ef__cell,
.ef.is-demo .ef__av,
.ef.is-demo .ef__b--main,
.ef.is-demo .ef__phase { animation: none !important; transform: none !important; }
// 움직임만 끄고 결과 상태는 남긴다 — 첫 활동이 들어온 뒤의 모습
.ef.is-demo .ef__empty { visibility: hidden !important; }
.ef.is-demo .ef__row--first .ef__fold,
.ef.is-demo .ef__row--second .ef__fold { grid-template-rows: 1fr !important; }
.ef.is-demo .ef__row .ef__av { transform: scale(1) !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);
}
.ef {
box-sizing: border-box;
width: min(304px, 92vw);
padding: 12px;
border: 1px solid #17141a;
border-radius: 16px;
background: #ffd23f;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
}
.ef__h {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 8px;
}
.ef__t {
margin: 0;
font-size: 12px;
font-weight: 700;
}
.ef__phase {
margin-left: auto;
padding: 0 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.1);
color: #17141a;
font-size: 10px;
font-weight: 700;
line-height: 1.7;
}
.ef__empty {
display: grid;
justify-items: center;
gap: 4px;
padding: 16px 0 8px;
}
.ef__ic {
display: inline-grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.6);
color: #17141a;
}
.ef__ic svg {
width: 24px;
height: 24px;
}
.ef__e1 {
margin: 0;
font-size: 12px;
font-weight: 700;
}
.ef__e2 {
margin: 0;
color: rgba(23, 20, 26, 0.72);
font-size: 10px;
}
.ef.has-act .ef__empty {
display: none;
}
.ef__list {
display: grid;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
}
.ef__fold {
display: grid;
grid-template-rows: 1fr;
}
.ef__row--first .ef__fold,
.ef__row--second .ef__fold {
grid-template-rows: 0fr;
}
.ef.has-act .ef__row--first .ef__fold {
grid-template-rows: 1fr;
}
.ef__cell {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 8px;
min-height: 0;
overflow: hidden;
padding: 4px 8px;
border-radius: 8px;
background: #ffffff;
color: #17141a;
}
.ef__av {
display: inline-grid;
place-items: center;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--a);
color: #17141a;
font-size: 11px;
font-weight: 700;
}
.ef__line {
margin: 0;
}
.ef__line b {
font-weight: 700;
}
.ef__tm {
color: rgba(23, 20, 26, 0.72);
font-size: 10px;
}
.ef__row.is-in .ef__cell {
animation: ef-rise 300ms cubic-bezier(0.2, 1.4, 0.4, 1) both;
}
@keyframes ef-rise {
from {
transform: translateY(8px);
}
to {
transform: translateY(0);
}
}
.ef__btns {
display: flex;
gap: 4px;
margin: 8px 0 0;
}
.ef__b {
padding: 4px 12px;
border: 1px solid #17141a;
border-radius: 999px;
background: transparent;
color: #17141a;
font-family: inherit;
font-size: 12px;
font-weight: 700;
line-height: 1.6;
cursor: pointer;
}
.ef__b:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.ef__b:active {
transform: scale(0.94);
}
.ef__b--main {
background: #17141a;
color: #ffd23f;
}
@keyframes ef-empty-out {
0%, 17% {
visibility: visible;
}
18%, 86% {
visibility: hidden;
}
87%, 100% {
visibility: visible;
}
}
@keyframes ef-first-in {
0%, 17% {
grid-template-rows: 0fr;
}
18% {
grid-template-rows: 1fr;
}
18.1%, 86% {
grid-template-rows: 1fr;
}
87%, 100% {
grid-template-rows: 0fr;
}
}
@keyframes ef-first-rise {
0%, 18% {
transform: translateY(8px);
}
25%, 86% {
transform: translateY(0);
}
87%, 100% {
transform: translateY(8px);
}
}
@keyframes ef-av-pop {
0%, 18% {
transform: scale(0.4);
}
24%, 86% {
transform: scale(1);
}
87%, 100% {
transform: scale(0.4);
}
}
@keyframes ef-second-in {
0%, 54% {
grid-template-rows: 0fr;
}
55% {
grid-template-rows: 1fr;
}
55.1%, 86% {
grid-template-rows: 1fr;
}
87%, 100% {
grid-template-rows: 0fr;
}
}
@keyframes ef-second-rise {
0%, 55% {
transform: translateY(8px);
}
62%, 86% {
transform: translateY(0);
}
87%, 100% {
transform: translateY(8px);
}
}
@keyframes ef-ic-bob {
0%, 17% {
transform: translateY(0);
}
6% {
transform: translateY(-3px);
}
12% {
transform: translateY(0);
}
18%, 100% {
transform: translateY(0);
}
}
@keyframes ef-add-pop {
0%, 17% {
transform: translateY(0);
}
20% {
transform: scale(0.92);
}
24%, 86% {
transform: scale(1);
}
87%, 100% {
transform: scale(1);
}
}
@keyframes ef-phase-cut {
0%, 17% {
transform: scale(1);
}
18%, 86% {
transform: scale(1.08);
}
87%, 100% {
transform: scale(1);
}
}
.ef.is-demo .ef__empty {
animation-name: ef-empty-out, ef-ic-bob;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite, infinite;
}
.ef.is-demo .ef__row--first .ef__fold {
animation-name: ef-first-in;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ef.is-demo .ef__row--first .ef__cell {
animation-name: ef-first-rise;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.ef.is-demo .ef__row--first .ef__av {
animation-name: ef-av-pop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.ef.is-demo .ef__row--second .ef__fold {
animation-name: ef-second-in;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ef.is-demo .ef__row--second .ef__cell {
animation-name: ef-second-rise;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.ef.is-demo .ef__b--main {
animation-name: ef-add-pop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.ef.is-demo .ef__phase {
animation-name: ef-phase-cut;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.ef {
padding: 8px;
font-size: 11px;
}
.ef__h {
margin-bottom: 4px;
}
.ef__empty {
padding: 12px 0 4px;
}
.ef__ic {
width: 36px;
height: 36px;
}
.ef__ic svg {
width: 20px;
height: 20px;
}
.ef__cell {
padding: 0 8px;
}
.ef__b {
padding: 0 12px;
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.ef__row.is-in .ef__cell {
animation: none !important;
}
.ef__b:active {
transform: none;
}
.ef.is-demo .ef__empty,
.ef.is-demo .ef__row .ef__fold,
.ef.is-demo .ef__row .ef__cell,
.ef.is-demo .ef__av,
.ef.is-demo .ef__b--main,
.ef.is-demo .ef__phase {
animation: none !important;
transform: none !important;
}
.ef.is-demo .ef__empty {
visibility: hidden !important;
}
.ef.is-demo .ef__row--first .ef__fold,
.ef.is-demo .ef__row--second .ef__fold {
grid-template-rows: 1fr !important;
}
.ef.is-demo .ef__row .ef__av {
transform: scale(1) !important;
}
}// 실제 동작: 새 활동을 누르면 빈 상태가 물러나고 첫 활동이 자라 나타난다.
// 비우기는 반대로 — 줄을 전부 치우고 빈 상태로 돌아간다. 상태 문구는 aria-live 로 읽힌다.
var ef = document.getElementById('ef');
var efEmpty = document.getElementById('ef-empty');
var efList = document.getElementById('ef-list');
var efPhase = document.getElementById('ef-phase');
var efCount = 0; // 접힌 미리보기 줄은 세지 않는다
function efSync(on) {
ef.classList.toggle('has-act', on);
efPhase.textContent = on
? efPhase.getAttribute('data-t1').replace('{n}', efCount)
: efPhase.getAttribute('data-t2');
}
document.getElementById('ef-add').addEventListener('click', function () {
ef.classList.remove('is-demo');
var row = document.getElementById('ef-tpl').content.firstElementChild.cloneNode(true);
efList.insertBefore(row, efList.firstChild);
efCount++;
efSync(true);
});
document.getElementById('ef-clear').addEventListener('click', function () {
ef.classList.remove('is-demo');
efList.innerHTML = '';
efCount = 0;
efSync(false);
});
efSync(false);import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./EmptyFirstActivity.module.scss";
export type EmptyFirstActivityProps = {
groupLabel?: string;
heading?: string;
emptyTitle?: string;
emptySub?: string;
emptyPhase?: string;
countPhase?: (n: number) => string;
addLabel?: string;
clearLabel?: string;
nowLabel?: string;
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Item = { key: number; name: string; initial: string; av: string; text: string };
const NEXT: Item[] = [
{ key: 0, name: "Park Seo-a", initial: "P", av: "#4cd4a6", text: " created the first doc" },
];
function TrayIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<g>
<path d="M216 48v112h-36.69a8 8 0 0 0-5.66 2.34l-19.31 19.32a8 8 0 0 1-5.66 2.34h-41.37a8 8 0 0 1-5.66-2.34l-19.31-19.32a8 8 0 0 0-5.66-2.34H40V48a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2" />
<path d="M208 32H48a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 16v104h-28.7a15.86 15.86 0 0 0-11.3 4.69L148.69 176h-41.38L88 156.68A15.9 15.9 0 0 0 76.69 152H48V48Zm0 160H48v-40h28.69L96 187.32a15.9 15.9 0 0 0 11.31 4.68h41.38a15.86 15.86 0 0 0 11.31-4.69L179.31 168H208z" />
</g>
</svg>
);
}
export default function EmptyFirstActivity({
groupLabel = "Activity feed",
heading = "Activity feed",
emptyTitle = "No activity yet",
emptySub = "The first activity will stack up here",
emptyPhase = "Empty",
countPhase = (n) => `${n} activities`,
addLabel = "New activity",
clearLabel = "Clear",
nowLabel = "now",
demo = true,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: EmptyFirstActivityProps) {
const [live, setLive] = useState(demo);
const [items, setItems] = useState<Item[]>([]);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function add() {
setLive(false);
setItems((r) => [{ ...NEXT[0], key: Date.now() }, ...r]);
}
function clear() {
setLive(false);
setItems([]);
}
const has = items.length > 0;
const cls = [styles.ef, live ? styles.isDemo : "", has ? styles.hasAct : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<header className={styles.h}>
<h2 className={styles.t}>{heading}</h2>
<span className={styles.phase} aria-live="polite">
{live ? (
<>
<i className={styles.pEmpty} aria-hidden="true">{emptyPhase}</i>
<i className={styles.pCount} aria-hidden="true">{countPhase(2)}</i>
</>
) : (
(has ? countPhase(items.length) : emptyPhase)
)}
</span>
</header>
<div className={styles.empty}>
<span className={styles.ic} aria-hidden="true"><TrayIcon /></span>
<p className={styles.e1}>{emptyTitle}</p>
<p className={styles.e2}>{emptySub}</p>
</div>
<ul className={styles.list} role="log" aria-live="polite">
<li className={styles.rowFirst}>
<div className={styles.fold}>
<div className={styles.cell}>
<span className={styles.av} style={{ background: color }}>K</span>
<p className={styles.line}><b>Kim Haneul</b> joined the team</p>
<time className={styles.tm}>{nowLabel}</time>
</div>
</div>
</li>
<li className={styles.rowSecond}>
<div className={styles.fold}>
<div className={styles.cell}>
<span className={styles.av} style={{ background: "#ff9ebb" }}>L</span>
<p className={styles.line}><b>Lee Jun</b> opened the getting-started guide</p>
<time className={styles.tm}>{nowLabel}</time>
</div>
</div>
</li>
{items.map((it) => (
<li key={it.key} className={styles.rowIn}>
<div className={styles.fold}>
<div className={styles.cell}>
<span className={styles.av} style={{ background: it.av }}>{it.initial}</span>
<p className={styles.line}><b>{it.name}</b>{it.text}</p>
<time className={styles.tm}>{nowLabel}</time>
</div>
</div>
</li>
))}
</ul>
<div className={styles.btns}>
<button className={styles.bMain} type="button" onClick={add}>{addLabel}</button>
<button className={styles.b} type="button" onClick={clear}>{clearLabel}</button>
</div>
</section>
);
}08 · 빈 피드가 첫 활동으로 바뀌는 순간 — 측정 누적면적 18.88% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
빈 상태 안내는 컷 한 번으로 물러나고 첫 활동이 아래에서 떠오르며, 그 자리에 aria-live 를 두어 첫 활동이 낭독기에 전해집니다.
function efSync(on) {
ef.classList.toggle('has-act', on);
efPhase.textContent = on
? efPhase.getAttribute('data-t1').replace('{n}', efCount)
: efPhase.getAttribute('data-t2');
}
document.getElementById('ef-add').addEventListener('click', function () {
ef.classList.remove('is-demo');
var row = document.getElementById('ef-tpl').content.firstElementChild.cloneNode(true);
efList.insertBefore(row, efList.firstChild);
efCount++;
efSync(true);
});
09오늘의 수와 시간대 막대를 세는 요약 카운터
어떤 움직임인가
오늘의 건수가 커지면 숫자가 갱신되어 읽히고, 시간대별 막대는 같은 눈금의 grid 위에서 밑뿌리를 두고 자라납니다.

<main class="stage">
<section class="sc is-demo" id="sc" aria-label="오늘의 활동 요약">
<header class="sc__h">
<span class="sc__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="m96 40l33.52 88H56Zm104 88h-70.48L160 208Z" opacity=".2"/><path d="M240 128a8 8 0 0 1-8 8h-27.06l-37.78 75.58A8 8 0 0 1 160 216h-.4a8 8 0 0 1-7.08-5.14L95.35 60.76l-32.07 70.55A8 8 0 0 1 56 136H24a8 8 0 0 1 0-16h26.85l37.87-83.31a8 8 0 0 1 14.76.46l57.51 151l31.85-63.71A8 8 0 0 1 200 120h32a8 8 0 0 1 8 8"/></g></svg></span>
<h2 class="sc__t">오늘의 활동</h2>
<button class="sc__re" type="button" id="sc-re">새로 집계</button>
</header>
<p class="sc__cap" aria-live="polite"><span class="sc__n" id="sc-n" data-n="12"><i class="sc__d sc__d--a" aria-hidden="true">0</i><i class="sc__d sc__d--b" aria-hidden="true">4</i><i class="sc__d sc__d--c" aria-hidden="true">12</i></span><span class="sc__unit">건의 활동</span></p>
<div class="sc__chart" role="img" aria-label="시간대별 활동 막대 — 아홉 시부터 열네 시까지">
<div class="sc__col"><span class="sc__barw" style="--f:.30"><i class="sc__bar"></i></span><span class="sc__hr">09</span></div>
<div class="sc__col"><span class="sc__barw" style="--f:.55"><i class="sc__bar"></i></span><span class="sc__hr">10</span></div>
<div class="sc__col"><span class="sc__barw" style="--f:1"><i class="sc__bar"></i></span><span class="sc__hr">11</span></div>
<div class="sc__col"><span class="sc__barw" style="--f:.80"><i class="sc__bar"></i></span><span class="sc__hr">12</span></div>
<div class="sc__col"><span class="sc__barw" style="--f:.45"><i class="sc__bar"></i></span><span class="sc__hr">13</span></div>
<div class="sc__col"><span class="sc__barw" style="--f:.65"><i class="sc__bar sc__bar--last"></i></span><span class="sc__hr">14</span></div>
</div>
</section>
</main>@use "sass:color";
@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-orange);
$ink: $stage-orange;
.sc {
box-sizing: border-box;
width: min(304px, 92vw);
padding: $sp-3;
border: 1px solid rgba(255, 247, 230, .35);
border-radius: $r-card;
background: rgba(23, 20, 26, .08);
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
}
.sc__h {
display: flex;
align-items: center;
gap: $sp-1;
margin: 0 0 $sp-2;
}
.sc__ic { display: inline-flex; width: 14px; height: 14px; color: $stage-yellow; }
.sc__t { margin: 0; font-size: 12px; font-weight: 700; }
.sc__re {
margin-left: auto;
padding: 0 $sp-2;
border: 1px solid rgba(255, 247, 230, .5);
border-radius: $r-pill;
background: transparent;
color: $subject-ink;
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.7;
cursor: pointer;
}
.sc__re:focus-visible { outline: 2px solid $stage-yellow; outline-offset: 2px; }
.sc__re:active { transform: scale(.94); }
// ── 오늘의 수 ─────────────────────────────────────
.sc__cap {
display: flex;
align-items: baseline;
gap: $sp-1;
margin: 0 0 $sp-2;
}
.sc__n {
position: relative;
display: inline-block;
min-width: 2ch;
font-size: 28px;
font-weight: 800;
line-height: 1;
text-align: right;
color: $subject-cream;
}
.sc__d { position: absolute; inset: 0; visibility: hidden; font-style: normal; }
.sc__d--c { visibility: visible; }
.sc__unit { color: $subject-ink; font-size: 11px; font-weight: 700; }
// ── 시간대별 막대 — 같은 눈금의 grid, 밑뿌리 scaleY ──
.sc__chart {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: $sp-2;
align-items: end;
}
.sc__col { display: grid; justify-items: center; gap: $sp-1; }
.sc__barw {
display: block;
width: 100%;
height: 56px;
}
.sc__bar {
display: block;
width: 100%;
height: 100%;
border-radius: $r-xs $r-xs 0 0;
background: $color;
transform-origin: bottom; // 규칙8: 막대 밑뿌리에서 성장
transform: scaleY(var(--f));
transition: transform 400ms $easing; // 실제 집계 갱신용
}
.sc__barw.is-up .sc__bar { background: $stage-yellow; }
// ── 격자 미리보기 전용 2초 루프 ─────────────────────
// 막대 하나 = 애니메이션 하나: 밑뿌리에서 stagger 로 자라다 한 바퀴 끝에 접는다
@keyframes sc-cycle {
0% { transform: scaleY(0); }
55% { transform: scaleY(var(--f)); }
90% { transform: scaleY(var(--f)); }
100% { transform: scaleY(0); }
}
@keyframes sc-d-a { 0%, 24% { visibility: visible; } 25%, 100% { visibility: hidden; } }
@keyframes sc-d-b { 0%, 24% { visibility: hidden; } 25%, 54% { visibility: visible; } 55%, 100% { visibility: hidden; } }
@keyframes sc-d-c { 0%, 54% { visibility: hidden; } 55%, 86% { visibility: visible; } 87%, 100% { visibility: hidden; } }
@keyframes sc-re-press {
0%, 82% { transform: translateY(0); }
85% { transform: translateY(2px) scale(.95); }
89%, 100% { transform: translateY(0) scale(1); }
}
@keyframes sc-unit-pop {
0%, 54% { transform: translateY(0); }
58% { transform: translateY(-2px); }
62%, 86% { transform: translateY(0); }
87%, 100% { transform: translateY(0); }
}
.sc.is-demo {
.sc__bar {
animation-name: sc-cycle;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
// 시차 — 낮은 특이도 리셋 함정: 단축 없이 낱개 속성, delay 는 nth-child 로 더 뒤에 둔다
.sc__chart .sc__col:nth-child(1) .sc__bar { animation-delay: 0s; }
.sc__chart .sc__col:nth-child(2) .sc__bar { animation-delay: 80ms; }
.sc__chart .sc__col:nth-child(3) .sc__bar { animation-delay: 160ms; }
.sc__chart .sc__col:nth-child(4) .sc__bar { animation-delay: 240ms; }
.sc__chart .sc__col:nth-child(5) .sc__bar { animation-delay: 320ms; }
.sc__chart .sc__col:nth-child(6) .sc__bar { animation-delay: 400ms; }
.sc__d--a { animation-name: sc-d-a; }
.sc__d--b { animation-name: sc-d-b; }
.sc__d--c { animation-name: sc-d-c; }
.sc__d {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sc__re {
animation-name: sc-re-press;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.sc__unit {
animation-name: sc-unit-pop;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
}
@media (max-width: 340px) {
.sc { padding: $sp-2; font-size: 11px; }
.sc__h { margin-bottom: $sp-1; }
.sc__n { font-size: 24px; }
.sc__cap { margin-bottom: $sp-1; }
.sc__chart { gap: $sp-1; }
.sc__barw { height: 44px; }
.sc__hr { font-size: 9px; }
}
@media (prefers-reduced-motion: reduce) {
.sc__bar { transition: none; }
.sc__re:active { transform: none; }
.sc.is-demo .sc__bar,
.sc.is-demo .sc__re,
.sc.is-demo .sc__unit,
.sc.is-demo .sc__d { animation: none !important; transform: none !important; }
// 움직임만 끄고 결과 상태는 남긴다 — 막대는 다 찬 상태, 숫자는 마지막 값
.sc.is-demo .sc__bar { transform: scaleY(var(--f)) !important; }
.sc.is-demo .sc__d--a, .sc.is-demo .sc__d--b { visibility: hidden !important; }
.sc.is-demo .sc__d--c { visibility: visible !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.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);
}
.sc {
box-sizing: border-box;
width: min(304px, 92vw);
padding: 12px;
border: 1px solid rgba(255, 247, 230, 0.35);
border-radius: 16px;
background: rgba(23, 20, 26, 0.08);
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
}
.sc__h {
display: flex;
align-items: center;
gap: 4px;
margin: 0 0 8px;
}
.sc__ic {
display: inline-flex;
width: 14px;
height: 14px;
color: #ffd23f;
}
.sc__t {
margin: 0;
font-size: 12px;
font-weight: 700;
}
.sc__re {
margin-left: auto;
padding: 0 8px;
border: 1px solid rgba(255, 247, 230, 0.5);
border-radius: 999px;
background: transparent;
color: #17141a;
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.7;
cursor: pointer;
}
.sc__re:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.sc__re:active {
transform: scale(0.94);
}
.sc__cap {
display: flex;
align-items: baseline;
gap: 4px;
margin: 0 0 8px;
}
.sc__n {
position: relative;
display: inline-block;
min-width: 2ch;
font-size: 28px;
font-weight: 800;
line-height: 1;
text-align: right;
color: #fff7e6;
}
.sc__d {
position: absolute;
inset: 0;
visibility: hidden;
font-style: normal;
}
.sc__d--c {
visibility: visible;
}
.sc__unit {
color: #17141a;
font-size: 11px;
font-weight: 700;
}
.sc__chart {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 8px;
align-items: end;
}
.sc__col {
display: grid;
justify-items: center;
gap: 4px;
}
.sc__barw {
display: block;
width: 100%;
height: 56px;
}
.sc__bar {
display: block;
width: 100%;
height: 100%;
border-radius: 4px 4px 0 0;
background: #fff7e6;
transform-origin: bottom;
transform: scaleY(var(--f));
transition: transform 400ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sc__barw.is-up .sc__bar {
background: #ffd23f;
}
@keyframes sc-cycle {
0% {
transform: scaleY(0);
}
55% {
transform: scaleY(var(--f));
}
90% {
transform: scaleY(var(--f));
}
100% {
transform: scaleY(0);
}
}
@keyframes sc-d-a {
0%, 24% {
visibility: visible;
}
25%, 100% {
visibility: hidden;
}
}
@keyframes sc-d-b {
0%, 24% {
visibility: hidden;
}
25%, 54% {
visibility: visible;
}
55%, 100% {
visibility: hidden;
}
}
@keyframes sc-d-c {
0%, 54% {
visibility: hidden;
}
55%, 86% {
visibility: visible;
}
87%, 100% {
visibility: hidden;
}
}
@keyframes sc-re-press {
0%, 82% {
transform: translateY(0);
}
85% {
transform: translateY(2px) scale(0.95);
}
89%, 100% {
transform: translateY(0) scale(1);
}
}
@keyframes sc-unit-pop {
0%, 54% {
transform: translateY(0);
}
58% {
transform: translateY(-2px);
}
62%, 86% {
transform: translateY(0);
}
87%, 100% {
transform: translateY(0);
}
}
.sc.is-demo .sc__bar {
animation-name: sc-cycle;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.sc.is-demo .sc__chart .sc__col:nth-child(1) .sc__bar {
animation-delay: 0s;
}
.sc.is-demo .sc__chart .sc__col:nth-child(2) .sc__bar {
animation-delay: 80ms;
}
.sc.is-demo .sc__chart .sc__col:nth-child(3) .sc__bar {
animation-delay: 160ms;
}
.sc.is-demo .sc__chart .sc__col:nth-child(4) .sc__bar {
animation-delay: 240ms;
}
.sc.is-demo .sc__chart .sc__col:nth-child(5) .sc__bar {
animation-delay: 320ms;
}
.sc.is-demo .sc__chart .sc__col:nth-child(6) .sc__bar {
animation-delay: 400ms;
}
.sc.is-demo .sc__d--a {
animation-name: sc-d-a;
}
.sc.is-demo .sc__d--b {
animation-name: sc-d-b;
}
.sc.is-demo .sc__d--c {
animation-name: sc-d-c;
}
.sc.is-demo .sc__d {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sc.is-demo .sc__re {
animation-name: sc-re-press;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.sc.is-demo .sc__unit {
animation-name: sc-unit-pop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.sc {
padding: 8px;
font-size: 11px;
}
.sc__h {
margin-bottom: 4px;
}
.sc__n {
font-size: 24px;
}
.sc__cap {
margin-bottom: 4px;
}
.sc__chart {
gap: 4px;
}
.sc__barw {
height: 44px;
}
.sc__hr {
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc__bar {
transition: none;
}
.sc__re:active {
transform: none;
}
.sc.is-demo .sc__bar,
.sc.is-demo .sc__re,
.sc.is-demo .sc__unit,
.sc.is-demo .sc__d {
animation: none !important;
transform: none !important;
}
.sc.is-demo .sc__bar {
transform: scaleY(var(--f)) !important;
}
.sc.is-demo .sc__d--a, .sc.is-demo .sc__d--b {
visibility: hidden !important;
}
.sc.is-demo .sc__d--c {
visibility: visible !important;
}
}// 실제 동작: 새로 집계를 누르면 숫자가 실제로 올라가고 마지막 막대가 실제로 자란다.
// 숫자가 바뀌는 순간 aria-live 로 다시 읽힌다.
var sc = document.getElementById('sc');
var scN = document.getElementById('sc-n');
var scLast = document.querySelector('.sc__bar--last');
document.getElementById('sc-re').addEventListener('click', function () {
sc.classList.remove('is-demo');
var from = Number(scN.getAttribute('data-n'));
var to = from + 1;
var t0 = null;
function tick(ts) {
if (!t0) { t0 = ts; }
var p = Math.min(1, (ts - t0) / 400);
var v = Math.round(from + (to - from) * p);
scN.textContent = String(v);
if (p < 1) { requestAnimationFrame(tick); }
}
requestAnimationFrame(tick);
scN.setAttribute('data-n', String(to));
var w = scLast.parentNode;
w.style.setProperty('--f', '.85'); // 막대 자체가 실제로 자란다
scLast.parentNode.classList.add('is-up');
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SummaryCounterBars.module.scss";
export type SummaryCounterBarsProps = {
groupLabel?: string;
chartLabel?: string;
heading?: string;
recountLabel?: string;
unitLabel?: string;
hours?: string[];
bars?: number[]; // 0~1 비율
startCount?: number;
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** useRef 의 로컬 타입 — ambient 의 useRef<T> 는 { current: T | null } 를 돌려준다 */
type RafBox = { current: number | null };
function PulseIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<g>
<path d="m96 40l33.52 88H56Zm104 88h-70.48L160 208Z" opacity=".2" />
<path d="M240 128a8 8 0 0 1-8 8h-27.06l-37.78 75.58A8 8 0 0 1 160 216h-.4a8 8 0 0 1-7.08-5.14L95.35 60.76l-32.07 70.55A8 8 0 0 1 56 136H24a8 8 0 0 1 0-16h26.85l37.87-83.31a8 8 0 0 1 14.76.46l57.51 151l31.85-63.71A8 8 0 0 1 200 120h32a8 8 0 0 1 8 8" />
</g>
</svg>
);
}
const DEFAULT_HOURS = ["09", "10", "11", "12", "13", "14"];
const DEFAULT_BARS = [0.3, 0.55, 1, 0.8, 0.45, 0.65];
export default function SummaryCounterBars({
groupLabel = "Today's activity summary",
chartLabel = "Activity bars by hour — 9am to 2pm",
heading = "Today's activity",
recountLabel = "Recount",
unitLabel = " activities so far",
hours = DEFAULT_HOURS,
bars = DEFAULT_BARS,
startCount = 12,
demo = true,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: SummaryCounterBarsProps) {
const [live, setLive] = useState(demo);
const [count, setCount] = useState(startCount);
const [fracs, setFracs] = useState<number[]>(bars);
const [up, setUp] = useState(false);
const rafBox: RafBox = useRef<number | null>(null);
// 언마운트 대기 중인 rAF 정리
useEffect(() => () => { if (rafBox.current !== null) cancelAnimationFrame(rafBox.current); }, []);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function recount() {
setLive(false);
const from = count;
const to = count + 1;
let t0: number | null = null;
const tick = (ts: number) => {
if (t0 === null) t0 = ts;
const p = Math.min(1, (ts - t0) / 400);
setCount(Math.round(from + (to - from) * p));
if (p < 1) {
rafBox.current = requestAnimationFrame(tick);
}
};
rafBox.current = requestAnimationFrame(tick);
setFracs((f) => f.map((v, i) => (i === f.length - 1 ? 0.85 : v))); // 마지막 막대가 실제로 자란다
setUp(true);
}
const cls = [styles.sc, live ? styles.isDemo : ""].filter(Boolean).join(" ");
const digits = ["0", "4", String(startCount)];
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<header className={styles.h}>
<span className={styles.ic} aria-hidden="true"><PulseIcon /></span>
<h2 className={styles.t}>{heading}</h2>
<button className={styles.re} type="button" onClick={recount}>{recountLabel}</button>
</header>
<p className={styles.cap} aria-live="polite">
<span className={styles.n}>
{live ? (
digits.map((d, i) => (
<i key={i} className={styles[`d${"ABC"[i]}`]} aria-hidden="true">{d}</i>
))
) : (
count
)}
</span>
<span className={styles.unit}>{unitLabel}</span>
</p>
<div className={styles.chart} role="img" aria-label={chartLabel}>
{fracs.map((f, i) => (
<div key={i} className={styles.col}>
<span className={[styles.barw, i === fracs.length - 1 && up ? styles.isUp : ""].filter(Boolean).join(" ")} style={{ "--f": f }}>
<i className={styles.bar}></i>
</span>
<span className={styles.hr}>{hours[i]}</span>
</div>
))}
</div>
</section>
);
}09 · 오늘의 수와 시간대 막대를 세는 요약 카운터 — 측정 누적면적 7.003% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
새로 집계를 누르면 숫자가 실제로 올라가고 마지막 막대가 실제로 자랍니다.
function tick(ts) {
if (!t0) { t0 = ts; }
var p = Math.min(1, (ts - t0) / 400);
var v = Math.round(from + (to - from) * p);
scN.textContent = String(v);
if (p < 1) { requestAnimationFrame(tick); }
}
requestAnimationFrame(tick);
w.style.setProperty('--f', '.85'); // 막대 자체가 실제로 자란다
어디서 깨지는가 — 함정
가장 자주 부딪히는 곳은 한 요소에 같은 속성을 움직이는 애니메이션을 두 개 거는 것입니다. 03 의 안 읽은 점은 사라지는 컷과 맥박이 둘 다 transform 을 썼는데, 두 애니메이션이 같은 속성을 만지면 뒤에 적은 쪽이 이겨 컷이 아예 일어나지 않았습니다. 그래서 컷은 opacity 로 옮기고 맥박만 transform 에 남겼습니다. 09 의 막대도 자라는 애니메이션과 접는 애니메이션을 나란히 걸었다가 접는 쪽이 계속 이겨 막대가 영원히 자라지 않았고, 결국 한 바퀴 안에서 자라고 접는 한 애니메이션으로 합쳤습니다.
두 번째는 문장을 조각으로 쪼개어 두는 것입니다. 활동 문장을 이름과 조사와 목적어로 잘라 강조하면 한국어에서는 예쁘지만 영어판과 일본어판에서 어순이 뒤섞입니다. 그래서 문장은 이름 노드 하나와 나머지 한 노드로만 나누고, 목적어 강조는 문장 안 색 대신 줄 전체의 배경과 왼쪽 막대로 옮겼습니다. 언어가 바뀌어도 문장이 살아 있는 이유입니다.
세 번째는 배치용 transform 을 되돌리는 실수입니다. 06 의 단계 점은 레일 한가운데 놓으려고 translate(-50%, -50%) 를 쓰는데, 움직임을 끄는 블록에서 transform 을 통째로 초기화하면 점이 레일 구석으로 튀어 버립니다. 점은 애니메이션만 끄고 자리 잡은 transform 은 그대로 두는 것이 이 부품의 규칙입니다.
덧붙여 이음선처럼 가는 요소는 넓이가 좁아 누적면적이 작게 나옵니다. 06 의 이음선은 차오르는 동안에도 누적면적이 1.62% 로 아홉 개 중 가장 작았지만 변한 픽셀 안의 평균 차이는 92.6 에 이르렀습니다. 실제로 02 도 되돌림 컷이 주를 이루어 누적면적이 2.81% 로 낮게 나왔지만 강도는 72.3 을 지켰습니다. 가는 요소는 면적이 아니라 강도로 말하는 셈이라, 측정값을 볼 때 두 값을 나누어 읽어야 한다는 뜻입니다. 그래서 두 값은 늘 따로 확인합니다.
마지막은 좁은 화면입니다. 폰에서는 320px 칸의 세로가 174px 뿐이라 아홉 개 전부가 그대로 들어가지 않습니다. 그래서 줄을 비우는 위젯은 오래된 줄부터 화면에서 빼고(01·03·04·05), 로그는 마지막 줄부터 빼며(07), 나머지 넷은 글자와 간격만 줄입니다. 이 글의 아홉 벌은 전부 그 여유 안에서 재단되었습니다. 줄 수를 줄여 칸에 맞추는 이 계산은 문구를 언어별로 갈아 끼우면 한 단계 더 빡빡해지는데, 그 길이 차이는 언어팩 키 누락 검수에서 따로 재 두었습니다. 아홉 벌의 바닐라 판과 React 판을 같은 값으로 담은 압축 파일의 비밀번호는 sub453jm 이며 안에는 이 글의 데모가 전부 들어 있습니다.
접근성
움직임을 줄여 달라는 설정이 켜진 화면에서는 아홉 벌 전부에서 반복 루프가 멈추고 결과 상태만 남습니다. 새 줄은 펼쳐진 채로, 읽은 항목은 읽은 색으로, 승인 타임라인은 검토를 지난 모습으로 멈춰 움직임이 사라져도 무엇이 확정됐는지는 화면에 그대로 있습니다.
피드 계열의 접근성은 결국 새 항목을 어떻게 알리는가로 정리됩니다. 쌓이는 목록은 role="log" 과 aria-live="polite" 로 묶어(01·04·05), 갱신되는 숫자와 안내 문구도 같은 값으로 읽힙니다(03·08·09). 누른 상태는 aria-pressed(02·07), 펼침은 aria-expanded(05), 지금 단계는 aria-current(06) 로 알립니다.
| 항목 | 눈으로 | 낭독기로 | 조작 |
|---|---|---|---|
| 01 팀 활동 스트림 | 새 줄이 위에서 등장 | role="log" 줄 단위 낭독 |
활동 추가 |
| 02 변경 이력 diff | 줄 그은 값 → 밑줄 값 | 되돌림 aria-pressed |
되돌리기 |
| 03 알림 목록 | 점 맥박 → 읽은 색 | 남은 수 aria-live |
항목 열기 · 모두 읽음 |
| 04 소셜 언급 | 카드가 옆에서 굴러 들어옴 | role="log" 언급 낭독 |
언급 불러오기 |
| 05 댓글 스레드 | 답글 뭉치 접힘 | aria-expanded |
답글 토글 · 댓글 달기 |
| 06 상태 타임라인 | 이음선 차오름 | aria-current="step" |
다음 단계 |
| 07 로그 필터 | 수준 칩 눌림 · 줄 숨김 | 칩 aria-pressed |
칩 토글 |
| 08 빈 피드 전환 | 빈 상태 → 첫 활동 | aria-live 첫 활동 안내 |
새 활동 · 비우기 |
| 09 요약 카운터 | 막대 stagger 성장 | 건수 aria-live |
새로 집계 |
role="log" 를 언제 토스트 대신 쓸지는 MDN 의 log role 문서에 기준이 정리되어 있습니다. 이 위젯들을 실제로 쓰는 화면은 알림 카테고리와 대시보드 카테고리에, 자바스크립트가 실제로 도는 부품은 JS 카테고리에 모여 있습니다.
FAQ
토스트 알림 ui 와 알림 목록은 어떻게 나누면 되나요?
사라져도 되는 소식이면 토스트, 남아 있어야 하는 기록이면 목록입니다. 토스트는 몇 초 뒤 저절로 사라지니 되돌아볼 수 없지만, 이 글의 목록은 줄이 화면에 남아 지나간 활동을 다시 읽게 해 줍니다. 둘의 접근성 역시 갈라지는데 토스트는 role="status", 쌓이는 목록은 role="log" 가 맞습니다.
새 항목이 계속 들어오면 낭독기가 과하게 읽지 않나요?
그래서 aria-live 를 목록 전체에 붙이고 새 줄 하나만 추가하는 방식을 씁니다. 이렇게 하면 방금 들어온 줄만 읽히고 이미 읽은 줄은 다시 읽히지 않습니다. 건수가 달라질 때는 숫자에 붙은 안내가 그 차이만 전하므로 목록 전체를 반복하지 않습니다.
피드가 수백 줄로 길어지면 어떻게 하나요?
전부 보여 주는 대신 세 갈래로 접습니다. 지난 대화는 접어 두고(05), 보고 싶은 것만 수준으로 걸러 내고(07), 오늘의 양은 막대로 요약합니다(09). 이 세 개를 사이드바에 함께 두면 긴 목록을 펼치지 않고도 흐름을 알 수 있습니다. 줄이 아니라 숫자 자체가 1초마다 바뀌는 쪽까지 필요하면 실시간 시세판 UI가 같은 갱신 문제를 값의 편에서 풉니다.








