01새 활동이 위에서 등장하는 팀 활동 스트림
누가 무엇을 언제 했는지가 아바타·동사·대상·시간 한 줄로 쌓이는 목록에 role="log" 와 aria-live 를 붙여, 새 항목이 translateY 로 위에서 등장할 때 나머지가 한 칸씩 밀려 내려갑니다.
쓰임툴 오른쪽 사이드바의 팀 로그, 협업 대시보드의 최근 작업
role="log"aria-livetranslateY

<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 포함 · 알림 목록 ui 9가지 — 사이드바에 박히는 활동 피드와 이력 위젯
이 데모가 속한 글
알림 목록 ui 9가지 — 사이드바에 박히는 활동 피드와 이력 위젯알림 목록 ui는 떴다 사라지는 토스트가 아니라 화면 한구석에 자리를 잡고 계속 쌓이는 목록을 말합니다. 사이드바에 박히는 이 아홉 개 위젯은 읽음 표시, 고치기 전 값, 로그 수준, 오늘의 건수까지 전부 실제 상태 이 글의 데모 전부 보기


