# 알림 센터

전체와 안 읽음으로 나눠 알림을 보고 읽음 표시나 삭제를 하는 팝오버예요.

- Type: block
- Theme: base
- Tags: arc, block, notification, inbox, popover, unread, overlay
- English title: Notification center
- URL: https://dsui.vercel.app/items/notification-center

## Used items

- [아바타](https://dsui.vercel.app/items/avatar): 사진이나 이름 첫 글자로 사람을 나타내고 상태 점을 달 수 있어요.

## Controls

| Key | Type | Label | Default |
| --- | --- | --- | --- |
| label | text | 이름 | 알림 |

## Source

### notification-center.module.css

```css
.trigger { position: relative; display: grid; width: 42px; height: 42px; place-items: center; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); color: var(--foreground); cursor: pointer; transition: border-color var(--duration-fast) var(--ease-standard), background var(--duration-fast) var(--ease-standard); }
.trigger[data-state="open"], .trigger:active { border-color: var(--border-strong); background: var(--surface-muted); }
@media (hover: hover) and (pointer: fine) { .trigger:hover { border-color: var(--border-strong); background: var(--surface-muted); } }
.trigger > span:first-child { display: grid; place-items: center; }
.badge { position: absolute; top: -6px; right: -6px; display: grid; min-width: 20px; height: 20px; padding: 0 5px; place-items: center; overflow: hidden; border: 2px solid var(--surface); border-radius: var(--radius-pill); background: var(--foreground); color: var(--background); font-size: var(--text-xs); font-weight: 500; line-height: 1; font-variant-numeric: tabular-nums; }
.panel { z-index: 90; display: flex; flex-direction: column; width: min(424px, calc(100vw - 24px)); max-height: min(590px, calc(100vh - 24px)); overflow: hidden; border: 1px solid var(--float-edge, var(--surface-raised)); border-radius: var(--radius-panel); background: var(--surface-raised); color: var(--foreground); box-shadow: var(--shadow-floating); font-family: var(--font-body); letter-spacing: var(--tracking-body); transform-origin: var(--radix-popover-content-transform-origin); }
/* The panel grows out of the bell: it starts a few pixels toward the trigger on whichever side Radix placed it, and leaves faster than it arrives. */
.panel { --panel-x: 0px; --panel-y: -6px; }
.panel[data-side="top"] { --panel-y: 6px; }
.panel[data-side="left"] { --panel-x: 6px; --panel-y: 0px; }
.panel[data-side="right"] { --panel-x: -6px; --panel-y: 0px; }
.panel[data-state="open"] { animation: panel-open var(--duration-standard) var(--ease-enter) both; }
.panel[data-state="closed"] { animation: panel-close var(--duration-instant) var(--ease-standard) both; }
.header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 23px 24px 18px; }
.heading { display: flex; align-items: center; gap: var(--space-2); }
.heading h2 { margin: 0; font-size: var(--text-xl); font-weight: 500; letter-spacing: var(--tracking-body); line-height: 1.2; }
.count { display: grid; min-width: 21px; height: 21px; padding: 0 5px; place-items: center; overflow: hidden; border-radius: 7px; background: var(--surface-muted); color: var(--text-secondary); font-size: var(--text-xs); line-height: 1; font-variant-numeric: tabular-nums; }
.header p { position: relative; margin: 4px 0 0; color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); }
.close { display: grid; width: 30px; height: 30px; flex: none; place-items: center; border: 0; border-radius: 8px; background: transparent; color: var(--text-muted); cursor: pointer; transition: transform var(--duration-spring) var(--ease-spring), color var(--duration-fast) var(--ease-standard), background var(--duration-fast) var(--ease-standard); }
.close:active { transform: scale(.96); }
.toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 52px; padding: 0 17px 13px 20px; border-bottom: 1px solid var(--border-subtle); }
.viewSwitch { display: inline-flex; align-items: center; gap: 3px; }
.viewSwitch button { position: relative; isolation: isolate; min-width: 61px; height: 31px; padding: 0 10px; border: 0; border-radius: 9px; background: transparent; color: var(--text-secondary); font: inherit; font-size: var(--text-xs); cursor: pointer; transition: color var(--duration-fast) var(--ease-standard); }
.viewSwitch .viewActive { color: var(--foreground); }
.viewSwitch button > span:last-child { position: relative; z-index: 1; }
.viewHighlight { position: absolute; z-index: -1; inset: 0; border-radius: 9px; background: var(--surface-muted); }
.markAll { display: inline-flex; align-items: center; gap: 6px; padding: 6px 0; border: 0; background: transparent; color: var(--text-secondary); font: inherit; font-size: var(--text-xs); white-space: nowrap; cursor: pointer; transition: transform var(--duration-spring) var(--ease-spring), color var(--duration-fast) var(--ease-standard); }
.markAll:active { transform: scale(.97); }
.list { min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 9px 11px 10px; scrollbar-width: none; }
.list::-webkit-scrollbar { display: none; }
.row { overflow: hidden; }
/* Rows settle in with a short cascade when the panel opens or a view adds them. */
.item { position: relative; padding: 10px; border-radius: calc(var(--radius-panel) - 12px); transition: background var(--duration-fast) var(--ease-standard); animation: item-in var(--duration-standard) var(--ease-enter) calc(var(--index, 0) * 35ms) both; }
.itemExpanded { background: var(--surface-muted); }
.itemRead .itemTitle strong { color: var(--text-secondary); }
.itemMain { display: flex; align-items: flex-start; gap: 11px; min-width: 0; }
.eventIcon { display: grid; width: 36px; height: 36px; flex: none; place-items: center; color: var(--accent); }
.eventIcon.success { color: var(--success); }
.eventIcon.warning { color: var(--warning); }
.itemToggle { display: grid; min-width: 0; flex: 1; gap: 4px; padding: 2px 0 0; border: 0; background: transparent; color: var(--foreground); text-align: left; font: inherit; cursor: pointer; }
.itemTitle { display: flex; align-items: center; gap: 6px; min-width: 0; min-height: 20px; }
.itemTitle strong { overflow: hidden; font-size: var(--text-sm); font-weight: 500; line-height: 1.4; text-overflow: ellipsis; white-space: nowrap; }
.unreadDot { display: block; width: 6px; height: 6px; flex: none; border-radius: 50%; background: var(--foreground); }
.itemPreview { overflow: hidden; color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); text-overflow: ellipsis; white-space: nowrap; }
.time { flex: none; padding-top: 4px; color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); font-variant-numeric: tabular-nums; white-space: nowrap; }
.details { overflow: hidden; padding-left: 47px; }
.details p { margin: 12px 0; color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); }
.itemActions { display: flex; flex-wrap: wrap; gap: 6px; padding-bottom: 5px; }
.itemActions button { display: inline-flex; align-items: center; gap: 5px; height: 30px; padding: 0 10px; overflow: clip; border: 1px solid var(--border); border-radius: 10px; background: transparent; color: var(--foreground); font: inherit; font-size: var(--text-xs); line-height: 1; white-space: nowrap; cursor: pointer; transition: transform var(--duration-spring) var(--ease-spring), border-color var(--duration-fast) var(--ease-standard), background var(--duration-fast) var(--ease-standard); }
.itemActions button:active { transform: scale(.97); }
/* Quick press, spring release: the resting transition above carries the spring back. */
.close:active, .markAll:active, .itemActions button:active, .footer button:active { transition-duration: var(--duration-instant); transition-timing-function: var(--ease-standard); }
.morph { display: inline-flex; align-items: center; }
.morphContent { position: relative; display: inline-flex; width: max-content; align-items: center; gap: 5px; }
.actionIcon, .actionGlyph { position: relative; display: grid; place-items: center; }
.swap { position: relative; display: inline-block; max-width: 100%; vertical-align: top; }
.swapLine { display: block; }
.roll { position: relative; display: inline-grid; vertical-align: top; }
.rollValue { display: block; }
.emptyFrame { overflow: hidden; }
.empty { display: grid; justify-items: center; align-content: center; min-height: 190px; padding: 22px 12px; text-align: center; }
.empty > svg { margin-bottom: 13px; color: var(--text-muted); }
.empty strong { font-size: var(--text-sm); font-weight: 500; }
.empty p { margin: 4px 0 0; color: var(--text-secondary); font-size: var(--text-xs); }
.empty button { margin-top: 16px; padding: 0; border: 0; background: transparent; color: var(--foreground); font: inherit; font-size: var(--text-xs); text-decoration: underline; text-underline-offset: 3px; cursor: pointer; }
.footerFrame { flex: none; overflow: hidden; }
.footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 46px; padding: 8px 22px; border-top: 1px solid var(--border-subtle); color: var(--text-muted); font-size: var(--text-xs); }
.footer button { padding: 4px 0; border: 0; background: transparent; color: var(--text-secondary); font: inherit; font-size: var(--text-xs); cursor: pointer; transition: transform var(--duration-spring) var(--ease-spring), color var(--duration-fast) var(--ease-standard); }
.footer button:active { transform: scale(.97); }
@media (hover: hover) and (pointer: fine) {
  .close:hover, .item:hover { background: var(--surface-muted); }
  .close:hover, .viewSwitch button:hover, .markAll:hover, .footer button:hover { color: var(--foreground); }
  .itemActions button:hover { border-color: var(--border-strong); background: var(--surface); }
}
@keyframes panel-open { from { opacity: 0; transform: translate(var(--panel-x), var(--panel-y)) scale(.97); } }
@keyframes panel-close { to { opacity: 0; transform: translate(calc(var(--panel-x) / 2), calc(var(--panel-y) / 2)) scale(.98); } }
@keyframes item-in { from { opacity: 0; transform: translateY(4px); } }
@media (max-width: 380px) { .header { padding: 20px 16px 16px; } .heading h2 { font-size: var(--text-lg); } .toolbar { align-items: flex-start; padding: 0 12px 12px; } .list { padding: 8px 5px; } .item { padding-inline: 7px; } .itemMain { gap: 7px; } .details { padding-left: 43px; } .itemActions { gap: 5px; } .itemActions button { padding-inline: 7px; } .markAll span { display: none; } .footer { padding-inline: 16px; } }
@media (prefers-reduced-motion: reduce) { .panel[data-state], .trigger, .close, .viewSwitch button, .markAll, .item, .itemActions button, .footer button { animation: none; transition-duration: 0ms; } .close:active, .markAll:active, .itemActions button:active, .footer button:active { transform: none; } }

```

### notification-center.tsx

```tsx
"use client";

import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { AnimatePresence, animate, motion, useIsPresent, useMotionValue, type AnimationPlaybackControls, type HTMLMotionProps, type TargetAndTransition, type Transition, type Variants } from "motion/react";
import { useReducedMotion } from "../../lib/use-reduced-motion";
import { HugeiconsIcon } from "@hugeicons/react";
import { CircleDotIcon, Message01Icon, TickDouble02Icon } from "@hugeicons/core-free-icons";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { Avatar } from "../../avatar/avatar";
import { motionTokens } from "../../lib/motion-tokens";
import styles from "./notification-center.module.css";
import { UiIcon } from "../../theme/ui-icon";
import { playUiSound } from "../../sound/play";

export interface NotificationItem {
  id: string;
  title: string;
  description?: string;
  time: string;
  read?: boolean;
  tone?: "info" | "success" | "warning";
  /** A local portrait asset for person-generated updates. */
  actor?: { name: string; photo: string };
}

export interface NotificationCenterProps {
  notifications: NotificationItem[];
  label?: string;
  onReadChange?: (notification: NotificationItem, read: boolean) => void;
  onDismiss?: (notification: NotificationItem) => void;
  open?: boolean;
  onOpenChange?: (open: boolean) => void;
  avoidCollisions?: boolean;
}

type View = "all" | "unread";

const enter: Transition = { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] };
const exitFast: Transition = { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] };
const instant: Transition = { duration: 0 };
const textIn: TargetAndTransition = { opacity: 0, y: "0.3em", filter: `blur(${motionTokens.blur.soft}px)` };
const textOut: TargetAndTransition = { opacity: 0, y: "-0.3em", filter: `blur(${motionTokens.blur.subtle}px)`, transition: exitFast };
const iconIn: TargetAndTransition = { opacity: 0, scale: .6, filter: `blur(${motionTokens.blur.subtle}px)` };
const shown: TargetAndTransition = { opacity: 1, y: "0em", scale: 1, filter: "blur(0px)" };
const fadeOut: TargetAndTransition = { opacity: 0, transition: { duration: motionTokens.duration.instant } };
/** Bulk actions cascade down the list, capped so the whole sweep stays under a quarter second. */
const cascade = (index: number) => Math.min(index * motionTokens.stagger.item, .2);

/** Outgoing copies are hidden from assistive tech while they fade, so live text reads only the current value. */
function Swap(props: HTMLMotionProps<"span">) {
  const present = useIsPresent();
  return <motion.span {...props} aria-hidden={present ? props["aria-hidden"] : true} />;
}

function SwapText({ children, reduce }: { children: string; reduce: boolean | null }) {
  return <span className={styles.swap}><AnimatePresence mode="popLayout" initial={false}><Swap key={children} className={styles.swapLine} initial={reduce ? { opacity: 0 } : textIn} animate={shown} exit={reduce ? fadeOut : textOut} transition={reduce ? instant : enter}>{children}</Swap></AnimatePresence></span>;
}

const rollVariants: Variants = {
  enter: (direction: number) => ({ opacity: 0, y: direction >= 0 ? "0.7em" : "-0.7em", filter: `blur(${motionTokens.blur.subtle}px)` }),
  center: { opacity: 1, y: "0em", filter: "blur(0px)" },
  exit: (direction: number) => ({ opacity: 0, y: direction >= 0 ? "-0.7em" : "0.7em", filter: `blur(${motionTokens.blur.subtle}px)`, transition: exitFast }),
};

/** Counts roll like an odometer: a higher number rises from below, a lower one drops from above. */
function RollingCount({ value, display = String(value), reduce }: { value: number; display?: string; reduce: boolean | null }) {
  const [previous, setPrevious] = useState(value);
  const [direction, setDirection] = useState(0);
  if (value !== previous) { setDirection(value > previous ? 1 : -1); setPrevious(value); }
  return <span className={styles.roll}><AnimatePresence mode="popLayout" initial={false} custom={direction}>
    <Swap key={display} className={styles.rollValue} custom={direction} variants={reduce ? undefined : rollVariants} initial={reduce ? { opacity: 0 } : "enter"} animate={reduce ? { opacity: 1 } : "center"} exit={reduce ? fadeOut : "exit"} transition={reduce ? instant : { y: motionTokens.spring.snappy, opacity: exitFast, filter: exitFast }}>{display}</Swap>
  </AnimatePresence></span>;
}

/** Follows the width of its content. After `morphKey` changes the width springs from the old size to the new one, then returns to auto, so a longer label grows the control instead of snapping it. */
function MorphWidth({ reduce, morphKey, children }: { reduce: boolean | null; morphKey: string; children: ReactNode }) {
  const frame = useRef<HTMLSpanElement>(null);
  const content = useRef<HTMLSpanElement>(null);
  const width = useMotionValue<number | "auto">("auto");
  const changedAt = useRef(0);
  useLayoutEffect(() => { changedAt.current = performance.now(); }, [morphKey]);
  useEffect(() => {
    const node = content.current;
    if (!node || typeof ResizeObserver === "undefined") return;
    let last: number | undefined;
    let controls: AnimationPlaybackControls | undefined;
    const settle = () => { width.jump("auto"); if (frame.current) frame.current.style.width = "auto"; };
    const observer = new ResizeObserver(([entry]) => {
      if (!entry) return;
      const next = entry.borderBoxSize?.[0]?.inlineSize ?? node.offsetWidth;
      const current = width.get();
      const from = typeof current === "number" ? current : last;
      last = next;
      controls?.stop();
      if (reduce || from === undefined || from === next || performance.now() - changedAt.current > 120) return settle();
      // Pin the old width before this frame paints, then spring to the new one.
      if (frame.current) frame.current.style.width = `${from}px`;
      controls = animate(width, [from, next], { ...motionTokens.spring.morph, onComplete: settle });
    });
    observer.observe(node);
    return () => { observer.disconnect(); controls?.stop(); };
  }, [width, reduce]);
  return <motion.span ref={frame} className={styles.morph} style={{ width }}><span ref={content} className={styles.morphContent}>{children}</span></motion.span>;
}

function NotificationVisual({ item }: { item: NotificationItem }) {
  if (item.actor) return <Avatar name={item.actor.name} src={item.actor.photo} size="md" />;
  return <span className={[styles.eventIcon, styles[item.tone ?? "info"]].join(" ")} aria-hidden="true">
    {item.tone === "warning" ? <UiIcon iconRole="warning" size={18} strokeWidth={1.7} /> : item.tone === "success" ? <UiIcon iconRole="success" size={18} strokeWidth={1.7} /> : <HugeiconsIcon icon={Message01Icon} size={18} strokeWidth={1.7} />}
  </span>;
}

export function NotificationCenter({ notifications: initial, label = "알림", onReadChange, onDismiss, open, onOpenChange, avoidCollisions = true }: NotificationCenterProps) {
  const reduce = useReducedMotion();
  const layoutId = useId();
  const [internalOpen, setInternalOpen] = useState(false);
  const [items, setItems] = useState(initial);
  const [view, setView] = useState<View>("all");
  const [expandedId, setExpandedId] = useState<string | null>(null);
  const [bulk, setBulk] = useState(false);
  const itemRefs = useRef(new Map<string, HTMLButtonElement>());
  const allTabRef = useRef<HTMLButtonElement>(null);
  const unreadTabRef = useRef<HTMLButtonElement>(null);
  const isOpen = open ?? internalOpen;
  const unreadCount = useMemo(() => items.filter((item) => !item.read).length, [items]);
  const readCount = items.length - unreadCount;
  const visible = view === "unread" ? items.filter((item) => !item.read) : items;
  const summary = unreadCount ? `확인하지 않은 소식이 ${unreadCount}개 있어요` : "모든 소식을 확인했어요";

  function setOpen(next: boolean) {
    playUiSound(next ? "open" : "close");
    if (open === undefined) setInternalOpen(next);
    onOpenChange?.(next);
  }

  function toggleRead(item: NotificationItem) {
    const next = !item.read;
    playUiSound("tick");
    if (next && view === "unread") {
      const index = visible.findIndex((entry) => entry.id === item.id);
      const nextId = visible[index + 1]?.id ?? visible[index - 1]?.id;
      requestAnimationFrame(() => nextId ? itemRefs.current.get(nextId)?.focus() : unreadTabRef.current?.focus());
    }
    setBulk(false);
    setItems((current) => current.map((entry) => entry.id === item.id ? { ...entry, read: next } : entry));
    if (next && view === "unread") setExpandedId(null);
    onReadChange?.(item, next);
  }

  function markAllRead() {
    playUiSound("tick");
    items.filter((item) => !item.read).forEach((item) => onReadChange?.(item, true));
    setBulk(true);
    setItems((current) => current.map((item) => ({ ...item, read: true })));
    setExpandedId(null);
    requestAnimationFrame(() => (view === "unread" ? unreadTabRef : allTabRef).current?.focus());
  }

  function dismiss(item: NotificationItem) {
    playUiSound("tick");
    const index = visible.findIndex((entry) => entry.id === item.id);
    const nextId = visible[index + 1]?.id ?? visible[index - 1]?.id;
    requestAnimationFrame(() => nextId ? itemRefs.current.get(nextId)?.focus() : unreadTabRef.current?.focus());
    setBulk(false);
    setItems((current) => current.filter((entry) => entry.id !== item.id));
    if (expandedId === item.id) setExpandedId(null);
    onDismiss?.(item);
  }

  function clearRead() {
    playUiSound("tick");
    items.filter((item) => item.read).forEach((item) => onDismiss?.(item));
    setBulk(true);
    setItems((current) => current.filter((item) => !item.read));
    requestAnimationFrame(() => allTabRef.current?.focus());
  }

  const height: Transition = reduce ? instant : { height: motionTokens.spring.smooth, opacity: enter };

  return <PopoverPrimitive.Root open={isOpen} onOpenChange={setOpen}>
    <PopoverPrimitive.Trigger asChild>
      {/* The trigger anchors the panel, so it gives press feedback with color only; scaling it would shift the panel. */}
      <button className={styles.trigger} type="button" aria-label={`${label}${unreadCount ? `, 읽지 않음 ${unreadCount}개` : ""}`}>
        <motion.span animate={{ rotate: isOpen && !reduce ? -12 : 0 }} transition={reduce ? instant : motionTokens.spring.snappy}><UiIcon iconRole="notification" size={19} strokeWidth={1.75} aria-hidden="true" /></motion.span>
        <AnimatePresence initial={false}>{unreadCount > 0 && <motion.span key="badge" className={styles.badge} aria-hidden="true" initial={reduce ? { opacity: 0 } : { opacity: 0, scale: .6 }} animate={{ opacity: 1, scale: 1 }} exit={reduce ? fadeOut : { opacity: 0, scale: .6, transition: exitFast }} transition={reduce ? instant : motionTokens.spring.snappy}><RollingCount value={unreadCount} display={unreadCount > 9 ? "9+" : String(unreadCount)} reduce={reduce} /></motion.span>}</AnimatePresence>
      </button>
    </PopoverPrimitive.Trigger>
    <PopoverPrimitive.Portal>
      <PopoverPrimitive.Content className={styles.panel} align="center" side="bottom" sideOffset={12} collisionPadding={12} avoidCollisions={avoidCollisions} aria-label={label}>
        <div className={styles.header}>
          <div><div className={styles.heading}><h2>{label}</h2><span className={styles.count}><RollingCount value={unreadCount} reduce={reduce} /></span></div><p aria-live="polite"><SwapText reduce={reduce}>{summary}</SwapText></p></div>
          <PopoverPrimitive.Close className={styles.close} aria-label="알림 닫기"><UiIcon iconRole="close" size={17} strokeWidth={1.75} aria-hidden="true" /></PopoverPrimitive.Close>
        </div>

        <div className={styles.toolbar}>
          <div className={styles.viewSwitch} role="group" aria-label="알림 보기">
            {(["all", "unread"] as const).map((next) => <button key={next} ref={next === "unread" ? unreadTabRef : allTabRef} type="button" className={view === next ? styles.viewActive : undefined} aria-pressed={view === next} onClick={() => { setBulk(false); setView(next); setExpandedId(null); }}>{view === next && <motion.span className={styles.viewHighlight} layoutId={`${layoutId}-view`} transition={reduce ? instant : motionTokens.spring.morph} />}<span>{next === "all" ? "전체" : "읽지 않음"}</span></button>)}
          </div>
          <AnimatePresence initial={false}>{unreadCount > 0 && <motion.button key="mark-all" type="button" className={styles.markAll} onClick={markAllRead} initial={reduce ? { opacity: 0 } : { opacity: 0, filter: `blur(${motionTokens.blur.subtle}px)` }} animate={{ opacity: 1, filter: "blur(0px)" }} exit={reduce ? fadeOut : { opacity: 0, filter: `blur(${motionTokens.blur.subtle}px)`, transition: exitFast }} transition={reduce ? instant : enter}><HugeiconsIcon icon={TickDouble02Icon} size={15} strokeWidth={1.75} aria-hidden="true" /><span>모두 읽음으로 표시하기</span></motion.button>}</AnimatePresence>
        </div>

        {/* Rows collapse their own height on the way out, so the list and the panel close the gap together. */}
        <div className={styles.list} role="list" aria-label={view === "all" ? "전체 알림" : "읽지 않은 알림"}>
          <AnimatePresence initial={false} custom={bulk}>
            {visible.map((item, index) => <motion.div key={item.id} role="listitem" className={styles.row} custom={bulk}
              variants={{ exit: (isBulk: boolean) => reduce ? fadeOut : { height: 0, opacity: 0, transition: { height: { ...motionTokens.spring.smooth, delay: isBulk ? cascade(index) : 0 }, opacity: { ...exitFast, delay: isBulk ? cascade(index) : 0 } } } }}
              initial={reduce ? { opacity: 0 } : { height: 0 }} animate={{ height: "auto", opacity: 1 }} exit="exit" transition={height}>
              <article className={[styles.item, item.read ? styles.itemRead : "", expandedId === item.id ? styles.itemExpanded : ""].filter(Boolean).join(" ")} style={{ "--index": Math.min(index, 7) } as CSSProperties}>
                <div className={styles.itemMain}>
                  <NotificationVisual item={item} />
                  <button ref={(node) => { if (node) itemRefs.current.set(item.id, node); else itemRefs.current.delete(item.id); }} type="button" className={styles.itemToggle} aria-expanded={expandedId === item.id} aria-label={`${item.title}${item.read ? "" : ", 읽지 않음"}. ${expandedId === item.id ? "자세히 닫기" : "자세히 보기"}`} onClick={() => setExpandedId((current) => current === item.id ? null : item.id)}>
                    <span className={styles.itemTitle}><strong>{item.title}</strong>
                      <AnimatePresence initial={false} custom={bulk}>{!item.read && <motion.span key="dot" className={styles.unreadDot} aria-hidden="true" custom={bulk}
                        variants={{ exit: (isBulk: boolean) => reduce ? fadeOut : { opacity: 0, scale: .3, transition: { ...exitFast, delay: isBulk ? cascade(index) : 0 } } }}
                        initial={reduce ? { opacity: 0 } : { opacity: 0, scale: .3 }} animate={{ opacity: 1, scale: 1 }} exit="exit" transition={reduce ? instant : motionTokens.spring.snappy} />}</AnimatePresence>
                    </span>
                    <span className={styles.itemPreview}>{item.description ?? (item.actor ? `${item.actor.name}님이 보냈어요` : "소식 자세히 보기")}</span>
                  </button>
                  <time className={styles.time}>{item.time}</time>
                </div>
                <AnimatePresence initial={false}>
                  {expandedId === item.id && <motion.div className={styles.details} initial={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={reduce ? fadeOut : { height: 0, opacity: 0, transition: { height: motionTokens.spring.smooth, opacity: exitFast } }} transition={height}>
                    <p>{item.description ?? (item.actor ? `${item.actor.name}님이 소식을 공유했어요.` : "확인해 볼 소식이 있어요.")}</p>
                    <div className={styles.itemActions}>
                      <button type="button" onClick={() => toggleRead(item)}><MorphWidth reduce={reduce} morphKey={item.read ? "read" : "unread"}>
                        <span className={styles.actionIcon}><AnimatePresence mode="popLayout" initial={false}><Swap key={item.read ? "unread" : "read"} className={styles.actionGlyph} initial={reduce ? { opacity: 0 } : iconIn} animate={shown} exit={reduce ? fadeOut : { ...iconIn, transition: exitFast }} transition={reduce ? instant : motionTokens.spring.snappy}>{item.read ? <HugeiconsIcon icon={CircleDotIcon} size={14} strokeWidth={1.75} aria-hidden="true" /> : <UiIcon iconRole="check" size={14} strokeWidth={1.75} aria-hidden="true" />}</Swap></AnimatePresence></span>
                        <SwapText reduce={reduce}>{item.read ? "읽지 않음으로 표시하기" : "읽음으로 표시하기"}</SwapText>
                      </MorphWidth></button>
                      <button type="button" onClick={() => dismiss(item)}><UiIcon iconRole="close" size={14} strokeWidth={1.75} aria-hidden="true" />삭제하기</button>
                    </div>
                  </motion.div>}
                </AnimatePresence>
              </article>
            </motion.div>)}
            {/* The empty state opens its height on the same spring the rows close on, so the panel morphs between them instead of stacking both. After a bulk action it waits for the cascade to get going, so the panel never grows before it shrinks. */}
            {visible.length === 0 && <motion.div key="empty" className={styles.emptyFrame} initial={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={reduce ? fadeOut : { height: 0, opacity: 0, transition: { height: motionTokens.spring.smooth, opacity: exitFast } }} transition={reduce ? instant : { height: { ...motionTokens.spring.smooth, delay: bulk ? cascade(3) : 0 }, opacity: { ...enter, delay: motionTokens.duration.fast } }}>
              <motion.div className={styles.empty} initial={reduce ? false : { y: 6 }} animate={{ y: 0 }} transition={reduce ? instant : { ...enter, delay: motionTokens.duration.fast }}><UiIcon iconRole="success" size={24} strokeWidth={1.5} aria-hidden="true" /><strong><SwapText reduce={reduce}>{view === "unread" ? "읽지 않은 알림이 없어요" : "알림이 없어요"}</SwapText></strong><p><SwapText reduce={reduce}>{view === "unread" ? "모든 소식을 확인했어요." : "새 소식이 오면 여기에 보여요."}</SwapText></p>{view === "unread" && items.length > 0 && <button type="button" onClick={() => { setBulk(false); setView("all"); }}>전체 보기</button>}</motion.div>
            </motion.div>}
          </AnimatePresence>
        </div>

        <AnimatePresence initial={false}>{readCount > 0 && <motion.div key="footer" className={styles.footerFrame} initial={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={reduce ? fadeOut : { height: 0, opacity: 0, transition: { height: motionTokens.spring.smooth, opacity: exitFast } }} transition={height}>
          <div className={styles.footer}><span>읽은 알림 <RollingCount value={readCount} reduce={reduce} />개</span><button type="button" onClick={clearRead}>읽은 알림 지우기</button></div>
        </motion.div>}</AnimatePresence>
      </PopoverPrimitive.Content>
    </PopoverPrimitive.Portal>
  </PopoverPrimitive.Root>;
}

export default NotificationCenter;

```
