Block
Page header
A page header with breadcrumbs and actions that condenses on scroll and folds overflow.
- #arc
- #block
- #header
- #breadcrumb
- #actions
- #layout
- #sticky
1280px
Install
One command installs the files and packages this item needs.
TerminalTerminalTerminalbash
npx shadcn@latest add https://dsui.vercel.app/r/page-header.jsonFiles
components/arc/blocks/page-header/page-header.module.csscomponents/arc/blocks/page-header/page-header.tsx
npm packages
@hugeicons/core-free-icons@^4.3.5@hugeicons/react@^1.1.10@radix-ui/react-dropdown-menu@^2.2.1@radix-ui/react-tabs@^1.2.1motion@^14.2.0
Registry items it pulls in
Source
View as Markdownpage-header.module.csspage-header.module.csspage-header.module.csscss
/* The frame is the page: a fixed header over a scrolling body. Breakpoints follow the frame width, not the viewport. */
.frame { --pad: 28px; position: relative; display: flex; width: min(100%, 980px); height: 640px; min-width: 0; flex-direction: column; margin-inline: auto; overflow: hidden; border: 1px solid var(--border); border-radius: 16px; background: var(--surface); color: var(--foreground); font-family: var(--font-body); font-size: var(--text-sm); letter-spacing: var(--tracking-body); line-height: var(--leading-body); container: page-header / inline-size; }
.header { position: relative; z-index: 1; flex: none; padding: 16px var(--pad) 0; background: var(--surface); box-shadow: inset 0 -1px 0 var(--border); }
.bar { display: grid; min-height: var(--control-height-sm); grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 16px; }
/* Breadcrumbs and the compact title share one cell and trade places when the header condenses. */
.lead { display: grid; min-width: 0; align-items: center; }
.lead > * { grid-area: 1 / 1; min-width: 0; }
.crumbs ol { display: flex; min-width: 0; align-items: center; gap: 6px; margin: 0; padding: 0; list-style: none; }
.crumbs li { display: inline-flex; flex: none; align-items: center; gap: 6px; color: var(--text-muted); white-space: nowrap; }
.crumbs li:last-child { flex: 0 1 auto; min-width: 0; }
.crumbs li > svg { flex: none; color: var(--border-strong); }
.crumb { margin: 0; border: 0; border-radius: 8px; padding: 4px 2px; background: none; color: var(--text-muted); font: inherit; letter-spacing: inherit; cursor: pointer; transition: color var(--duration-fast) var(--ease-standard); }
.current { display: block; min-width: 0; overflow: hidden; color: var(--text-secondary); text-overflow: ellipsis; }
.compact { display: inline-flex; max-width: calc(100% + 8px); justify-self: start; align-items: center; gap: 10px; margin-left: -8px; border: 0; border-radius: 8px; padding: 5px 8px; background: transparent; color: var(--foreground); font: inherit; letter-spacing: inherit; text-align: left; cursor: pointer; transition: background-color var(--duration-fast) var(--ease-standard); }
.compactTitle { min-width: 0; overflow: hidden; font-size: var(--text-base); font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
.compactBadge { flex: none; }
.dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
/* Secondary actions fold to the right, under the overflow button, as their slot narrows. Only the right edge clips. */
.actions { display: flex; align-items: center; justify-content: flex-end; }
.secondary { display: flex; flex: none; clip-path: inset(-8px 0 -8px -8px); }
.secondaryInner { display: flex; flex: none; gap: 8px; padding-right: 8px; }
.trailing { display: flex; flex: none; align-items: center; gap: 8px; }
.actions .iconButton { width: var(--control-height-sm); padding-inline: 0; }
.intro { overflow: clip; }
.introInner { display: grid; justify-items: start; gap: 10px; padding: 18px 0 20px; }
.titleRow { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; transform-origin: 0 0; }
.title { margin: 0; font-family: var(--font-display); font-size: var(--text-3xl); font-weight: 500; letter-spacing: var(--tracking-display); line-height: var(--leading-display); }
.title:focus { outline: none; }
.details { display: grid; justify-items: start; gap: 14px; }
.description { max-width: 60ch; margin: 0; color: var(--text-secondary); text-wrap: pretty; }
.meta { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 18px; color: var(--text-muted); font-size: var(--text-xs); }
.metaIcon { display: inline-flex; align-items: center; gap: 6px; }
.meta strong { color: var(--text-secondary); font-weight: 500; }
/* Tabs bleed to the frame edges so the rule under them spans the page; the first label lines up with the title. */
.tabs { position: relative; display: flex; gap: 2px; margin: 2px calc(var(--pad) * -1) 0; padding: 0 calc(var(--pad) - 10px); overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none; }
.tabs::-webkit-scrollbar { display: none; }
.tab { position: relative; display: inline-flex; height: 44px; flex: none; align-items: center; gap: 6px; border: 0; padding: 0 10px; background: none; color: var(--text-secondary); font: inherit; font-weight: 500; letter-spacing: inherit; white-space: nowrap; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard); }
.tab::before { position: absolute; inset: 8px 0; border-radius: 8px; background: color-mix(in oklab, var(--foreground) 4.5%, transparent); content: ""; opacity: 0; transition: opacity var(--duration-fast) var(--ease-standard); }
.tab[data-state="active"] { color: var(--foreground); }
.tabLabel, .count { position: relative; }
.count { color: var(--text-muted); font-variant-numeric: tabular-nums; }
.tab[data-state="active"] .count { color: var(--text-secondary); }
/* The shared counter renders at display size by default; here it takes the tab's type. */
.count > span { color: inherit; font-family: inherit; font-size: inherit; font-weight: inherit; letter-spacing: inherit; line-height: inherit; }
.indicator { position: absolute; right: 10px; bottom: 0; left: 10px; height: 2px; border-radius: 2px 2px 0 0; background: var(--accent); }
.scroller { position: relative; flex: 1 1 auto; min-height: 0; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; padding: 28px var(--pad) 84px; scrollbar-color: var(--border-strong) transparent; scrollbar-width: thin; }
/* Every panel is taller than the condensed viewport, so condensing never clamps the scroll back to the top. */
.panel { min-height: 600px; }
.sectionTitle { margin: 0 0 8px; font-size: var(--text-sm); font-weight: 500; }
.note { color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; white-space: nowrap; }
.overview { display: grid; gap: 36px; }
.progress { max-width: 480px; }
.milestones, .activity, .issues, .updates, .files { margin: 0; padding: 0; list-style: none; }
.milestones li { display: grid; min-height: 48px; grid-template-columns: 16px minmax(0, 1fr) auto; align-items: center; gap: 2px 12px; border-bottom: 1px solid var(--border-subtle); }
.milestones li:last-child, .activity li:last-child, .files li:last-child { border-bottom: 0; }
.milestones li > svg { color: var(--text-muted); }
.milestones li[data-state="done"] > svg { color: var(--success); }
.milestones li[data-state="active"] > svg { color: var(--accent); }
.activity li { display: grid; min-height: 52px; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 12px; border-bottom: 1px solid var(--border-subtle); }
.activity p { margin: 0; color: var(--text-secondary); }
.activity strong { color: var(--foreground); font-weight: 500; }
.issue, .update { overflow-x: visible; overflow-y: clip; border-bottom: 1px solid var(--border-subtle); }
.issue:last-child, .update:last-child { border-bottom-color: transparent; }
.issueInner { position: relative; isolation: isolate; display: grid; min-height: 52px; grid-template-columns: 32px minmax(0, 1fr) auto auto; align-items: center; gap: 12px; margin-inline: -10px; padding: 4px 10px; }
.check { position: relative; display: grid; width: 32px; height: 32px; place-items: center; margin-left: -7px; border: 0; border-radius: 50%; padding: 0; background: none; color: var(--text-muted); cursor: pointer; transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }
.checkGlyph { display: grid; place-items: center; }
.issue[data-closing] .check { color: var(--success); cursor: default; }
.issueText { display: flex; min-width: 0; align-items: baseline; gap: 12px; }
.issueId { flex: none; min-width: 60px; color: var(--text-muted); font-size: var(--text-xs); }
.issueTitle { min-width: 0; overflow: hidden; text-decoration: line-through transparent; text-overflow: ellipsis; white-space: nowrap; transition: color var(--duration-standard) var(--ease-standard), text-decoration-color var(--duration-standard) var(--ease-standard); }
.issue[data-closing] .issueTitle { color: var(--text-muted); text-decoration-color: currentColor; }
.issueLabel { color: var(--text-muted); font-size: var(--text-xs); }
/* A new row arrives lit and settles to the page, so it is easy to find without a lasting marker. */
.issue[data-fresh] .issueInner::before, .update[data-fresh] .updateInner::before { position: absolute; z-index: -1; inset: 3px 0; border-radius: 10px; background: color-mix(in oklab, var(--foreground) 6%, transparent); content: ""; animation: arrived 1.8s var(--ease-standard) both; }
@keyframes arrived { 0%, 35% { opacity: 1; } 100% { opacity: 0; } }
.updateInner { position: relative; isolation: isolate; display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 14px; margin-inline: -10px; padding: 18px 10px; }
.updateHead { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; min-height: 22px; }
.author { font-weight: 500; }
.updateInner p { max-width: 64ch; margin: 6px 0 0; color: var(--text-secondary); text-wrap: pretty; }
.files li { display: grid; min-height: 56px; grid-template-columns: 16px minmax(0, 1fr) auto 52px; align-items: center; gap: 14px; border-bottom: 1px solid var(--border-subtle); }
.files li > svg { color: var(--text-secondary); }
.fileName { display: grid; min-width: 0; }
.fileName > span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fileDate { color: var(--text-muted); font-size: var(--text-xs); text-align: right; white-space: nowrap; }
.empty { display: grid; justify-items: center; gap: 4px; padding: 72px 16px; text-align: center; }
.empty > svg { margin-bottom: 8px; color: var(--success); }
.empty p { margin: 0; font-size: var(--text-base); font-weight: 500; }
.empty span { color: var(--text-muted); }
.empty button { margin-top: 14px; }
.toastLayer { position: absolute; z-index: 3; right: 16px; bottom: 16px; left: 16px; display: flex; justify-content: center; pointer-events: none; }
.toast { display: inline-flex; max-width: 100%; align-items: center; gap: 8px; border: 1px solid var(--float-edge, var(--surface-raised)); border-radius: var(--radius-control); padding: 9px 14px; background: var(--surface-raised); box-shadow: var(--shadow-floating); color: var(--foreground); font-size: var(--text-sm); }
.toast > svg { flex: none; color: var(--text-secondary); }
.toast[data-tone="success"] > svg { color: var(--success); }
.toast[data-tone="error"] > svg { color: var(--danger); }
.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
/* The menu grows from the overflow button. Transitions, not keyframes, so a quick reopen reverses from where it is. */
.menu { --menu-y: -5px; position: relative; z-index: 60; min-width: 13rem; border: 1px solid var(--float-edge, var(--surface-raised)); border-radius: var(--radius-panel); padding: 5px; background: var(--surface-raised); box-shadow: var(--shadow-floating); color: var(--foreground); font-family: var(--font-body); font-size: var(--text-sm); letter-spacing: var(--tracking-body); transform-origin: var(--radix-dropdown-menu-content-transform-origin); transition: opacity var(--duration-fast) var(--ease-enter), transform var(--duration-spring) var(--ease-spring); }
.menu[data-side="top"] { --menu-y: 5px; }
@starting-style { .menu[data-state="open"] { opacity: 0; transform: translateY(var(--menu-y)) scale(.97); } }
.menu[data-state="closed"] { opacity: 0; transform: translateY(calc(var(--menu-y) * .5)) scale(.985); transition: opacity var(--duration-instant) var(--ease-standard), transform var(--duration-instant) var(--ease-standard); animation: menu-exit var(--duration-instant) linear both; pointer-events: none; }
@keyframes menu-exit { to { --menu-exit: 1; } }
.highlight { position: absolute; top: 0; right: 5px; left: 5px; border-radius: calc(var(--radius-panel) - 6px); background: var(--surface-muted); opacity: 0; pointer-events: none; }
.item { position: relative; display: flex; min-height: 36px; align-items: center; gap: 10px; border-radius: calc(var(--radius-panel) - 6px); padding: 0 11px; outline: none; cursor: pointer; user-select: none; }
.item > svg { flex: none; color: var(--text-secondary); }
.item[data-disabled] { opacity: .45; cursor: default; }
.separator { height: 1px; margin: 4px -5px; background: var(--border-subtle); }
@media (hover: hover) and (pointer: fine) {
.crumb:hover { color: var(--foreground); }
.compact:hover { background: color-mix(in oklab, var(--foreground) 4.5%, transparent); }
.tab:hover { color: var(--foreground); }
.tab:hover::before { opacity: 1; }
.check:hover { background: color-mix(in oklab, var(--foreground) 5%, transparent); color: var(--foreground); }
.issue[data-closing] .check:hover { background: none; color: var(--success); }
}
/* Before the first measurement, narrow frames already hide the actions that will fold, so nothing flashes on load. */
@container page-header (max-width: 719px) { .frame:not([data-measured]) .secondary { width: 0 !important; opacity: 0 !important; } }
@container page-header (max-width: 559px) {
.header, .scroller { --pad: 18px; }
.crumbs .rootCrumb, .compact .compactBadge { display: none; }
/* The tab row scrolls sideways here; its far edge fades so the next tab reads as more to come. */
.tabs { padding-right: calc(var(--pad) + 14px); mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent); }
.milestones li { grid-template-columns: 16px minmax(0, 1fr); padding-block: 10px; }
.milestones li > .note { grid-column: 2; }
.title { font-size: var(--text-2xl); }
.introInner { padding-top: 16px; }
.issueInner { grid-template-columns: 32px minmax(0, 1fr) auto; align-items: start; padding-block: 10px; }
.issueInner .check { margin-top: -6px; }
.issueText { display: grid; gap: 2px; }
.issueTitle { overflow: visible; white-space: normal; }
.issueLabel, .fileDate { display: none; }
.files li { grid-template-columns: 16px minmax(0, 1fr) auto; }
.activity li { grid-template-columns: auto minmax(0, 1fr); padding-block: 10px; }
.activity li > .note { grid-column: 2; margin-top: -8px; }
}
@container page-header (max-width: 459px) {
.createText { display: none; }
.actions .create { width: var(--control-height-sm); padding-inline: 0; }
}
/* On a phone the frame is the screen, so it takes most of it rather than stopping short with the body cut mid list. */
@media (max-width: 560px) { .frame { height: clamp(560px, calc(100svh - 48px), 760px); border-radius: 14px; } }
@media (prefers-reduced-motion: reduce) {
.crumb, .compact, .tab, .tab::before, .check, .issueTitle { transition: none; }
.menu, .menu[data-state="closed"] { transform: none; transition: opacity var(--duration-instant) linear; }
}
page-header.tsxpage-header.tsxpage-header.tsxtsx
"use client";
import { Fragment, useEffect, useId, useRef, useState } from "react";
import type { FocusEvent, ReactNode, UIEvent } from "react";
import * as DropdownPrimitive from "@radix-ui/react-dropdown-menu";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { AnimatePresence, LayoutGroup, motion, type Transition, type Variants } from "motion/react";
import { useReducedMotion } from "../../lib/use-reduced-motion";
import { HugeiconsIcon } from "@hugeicons/react";
import type { IconSvgElement } from "@hugeicons/react";
import { Archive01Icon, ArchiveRestoreIcon, CheckmarkCircle02Icon, CircleDashedIcon, CircleIcon, File01Icon, FilmRoll01Icon, Megaphone01Icon, Notification01Icon, NotificationOff01Icon, PenTool01Icon, PresentationBarChart01Icon, Xls01Icon } from "@hugeicons/core-free-icons";
import { AnimatedCounter } from "../../animated-counter/animated-counter";
import { Avatar } from "../../avatar/avatar";
import { AvatarGroup } from "../../avatar-group/avatar-group";
import { Badge } from "../../badge/badge";
import { Button } from "../../button/button";
import { Progress } from "../../progress/progress";
import { motionTokens } from "../../lib/motion-tokens";
import styles from "./page-header.module.css";
import { UiIcon } from "../../theme/ui-icon";
import { playUiSound } from "../../sound/play";
/** The header condenses past CONDENSE_AT and only opens again near the top, so it never flickers at the threshold. */
const CONDENSE_AT = 16;
const EXPAND_AT = 4;
/** Secondary actions fold into the overflow menu when the breadcrumbs would get less room than this, and return with a little slack. */
const LEAD_MIN = 240;
const LEAD_RETURN = 272;
const DONE_AT_START = 31;
const PROJECT_URL = "https://bukkyeong.example/projects/checkout-redesign";
type PersonId = "emma" | "marcus" | "ava" | "sofia" | "jasmine";
const people: Record<PersonId, { name: string; src: string }> = {
emma: { name: "김서연", src: "/media/people/emma-collins.jpg" },
marcus: { name: "이민준", src: "/media/people/marcus-johnson.jpg" },
ava: { name: "박지우", src: "/media/people/ava-mitchell.jpg" },
sofia: { name: "최하윤", src: "/media/people/sofia-ramirez.jpg" },
jasmine: { name: "정다은", src: "/media/people/jasmine-brooks.jpg" },
};
const members = (["emma", "marcus", "ava", "sofia", "jasmine"] as const).map(key => people[key]);
type Section = "overview" | "issues" | "updates" | "files";
const sections: { value: Section; label: string }[] = [{ value: "overview", label: "개요" }, { value: "issues", label: "이슈" }, { value: "updates", label: "업데이트" }, { value: "files", label: "파일" }];
const order = (section: Section) => sections.findIndex(item => item.value === section);
type Issue = { id: string; title: string; owner: PersonId; label: string; fresh?: boolean };
const startingIssues: Issue[] = [
{ id: "CHK-138", title: "작은 화면에서 저장된 카드 목록의 유효기간이 잘려요", owner: "sofia", label: "버그" },
{ id: "CHK-136", title: "주문 확인 단계의 각 영역 옆에 수정 링크를 추가해요", owner: "emma", label: "디자인" },
{ id: "CHK-135", title: "카드 확인에 실패하면 지갑 시트가 두 번 열려요", owner: "marcus", label: "버그" },
{ id: "CHK-133", title: "결제 전에 배송 예상일을 보여 줘요", owner: "ava", label: "리서치" },
{ id: "CHK-131", title: "잘못된 프로모션 코드를 넣으면 입력칸의 포커스가 사라져요", owner: "sofia", label: "버그" },
{ id: "CHK-129", title: "주문 확인에서 결제까지 이탈률을 추적해요", owner: "ava", label: "분석" },
{ id: "CHK-127", title: "재방문 고객에게 마지막에 쓴 카드를 미리 선택해 줘요", owner: "marcus", label: "기능" },
{ id: "CHK-124", title: "카드 승인 거절 안내 문구를 더 쉽게 다듬어요", owner: "emma", label: "콘텐츠" },
{ id: "CHK-122", title: "주소 자동 완성에서 동·호수가 빠져요", owner: "sofia", label: "버그" },
];
const draftIssues = ["키보드 포커스가 카드 저장 체크박스를 건너뛰어요", "배송 국가가 바뀌면 세금을 다시 계산해요", "결제 버튼에 눌림 상태와 로딩 상태가 필요해요", "큰 글자 크기에서 주문 합계가 어색하게 줄바꿈돼요"];
type Update = { id: string; author: PersonId; date: string; tone: "success" | "warning"; status: string; body: string; fresh?: boolean };
const startingUpdates: Update[] = [
{ id: "u4", author: "emma", date: "9월 19일", tone: "success", status: "순조로워요", body: "저장된 결제 수단이 목요일에 모든 iOS 고객에게 나갔어요. 재방문 고객의 결제 완료율이 3.1%p 올랐어요. 다음은 주문 확인 단계예요." },
{ id: "u3", author: "ava", date: "9월 12일", tone: "success", status: "순조로워요", body: "사용성 테스트 5회를 마쳤어요. 수정하는 동안 합계가 계속 보이면 주문 확인을 한 단계로 줄여도 사람들이 믿고 따라요." },
{ id: "u2", author: "marcus", date: "9월 5일", tone: "warning", status: "위험해요", body: "카드 토큰화는 결제팀 작업을 기다리고 있어요. 10월 일정을 지키려고 주문 확인 단계를 먼저 진행해요." },
{ id: "u1", author: "emma", date: "8월 29일", tone: "success", status: "순조로워요", body: "킥오프를 했어요. 범위는 저장 카드, 주문 확인 한 단계, 10월 14일부터 단계별 배포예요." },
];
const draftUpdates = ["주문 확인 단계가 플래그 뒤에서 스테이징에 올라갔어요. 금요일까지 오류율이 유지되면 월요일에 트래픽의 10%에 열어요.", "카드 거절 안내 문구를 확정해서 고객지원팀 검토 중이에요. 10월 14일 배포 일정은 그대로예요."];
const files: { name: string; kind: string; size: string; owner: PersonId; date: string; icon: IconSvgElement }[] = [
{ name: "결제 흐름 v3", kind: "디자인 파일", size: "18.4 MB", owner: "emma", date: "9월 20일", icon: PenTool01Icon },
{ name: "주문 확인 단계 시연", kind: "동영상", size: "46 MB", owner: "emma", date: "9월 18일", icon: FilmRoll01Icon },
{ name: "결제 API 메모", kind: "문서", size: "96 KB", owner: "marcus", date: "9월 16일", icon: File01Icon },
{ name: "사용성 조사 결과, 2차", kind: "PDF", size: "2.1 MB", owner: "ava", date: "9월 13일", icon: File01Icon },
{ name: "배포 계획", kind: "슬라이드", size: "4.8 MB", owner: "sofia", date: "9월 11일", icon: PresentationBarChart01Icon },
{ name: "전환율 기준선", kind: "스프레드시트", size: "640 KB", owner: "ava", date: "9월 9일", icon: Xls01Icon },
{ name: "빈 화면과 오류 화면", kind: "디자인 파일", size: "9.2 MB", owner: "emma", date: "9월 6일", icon: PenTool01Icon },
{ name: "출시 체크리스트", kind: "문서", size: "54 KB", owner: "sofia", date: "9월 2일", icon: File01Icon },
];
const milestoneIcons = { done: CheckmarkCircle02Icon, active: CircleDashedIcon, planned: CircleIcon };
const milestones: { name: string; note: string; state: keyof typeof milestoneIcons }[] = [
{ name: "저장된 결제 수단", note: "9월 18일에 출시했어요", state: "done" },
{ name: "주문 확인 한 단계", note: "진행 중, 10월 1일까지", state: "active" },
{ name: "단계별 배포", note: "10월 14일 예정", state: "planned" },
];
const activity: { who: PersonId; text: string; time: string }[] = [
{ who: "ava", text: "님이 CHK-131을 검토로 옮겼어요", time: "1시간 전" },
{ who: "marcus", text: "님이 저장 카드 엔드포인트를 병합했어요", time: "3시간 전" },
{ who: "emma", text: "님이 결제 흐름 v3를 공유했어요", time: "어제" },
{ who: "sofia", text: "님이 재시도 시 이중 결제 건인 CHK-119를 닫았어요", time: "어제" },
{ who: "jasmine", text: "님이 배포일을 10월 14일로 정했어요", time: "9월 17일" },
];
type Notice = { key: number; text: string; tone: "success" | "error" | "neutral" };
type MenuAction = { key: string; label: string; icon: ReactNode; onSelect: () => void; disabled?: boolean; separatorBefore?: boolean };
const still: Transition = { duration: 0 };
const quick: Transition = { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] };
const blur = (px: number) => `blur(${px}px)`;
const icon = { size: 16, strokeWidth: 1.75, "aria-hidden": true } as const;
/** Panels slide a few pixels in the direction of the tab that was chosen. */
const panelSlide: Variants = {
enter: (direction: number) => ({ opacity: 0, x: direction * 12 }),
center: { opacity: 1, x: 0, transition: { x: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] } } },
exit: (direction: number) => ({ opacity: 0, x: direction * -8, transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } }),
};
const panelFade: Variants = { enter: { opacity: 0, x: 0 }, center: { opacity: 1, x: 0, transition: { duration: motionTokens.duration.instant } }, exit: { opacity: 0, x: 0, transition: { duration: motionTokens.duration.instant } } };
/** Rows open and close their own height, so the list closes the gap instead of jumping. */
function rowMotion(reduce: boolean) {
return reduce
? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: { duration: motionTokens.duration.instant } }
: { initial: { opacity: 0, height: 0 }, animate: { opacity: 1, height: "auto" }, exit: { opacity: 0, height: 0 }, transition: { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.standard] } } };
}
function StatusDot() {
return <span className={styles.dot} />;
}
/** The overflow button never scales: it anchors the menu. A shared highlight glides between items under the pointer. */
function OverflowMenu({ actions, reduce }: { actions: MenuAction[]; reduce: boolean }) {
const [highlight, setHighlight] = useState<{ top: number; height: number; glide: boolean } | null>(null);
const pointer = useRef(false);
const clearTimer = useRef(0);
useEffect(() => () => window.clearTimeout(clearTimer.current), []);
function onFocus(event: FocusEvent<HTMLDivElement>) {
const item = event.target instanceof HTMLElement ? event.target.closest<HTMLElement>('[role="menuitem"]') : null;
window.clearTimeout(clearTimer.current);
if (!item) { clearTimer.current = window.setTimeout(() => setHighlight(null), pointer.current ? 70 : 0); return; }
const glide = pointer.current;
setHighlight(current => ({ top: item.offsetTop, height: item.offsetHeight, glide: glide && current !== null }));
}
// Radix closes the menu right after an item is chosen; a choice plays only "select", so the close sound waits one microtask to see whether a choice came with it.
const chose = useRef(false);
return <DropdownPrimitive.Root onOpenChange={open => { if (open) { chose.current = false; playUiSound("open"); } else queueMicrotask(() => { if (!chose.current) playUiSound("close"); chose.current = false; }); if (open) { window.clearTimeout(clearTimer.current); setHighlight(null); } }}>
<DropdownPrimitive.Trigger asChild><Button variant="secondary" size="sm" silent className={styles.iconButton} aria-label="더 보기"><UiIcon iconRole="more" size={17} strokeWidth={1.75} aria-hidden="true" /></Button></DropdownPrimitive.Trigger>
<DropdownPrimitive.Portal>
<DropdownPrimitive.Content className={styles.menu} align="end" sideOffset={6} collisionPadding={12} loop onFocus={onFocus} onPointerMoveCapture={() => { pointer.current = true; }} onKeyDownCapture={() => { pointer.current = false; }}>
<motion.span className={styles.highlight} aria-hidden="true" initial={false} animate={highlight ? { y: highlight.top, height: highlight.height, opacity: 1 } : { opacity: 0 }} transition={{ default: highlight?.glide && !reduce ? motionTokens.spring.snappy : still, opacity: { duration: reduce ? 0 : motionTokens.duration.micro } }} />
{actions.map(action => <Fragment key={action.key}>
{action.separatorBefore && <DropdownPrimitive.Separator className={styles.separator} />}
<DropdownPrimitive.Item className={styles.item} disabled={action.disabled} onSelect={() => { chose.current = true; playUiSound("select"); action.onSelect(); }}>{action.icon}{action.label}</DropdownPrimitive.Item>
</Fragment>)}
</DropdownPrimitive.Content>
</DropdownPrimitive.Portal>
</DropdownPrimitive.Root>;
}
function OverviewPanel({ done, open }: { done: number; open: number }) {
const total = done + open;
return <div className={styles.overview}>
<div className={styles.progress}><Progress value={done} max={total} label={`이슈 ${total}개 중 ${done}개 완료`} showValue /></div>
<div>
<h3 className={styles.sectionTitle}>마일스톤</h3>
<ol className={styles.milestones}>{milestones.map(item => {
const icon = milestoneIcons[item.state];
return <li key={item.name} data-state={item.state}><HugeiconsIcon icon={icon} size={16} strokeWidth={1.75} aria-hidden="true" /><span className={styles.milestoneName}>{item.name}</span><span className={styles.note}>{item.note}</span></li>;
})}</ol>
</div>
<div>
<h3 className={styles.sectionTitle}>최근 활동</h3>
<ul className={styles.activity}>{activity.map(item => <li key={`${item.who}-${item.text}`}><Avatar name={people[item.who].name} src={people[item.who].src} size="sm" /><p><strong>{people[item.who].name}</strong>{item.text}</p><span className={styles.note}>{item.time}</span></li>)}</ul>
</div>
</div>;
}
function IssuesPanel({ issues, closing, reduce, onComplete, onReset, register }: { issues: Issue[]; closing: string[]; reduce: boolean; onComplete: (issue: Issue) => void; onReset: () => void; register: (id: string, node: HTMLButtonElement | null) => void }) {
const row = rowMotion(reduce);
return <>
<ul className={styles.issues} aria-label="열린 이슈">
<AnimatePresence initial={false}>
{issues.map(issue => {
const isClosing = closing.includes(issue.id);
const owner = people[issue.owner];
return <motion.li key={issue.id} className={styles.issue} data-fresh={issue.fresh || undefined} data-closing={isClosing || undefined} {...row}>
<div className={styles.issueInner}>
<button ref={node => register(issue.id, node)} type="button" className={styles.check} aria-label={`${issue.id} 이슈 완료로 표시하기`} aria-disabled={isClosing || undefined} onClick={() => { playUiSound("click"); onComplete(issue); }}>
<AnimatePresence initial={false} mode="popLayout">
<motion.span key={isClosing ? "done" : "open"} className={styles.checkGlyph} initial={reduce ? { opacity: 0 } : { opacity: 0, scale: .5 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: reduce ? 1 : .5, transition: quick }} transition={reduce ? still : motionTokens.spring.snappy}>
{isClosing ? <UiIcon iconRole="success" size={18} strokeWidth={1.75} aria-hidden="true" /> : <HugeiconsIcon icon={CircleIcon} size={18} strokeWidth={1.75} aria-hidden="true" />}
</motion.span>
</AnimatePresence>
</button>
<span className={styles.issueText}><span className={styles.issueId}>{issue.id}</span><span className={styles.issueTitle}>{issue.title}</span></span>
<span className={styles.issueLabel}>{issue.label}</span>
<Avatar name={owner.name} src={owner.src} size="sm" />
</div>
</motion.li>;
})}
</AnimatePresence>
</ul>
{issues.length === 0 && <motion.div className={styles.empty} initial={{ opacity: 0, y: reduce ? 0 : 6 }} animate={{ opacity: 1, y: 0 }} transition={reduce ? { duration: motionTokens.duration.instant } : motionTokens.spring.smooth}>
<UiIcon iconRole="success" size={24} strokeWidth={1.75} aria-hidden="true" />
<p>열린 이슈가 없어요</p>
<span>이 프로젝트의 일을 모두 끝냈어요.</span>
<Button variant="secondary" size="sm" onClick={onReset}>샘플 이슈 되돌리기</Button>
</motion.div>}
</>;
}
function UpdatesPanel({ updates, reduce }: { updates: Update[]; reduce: boolean }) {
const row = rowMotion(reduce);
return <ol className={styles.updates} aria-label="프로젝트 업데이트">
<AnimatePresence initial={false}>
{updates.map(update => {
const author = people[update.author];
return <motion.li key={update.id} className={styles.update} data-fresh={update.fresh || undefined} {...row}>
<article className={styles.updateInner}>
<Avatar name={author.name} src={author.src} size="md" />
<div>
<div className={styles.updateHead}><span className={styles.author}>{author.name}</span><span className={styles.note}>{update.date}</span><Badge size="sm" tone={update.tone} icon={<StatusDot />}>{update.status}</Badge></div>
<p>{update.body}</p>
</div>
</article>
</motion.li>;
})}
</AnimatePresence>
</ol>;
}
function FilesPanel() {
return <ul className={styles.files} aria-label="프로젝트 파일">{files.map(file => {
const icon = file.icon;
const owner = people[file.owner];
return <li key={file.name}><HugeiconsIcon icon={icon} size={16} strokeWidth={1.75} aria-hidden="true" /><span className={styles.fileName}><span>{file.name}</span><span className={styles.note}>{file.kind}, {file.size}</span></span><Avatar name={owner.name} src={owner.src} size="sm" /><span className={styles.fileDate}>{file.date}</span></li>;
})}</ul>;
}
export function PageHeader() {
const id = useId();
const reduce = useReducedMotion() ?? false;
const bar = useRef<HTMLDivElement>(null);
const secondary = useRef<HTMLDivElement>(null);
const trailing = useRef<HTMLDivElement>(null);
const scroller = useRef<HTMLDivElement>(null);
const tabList = useRef<HTMLDivElement>(null);
const titleRef = useRef<HTMLHeadingElement>(null);
const toggles = useRef(new Map<string, HTMLButtonElement>());
const timers = useRef(new Set<number>());
const counters = useRef({ issue: 0, update: 0, notice: 0 });
const [view, setView] = useState<{ section: Section; direction: number }>({ section: "overview", direction: 1 });
const [condensed, setCondensed] = useState(false);
const [layout, setLayout] = useState({ measured: false, collapsed: false, animate: false });
const [following, setFollowing] = useState(false);
const [archived, setArchived] = useState(false);
const [sharing, setSharing] = useState<"idle" | "sending" | "sent">("idle");
const [issues, setIssues] = useState(startingIssues);
const [closing, setClosing] = useState<string[]>([]);
const [done, setDone] = useState(DONE_AT_START);
const [updates, setUpdates] = useState(startingUpdates);
const [notice, setNotice] = useState<Notice | null>(null);
// Only the header width is observed, so a label that grows (Follow to Following) never folds the button you just pressed.
// The first measurement applies at once; later width changes fold the actions with a spring.
useEffect(() => {
const row = bar.current, folded = secondary.current, fixed = trailing.current;
if (!row || !folded || !fixed || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => {
const room = row.clientWidth - folded.offsetWidth - fixed.offsetWidth - 16;
setLayout(current => {
const collapsed = current.measured && current.collapsed ? room < LEAD_RETURN : room < LEAD_MIN;
return current.measured && current.collapsed === collapsed ? current : { measured: true, collapsed, animate: current.measured };
});
});
observer.observe(row);
return () => observer.disconnect();
}, []);
useEffect(() => {
const pending = timers.current;
return () => pending.forEach(handle => window.clearTimeout(handle));
}, []);
useEffect(() => {
if (!notice) return;
const handle = window.setTimeout(() => setNotice(null), 3200);
return () => window.clearTimeout(handle);
}, [notice]);
// Keep the chosen tab in view when the list scrolls sideways on narrow screens.
useEffect(() => {
const list = tabList.current;
const tab = list?.querySelector<HTMLElement>('[role="tab"][data-state="active"]');
if (!list || !tab) return;
const start = tab.offsetLeft - 12;
const end = tab.offsetLeft + tab.offsetWidth + 12 - list.clientWidth;
const behavior = reduce ? "auto" : "smooth";
if (list.scrollLeft > start) list.scrollTo({ left: start, behavior });
else if (list.scrollLeft < end) list.scrollTo({ left: end, behavior });
}, [view.section, reduce]);
function later(run: () => void, delay: number) {
const handle = window.setTimeout(() => { timers.current.delete(handle); run(); }, delay);
timers.current.add(handle);
}
function notify(text: string, tone: Notice["tone"] = "success") {
counters.current.notice += 1;
setNotice({ key: counters.current.notice, text, tone });
}
/** Switching sections while condensed shows the top of the new panel and keeps the compact bar. */
function selectSection(next: Section, reveal = false) {
setView(current => current.section === next ? current : { section: next, direction: order(next) > order(current.section) ? 1 : -1 });
const node = scroller.current;
if (node && (reveal || next !== view.section) && node.scrollTop > CONDENSE_AT + 1) node.scrollTop = CONDENSE_AT + 1;
}
function onScroll(event: UIEvent<HTMLDivElement>) {
const top = event.currentTarget.scrollTop;
setCondensed(current => (current ? top > EXPAND_AT : top > CONDENSE_AT));
}
function backToTop() {
scroller.current?.scrollTo({ top: 0, behavior: reduce ? "auto" : "smooth" });
titleRef.current?.focus({ preventScroll: true });
}
function toggleFollow() {
const next = !following;
setFollowing(next);
notify(next ? "결제 화면 개편 팔로우를 시작했어요" : "결제 화면 개편 팔로우를 취소했어요", next ? "success" : "neutral");
}
function shareUpdate() {
if (archived || sharing !== "idle") return;
setSharing("sending");
later(() => {
const index = counters.current.update++;
const key = `draft-${index}`;
setUpdates(list => [{ id: key, author: "jasmine", date: "방금", tone: "success", status: "순조로워요", body: draftUpdates[index % draftUpdates.length]!, fresh: true }, ...list]);
setSharing("sent");
selectSection("updates", true);
notify("프로젝트 팀에 업데이트를 공유했어요");
later(() => setSharing("idle"), 1800);
later(() => setUpdates(list => list.map(item => item.id === key ? { ...item, fresh: false } : item)), 1800);
}, 700);
}
function createIssue() {
if (archived) return;
const index = counters.current.issue++;
const issue: Issue = { id: `CHK-${139 + index}`, title: draftIssues[index % draftIssues.length]!, owner: "jasmine", label: "분류 필요", fresh: true };
setIssues(list => [issue, ...list]);
selectSection("issues", true);
notify(`${issue.id} 이슈를 만들고 나에게 배정했어요`);
later(() => setIssues(list => list.map(item => item.id === issue.id ? { ...item, fresh: false } : item)), 1800);
}
function completeIssue(issue: Issue) {
if (closing.includes(issue.id)) return;
const index = issues.findIndex(item => item.id === issue.id);
const remaining = issues.filter(item => item.id !== issue.id && !closing.includes(item.id));
const neighbor = remaining[Math.min(index, remaining.length - 1)];
setClosing(list => [...list, issue.id]);
later(() => {
const hadFocus = document.activeElement === toggles.current.get(issue.id);
setIssues(list => list.filter(item => item.id !== issue.id));
setClosing(list => list.filter(item => item !== issue.id));
setDone(count => count + 1);
notify(`${issue.id} 이슈를 완료했어요`);
if (hadFocus) (neighbor ? toggles.current.get(neighbor.id) : scroller.current?.querySelector<HTMLElement>('[role="tabpanel"][data-state="active"]'))?.focus();
}, 380);
}
function resetIssues() {
setIssues(startingIssues);
setDone(DONE_AT_START);
notify("샘플 이슈를 되돌렸어요", "neutral");
}
function toggleArchive() {
const next = !archived;
setArchived(next);
notify(next ? "프로젝트를 보관했어요. 새 이슈와 업데이트는 잠시 멈춰요." : "프로젝트를 복원했어요", "neutral");
}
async function copyLink() {
try {
await navigator.clipboard.writeText(PROJECT_URL);
notify("링크를 복사했어요");
} catch {
notify("링크를 복사하지 못했어요. 주소창에서 직접 복사해 볼까요?", "error");
}
}
function openCrumb(label: string) {
notify(`여기서 ${label} 화면이 열려요.`, "neutral");
}
const status = archived ? { tone: "neutral" as const, label: "보관 중이에요" } : { tone: "success" as const, label: "순조로워요" };
const menuActions: MenuAction[] = [
...(layout.collapsed ? [
{ key: "follow", label: following ? "팔로우 취소하기" : "팔로우하기", icon: following ? <HugeiconsIcon icon={NotificationOff01Icon} {...icon} /> : <UiIcon iconRole="notification" {...icon} />, onSelect: toggleFollow },
{ key: "share", label: "업데이트 공유하기", icon: <HugeiconsIcon icon={Megaphone01Icon} {...icon} />, onSelect: shareUpdate, disabled: archived || sharing !== "idle" },
] : []),
{ key: "copy", label: "링크 복사하기", icon: <UiIcon iconRole="link" {...icon} />, onSelect: copyLink, separatorBefore: layout.collapsed },
{ key: "archive", label: archived ? "프로젝트 복원하기" : "프로젝트 보관하기", icon: archived ? <HugeiconsIcon icon={ArchiveRestoreIcon} {...icon} /> : <HugeiconsIcon icon={Archive01Icon} {...icon} />, onSelect: toggleArchive },
];
/** Leaving copy fades fast; arriving copy settles on the smooth spring with a short blur. */
const swap = (entering: boolean): Transition => reduce ? still : { ...motionTokens.spring.smooth, opacity: { duration: entering ? motionTokens.duration.standard : motionTokens.duration.fast, ease: [...motionTokens.ease.standard], delay: entering ? motionTokens.delay.micro : 0 }, filter: { duration: entering ? motionTokens.duration.standard : motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } };
const shown = { opacity: 1, y: 0, scale: 1, filter: blur(0) };
return (
<TabsPrimitive.Root asChild value={view.section} onValueChange={value => { playUiSound("tick"); selectSection(value as Section); }}>
<section className={styles.frame} aria-labelledby={`${id}-title`} data-measured={layout.measured || undefined}>
<header className={styles.header}>
<div ref={bar} className={styles.bar}>
<div className={styles.lead}>
<motion.nav className={styles.crumbs} aria-label="이동 경로" inert={condensed} initial={false} animate={condensed ? { opacity: 0, y: -8, filter: blur(motionTokens.blur.subtle) } : shown} transition={swap(!condensed)}>
<ol>
<li className={styles.rootCrumb}><button type="button" className={styles.crumb} onClick={() => { playUiSound("click"); openCrumb("부경물산"); }}>부경물산</button><UiIcon iconRole="chevron-right" size={14} strokeWidth={1.75} aria-hidden="true" /></li>
<li><button type="button" className={styles.crumb} onClick={() => { playUiSound("click"); openCrumb("프로젝트"); }}>프로젝트</button><UiIcon iconRole="chevron-right" size={14} strokeWidth={1.75} aria-hidden="true" /></li>
<li><span className={styles.current} aria-current="page">결제 화면 개편</span></li>
</ol>
</motion.nav>
<motion.button type="button" className={styles.compact} inert={!condensed} aria-label="결제 화면 개편, 맨 위로 이동하기" onClick={() => { playUiSound("click"); backToTop(); }} initial={false} animate={condensed ? shown : { opacity: 0, y: 14, filter: blur(motionTokens.blur.soft) }} transition={swap(condensed)}>
<span className={styles.compactTitle}>결제 화면 개편</span>
<Badge className={styles.compactBadge} size="sm" tone={status.tone} icon={<StatusDot />}>{status.label}</Badge>
</motion.button>
</div>
<div className={styles.actions}>
<motion.div className={styles.secondary} inert={layout.collapsed} initial={false} animate={layout.collapsed ? { width: 0, opacity: 0 } : { width: "auto", opacity: 1 }} transition={layout.animate && !reduce ? { width: motionTokens.spring.smooth, opacity: { duration: layout.collapsed ? motionTokens.duration.fast : motionTokens.duration.standard, ease: [...motionTokens.ease.standard], delay: layout.collapsed ? motionTokens.delay.micro : motionTokens.delay.short } } : still}>
<div ref={secondary} className={styles.secondaryInner}>
<Button variant="secondary" size="sm" onClick={toggleFollow}>{following ? <><HugeiconsIcon icon={Notification01Icon} {...icon} /> 팔로우 중</> : <><UiIcon iconRole="notification" {...icon} /> 팔로우하기</>}</Button>
<Button variant="secondary" size="sm" loading={sharing === "sending"} disabled={archived} onClick={shareUpdate}>{sharing === "sent" ? <><UiIcon iconRole="check" {...icon} /> 공유했어요</> : <><HugeiconsIcon icon={Megaphone01Icon} {...icon} /> 업데이트 공유하기</>}</Button>
</div>
</motion.div>
<div ref={trailing} className={styles.trailing}>
<OverflowMenu actions={menuActions} reduce={reduce} />
<Button className={styles.create} size="sm" aria-label="이슈 만들기" disabled={archived} onClick={createIssue}><UiIcon iconRole="plus" {...icon} /><span className={styles.createText}>이슈 만들기</span></Button>
</div>
</div>
</div>
<motion.div className={styles.intro} initial={false} animate={{ height: condensed ? 0 : "auto" }} transition={reduce ? still : motionTokens.spring.smooth}>
<div className={styles.introInner}>
<motion.div className={styles.titleRow} initial={false} animate={condensed ? { opacity: 0, y: -10, scale: .62, filter: blur(motionTokens.blur.subtle) } : shown} transition={swap(!condensed)}>
<h2 ref={titleRef} id={`${id}-title`} className={styles.title} tabIndex={-1}>결제 화면 개편</h2>
<Badge tone={status.tone} icon={<StatusDot />}>{status.label}</Badge>
</motion.div>
<motion.div className={styles.details} initial={false} animate={condensed ? { opacity: 0, y: -6 } : { opacity: 1, y: 0 }} transition={swap(!condensed)}>
<p className={styles.description}>저장된 결제 수단과 주문 확인 한 단계를 중심으로 모바일 결제를 다시 만들고, 10월 14일부터 단계별로 배포해요.</p>
<div className={styles.meta}>
<AvatarGroup members={members} max={4} size="sm" label="프로젝트 멤버" />
<span><strong>김서연</strong> 담당</span>
<span className={styles.metaIcon}><UiIcon iconRole="calendar" size={14} strokeWidth={1.75} aria-hidden="true" />목표일 10월 14일</span>
</div>
</motion.div>
</div>
</motion.div>
<LayoutGroup id={id}>
<TabsPrimitive.List asChild aria-label="프로젝트 섹션">
<motion.div ref={tabList} layoutScroll className={styles.tabs}>
{sections.map(item => {
const count = item.value === "issues" ? issues.length : item.value === "updates" ? updates.length : item.value === "files" ? files.length : null;
return <TabsPrimitive.Trigger key={item.value} value={item.value} className={styles.tab}>
<span className={styles.tabLabel}>{item.label}</span>
{count !== null && <span className={styles.count}><AnimatedCounter value={count} /></span>}
{view.section === item.value && <motion.span className={styles.indicator} layoutId="indicator" layoutDependency={view.section} transition={reduce ? still : motionTokens.spring.morph} aria-hidden="true" />}
</TabsPrimitive.Trigger>;
})}
</motion.div>
</TabsPrimitive.List>
</LayoutGroup>
</header>
<div ref={scroller} className={styles.scroller} onScroll={onScroll}>
<AnimatePresence initial={false} mode="popLayout" custom={view.direction}>
<TabsPrimitive.Content key={view.section} value={view.section} forceMount asChild>
<motion.div className={styles.panel} custom={view.direction} variants={reduce ? panelFade : panelSlide} initial="enter" animate="center" exit="exit">
{view.section === "overview" && <OverviewPanel done={done} open={issues.length} />}
{view.section === "issues" && <IssuesPanel issues={issues} closing={closing} reduce={reduce} onComplete={completeIssue} onReset={resetIssues} register={(key, node) => { if (node) toggles.current.set(key, node); else toggles.current.delete(key); }} />}
{view.section === "updates" && <UpdatesPanel updates={updates} reduce={reduce} />}
{view.section === "files" && <FilesPanel />}
</motion.div>
</TabsPrimitive.Content>
</AnimatePresence>
</div>
<div className={styles.toastLayer} aria-hidden="true">
<AnimatePresence initial={false} mode="popLayout">
{notice && <motion.div key={notice.key} className={styles.toast} data-tone={notice.tone} initial={reduce ? { opacity: 0 } : { opacity: 0, y: 14, filter: blur(motionTokens.blur.soft) }} animate={{ opacity: 1, y: 0, filter: blur(0) }} exit={reduce ? { opacity: 0, transition: still } : { opacity: 0, y: 8, filter: blur(motionTokens.blur.subtle), transition: quick }} transition={reduce ? { duration: motionTokens.duration.instant } : { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] }, filter: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } }}>
{notice.tone === "success" ? <UiIcon iconRole="check" {...icon} /> : notice.tone === "error" ? <UiIcon iconRole="alert" {...icon} /> : null}
<span>{notice.text}</span>
</motion.div>}
</AnimatePresence>
</div>
<p className={styles.srOnly} role="status" aria-live="polite">{notice?.text}</p>
</section>
</TabsPrimitive.Root>
);
}
export default PageHeader;
Used items
- Animated counterComponent
A number that counts up smoothly when it scrolls into view.
- AvatarComponent
A person's photo or initials, with an optional status dot.
- Avatar groupComponent
Overlapping avatars that collapse extra people into a count.
- BadgeComponent
A small label that marks status in neutral, success, info, warning or danger tones.
- ButtonComponent
The base button with press feedback, sound and several variants.
- ProgressComponent
A progress bar that shows how far a task has come, with an optional value readout.