07세 개가 넘으면 접히는 묶음
알림이 세 개를 넘으면 카드들이 덱처럼 겹쳐 접히고 맨 위에 남은 개수가 붙으며, 누르면 계단처럼 펼쳐졌다가 다시 누르면 도로 접힌다.
쓰임이벤트가 몰려 화면을 덮어 버리는 실시간 알림
aria-expanded--iscale

<main class="stage">
<div class="cg is-demo">
<div class="cg__box">
<ul class="cg__list" id="cg-list" aria-label="쌓인 알림 묶음">
<li class="cg__item" style="--i:3"><span class="cg__in">
<span class="cg__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 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>
<span class="cg__msg">적립금이 들어왔습니다</span>
</span></li>
<li class="cg__item" style="--i:2"><span class="cg__in">
<span class="cg__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><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>
<span class="cg__msg">쿠폰이 도착했습니다</span>
</span></li>
<li class="cg__item" style="--i:1"><span class="cg__in">
<span class="cg__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><path d="M240 128a80 80 0 0 1-80 80H72A56 56 0 1 1 85.92 97.74v.1A80 80 0 0 1 240 128" opacity=".2"/><path d="M178.34 165.66L160 147.31V208a8 8 0 0 1-16 0v-60.69l-18.34 18.35a8 8 0 0 1-11.32-11.32l32-32a8 8 0 0 1 11.32 0l32 32a8 8 0 0 1-11.32 11.32M160 40a88.08 88.08 0 0 0-78.71 48.68A64 64 0 1 0 72 216h40a8 8 0 0 0 0-16H72a48 48 0 0 1 0-96c1.1 0 2.2 0 3.29.12A88 88 0 0 0 72 128a8 8 0 0 0 16 0a72 72 0 1 1 100.8 66a8 8 0 0 0 3.2 15.34a7.9 7.9 0 0 0 3.2-.68A88 88 0 0 0 160 40"/></g></svg></span>
<span class="cg__msg">배송이 시작됐습니다</span>
</span></li>
<li class="cg__item" style="--i:0"><span class="cg__in">
<span class="cg__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><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="cg__msg">결제가 승인됐습니다</span>
<span class="cg__more" id="cg-more" aria-hidden="true">+3</span>
</span></li>
</ul>
</div>
<button class="cg__toggle" type="button" id="cg-toggle" aria-expanded="false" aria-controls="cg-list">
<span class="cg__caret" aria-hidden="true"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><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>
묶인 알림 펼치기
</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-orange);
.cg {
box-sizing: border-box;
width: min(384px, 88vw);
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
--step: 36px;
--fold: 12px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
}
.cg__box {
position: relative;
width: 100%;
height: 150px;
}
.cg__list {
position: absolute;
left: 0;
right: 0;
bottom: 0;
margin: 0;
padding: 0;
list-style: none;
}
// 접혔을 때는 뒤 카드일수록 살짝 위로 올라가고 작아진다 — 덱처럼 보이게 하는 두 값이 전부다
.cg__item {
position: absolute;
left: 0;
right: 0;
bottom: 0;
margin: 0;
padding: 0;
z-index: calc(9 - var(--i));
transform: translateY(calc(var(--i) * var(--fold) * -1)) scale(calc(1 - var(--i) * .04));
transition: transform $duration $easing;
cursor: pointer;
}
.cg__list.is-open .cg__item { transform: translateY(calc(var(--i) * var(--step) * -1)) scale(1); }
.cg__in {
box-sizing: border-box;
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-2 $sp-3;
border-radius: $r-control;
background: $subject-cream;
color: $subject-ink;
box-shadow: $shadow-press;
}
.cg__ic { display: inline-flex; flex: 0 0 auto; color: $color; }
.cg__ic svg { display: block; }
.cg__msg {
flex: 1 1 auto;
min-width: 0;
font-size: 12px;
line-height: 18px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cg__more {
flex: 0 0 auto;
padding: 0 $sp-2;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-size: 11px;
line-height: 18px;
font-weight: 700;
}
.cg__list.is-open .cg__more { opacity: 0; }
.cg__toggle {
box-sizing: border-box;
display: inline-flex;
align-items: center;
gap: $sp-2;
margin: 0;
padding: $sp-1 $sp-4;
border: 0;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-size: 12px;
line-height: 20px;
font-weight: 700;
cursor: pointer;
}
.cg__caret { display: inline-flex; transition: transform $duration $easing; }
.cg__caret svg { display: block; }
.cg__toggle[aria-expanded="true"] .cg__caret { transform: rotate(180deg); }
.cg__toggle:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
// ── 자동 루프: 접힘 → 계단으로 펼침 → 다시 접힘. 네 장이 늘 화면에 남는다 ──
.cg.is-demo .cg__item {
transition: none;
animation-name: cg-fan;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes cg-fan {
0%, 26% { transform: translateY(calc(var(--i) * var(--fold) * -1)) scale(calc(1 - var(--i) * .04)); }
42% { transform: translateY(calc(var(--i) * var(--step) * -1)) scale(1); }
68% { transform: translateY(calc(var(--i) * var(--step) * -1)) scale(1); }
84% { transform: translateY(calc(var(--i) * var(--fold) * -1)) scale(calc(1 - var(--i) * .04)); }
90%, 100% { transform: translateY(calc(var(--i) * var(--fold) * -1)) scale(calc(1 - var(--i) * .04)); }
}
// 남은 개수 배지와 화살표는 샘플 프레임 사이에서 끊어 바꾼다
.cg.is-demo .cg__more {
animation-name: cg-more;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes cg-more {
0% { opacity: 1; }
31% { opacity: 0; }
81% { opacity: 1; }
100% { opacity: 1; }
}
.cg.is-demo .cg__caret {
transition: none;
animation-name: cg-caret;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes cg-caret {
0%, 26% { transform: rotate(0deg); }
42% { transform: rotate(180deg); }
68% { transform: rotate(180deg); }
84% { transform: rotate(0deg); }
90%, 100% { transform: rotate(0deg); }
}
@media (max-width: 340px) {
.cg { gap: $sp-2; --step: 28px; --fold: 8px; }
.cg__box { height: 118px; }
.cg__in { padding: $sp-1 $sp-2; }
.cg__msg { font-size: 11px; line-height: 16px; }
.cg__more { font-size: 10px; line-height: 16px; }
.cg__toggle { padding: 0 $sp-3; font-size: 11px; line-height: 20px; }
}
@media (prefers-reduced-motion: reduce) {
.cg.is-demo .cg__item,
.cg.is-demo .cg__more,
.cg.is-demo .cg__caret { animation: none !important; }
.cg__item,
.cg__caret { transition: none; }
}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);
}
.cg {
box-sizing: border-box;
width: min(384px, 88vw);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
--step: 36px;
--fold: 12px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.cg__box {
position: relative;
width: 100%;
height: 150px;
}
.cg__list {
position: absolute;
left: 0;
right: 0;
bottom: 0;
margin: 0;
padding: 0;
list-style: none;
}
.cg__item {
position: absolute;
left: 0;
right: 0;
bottom: 0;
margin: 0;
padding: 0;
z-index: calc(9 - var(--i));
transform: translateY(calc(var(--i) * var(--fold) * -1)) scale(calc(1 - var(--i) * 0.04));
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
cursor: pointer;
}
.cg__list.is-open .cg__item {
transform: translateY(calc(var(--i) * var(--step) * -1)) scale(1);
}
.cg__in {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 12px;
background: #fff7e6;
color: #17141a;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.cg__ic {
display: inline-flex;
flex: 0 0 auto;
color: #2f6df6;
}
.cg__ic svg {
display: block;
}
.cg__msg {
flex: 1 1 auto;
min-width: 0;
font-size: 12px;
line-height: 18px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cg__more {
flex: 0 0 auto;
padding: 0 8px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-size: 11px;
line-height: 18px;
font-weight: 700;
}
.cg__list.is-open .cg__more {
opacity: 0;
}
.cg__toggle {
box-sizing: border-box;
display: inline-flex;
align-items: center;
gap: 8px;
margin: 0;
padding: 4px 16px;
border: 0;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-size: 12px;
line-height: 20px;
font-weight: 700;
cursor: pointer;
}
.cg__caret {
display: inline-flex;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cg__caret svg {
display: block;
}
.cg__toggle[aria-expanded=true] .cg__caret {
transform: rotate(180deg);
}
.cg__toggle:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.cg.is-demo .cg__item {
transition: none;
animation-name: cg-fan;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes cg-fan {
0%, 26% {
transform: translateY(calc(var(--i) * var(--fold) * -1)) scale(calc(1 - var(--i) * 0.04));
}
42% {
transform: translateY(calc(var(--i) * var(--step) * -1)) scale(1);
}
68% {
transform: translateY(calc(var(--i) * var(--step) * -1)) scale(1);
}
84% {
transform: translateY(calc(var(--i) * var(--fold) * -1)) scale(calc(1 - var(--i) * 0.04));
}
90%, 100% {
transform: translateY(calc(var(--i) * var(--fold) * -1)) scale(calc(1 - var(--i) * 0.04));
}
}
.cg.is-demo .cg__more {
animation-name: cg-more;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes cg-more {
0% {
opacity: 1;
}
31% {
opacity: 0;
}
81% {
opacity: 1;
}
100% {
opacity: 1;
}
}
.cg.is-demo .cg__caret {
transition: none;
animation-name: cg-caret;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes cg-caret {
0%, 26% {
transform: rotate(0deg);
}
42% {
transform: rotate(180deg);
}
68% {
transform: rotate(180deg);
}
84% {
transform: rotate(0deg);
}
90%, 100% {
transform: rotate(0deg);
}
}
@media (max-width: 340px) {
.cg {
gap: 8px;
--step: 28px;
--fold: 8px;
}
.cg__box {
height: 118px;
}
.cg__in {
padding: 4px 8px;
}
.cg__msg {
font-size: 11px;
line-height: 16px;
}
.cg__more {
font-size: 10px;
line-height: 16px;
}
.cg__toggle {
padding: 0 12px;
font-size: 11px;
line-height: 20px;
}
}
@media (prefers-reduced-motion: reduce) {
.cg.is-demo .cg__item,
.cg.is-demo .cg__more,
.cg.is-demo .cg__caret {
animation: none !important;
}
.cg__item,
.cg__caret {
transition: none;
}
}// 실제 동작: 펼침 상태는 aria-expanded 하나가 정하고, 카드 위치는 각자의 --i 와 그 상태를 곱해 계산한다
var root = document.querySelector('.cg');
var list = document.getElementById('cg-list');
var btn = document.getElementById('cg-toggle');
function toggle() {
root.classList.remove('is-demo');
var open = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', String(!open));
list.classList.toggle('is-open', !open);
}
btn.addEventListener('click', toggle);
list.addEventListener('click', function (e) {
if (e.target.closest('.cg__item')) toggle(); // 앞 카드를 눌러도 펼쳐진다
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CollapsedGroup.module.scss";
export type CollapsedGroupProps = {
items?: string[];
listLabel?: string;
toggleLabel?: string;
step?: number;
fold?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--step"?: string; "--fold"?: string; "--duration"?: string; "--easing"?: string; "--color"?: string };
type ItemVars = CSSProperties & { "--i"?: number };
const RING = <g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" /><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 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>;
export default function CollapsedGroup({
items = ["Payment approved", "Shipping started", "A coupon arrived", "Points credited"],
listLabel = "Grouped notifications",
toggleLabel = "Open the grouped notifications",
step = 36,
fold = 12,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: CollapsedGroupProps) {
const [demo, setDemo] = useState(true);
const [open, setOpen] = useState(false);
const vars: Vars = {
"--step": `${step}px`, "--fold": `${fold}px`,
"--duration": `${duration}ms`, "--easing": easing, "--color": color,
};
function toggle() {
setDemo(false);
setOpen((v) => !v);
}
// 맨 앞 카드가 --i 0 이 되도록 뒤집어 그린다
const deck = items.slice(0, 4).map((text, i) => ({ text, i: items.length - 1 - i })).reverse();
return (
<div className={`${styles.cg} ${demo ? styles.isDemo : ""}`} style={vars}>
<div className={styles.box}>
<ul className={`${styles.list} ${open ? styles.isOpen : ""}`} id="cg-list" aria-label={listLabel}>
{deck.map((row) => {
const iv: ItemVars = { "--i": row.i };
return (
<li key={row.text} className={styles.item} style={iv} onClick={toggle}>
<span className={styles.in}>
<span className={styles.ic} aria-hidden="true"><svg viewBox="0 0 256 256" width="16" height="16">{RING}</svg></span>
<span className={styles.msg}>{row.text}</span>
{row.i === 0 ? <span className={styles.more} aria-hidden="true">{`+${deck.length - 1}`}</span> : null}
</span>
</li>
);
})}
</ul>
</div>
<button className={styles.toggle} type="button" aria-expanded={open} aria-controls="cg-list" onClick={toggle}>
<span className={styles.caret} aria-hidden="true">
<svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><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>
{toggleLabel}
</button>
</div>
);
}07 세 개가 넘으면 접히는 묶음 — 측정 누적면적 24.119% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 토스트 알림 UI 9종 — 쌓이고 밀리고 사라지는 스택
이 데모가 속한 글
토스트 알림 UI 9종 — 쌓이고 밀리고 사라지는 스택토스트 알림 UI는 화면 구석에 잠깐 떴다 사라지는 알림을 어떻게 쌓고 어떻게 치울지 정하는 설계입니다. 아홉 개 전부 라이브러리 없이 바닐라 JS와 SCSS로만 돌아가고, 첫 번째는 새 알림이 앞선 카드를 위로 밀 이 글의 데모 전부 보기


