컴포넌트
공지 바
화면 상단에서 공지를 돌려 보여 주고 닫을 수 있는 띠예요.
- #arc
- #component
- #banner
- #notice
- #rotating
- #countdown
1280px
설치
이 명령 한 줄이면 필요한 파일과 패키지가 함께 설치돼요.
터미널터미널터미널bash
npx shadcn@latest add https://dsui.vercel.app/r/announcement-bar.json파일
components/arc/announcement-bar/announcement-bar.module.csscomponents/arc/announcement-bar/announcement-bar.tsx
npm 패키지
motion@^14.2.0
함께 오는 항목
announcement-bar.module.cssannouncement-bar.module.cssannouncement-bar.module.csscss
/* The outer section animates its height to zero on dismiss, so whatever sits below eases up with it. */
.collapse { overflow: hidden; container-type: inline-size; font-family: var(--font-body); letter-spacing: var(--tracking-body); }
/* The side column mirrors the controls, so the message stays centered on the page rather than in the leftover space. */
.bar { --icon: 32px; --control-gap: 2px; --muted: var(--text-secondary); display: grid; grid-template-columns: calc(var(--controls, 0) * var(--icon) + max(var(--controls, 0) - 1, 0) * var(--control-gap)) minmax(0, 1fr) auto; align-items: center; gap: var(--space-3); padding: 6px 8px 6px 16px; border-bottom: 1px solid var(--border); background: var(--surface-muted); color: var(--foreground); }
.bar[data-tone="inverted"] { --muted: color-mix(in oklab, var(--background) 66%, transparent); border-bottom-color: transparent; background: var(--foreground); color: var(--background); }
.side { min-width: 0; }
.viewport { position: relative; min-height: 32px; overflow: hidden; }
/* Faces overlap in place; the entering one reports its height and the viewport springs to it. */
.face { position: absolute; top: 0; right: 0; left: 0; display: flex; min-height: 32px; align-items: center; justify-content: center; }
.message { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: center; gap: 2px 12px; margin: 0; padding: 6px 0; font-size: var(--text-sm); line-height: var(--leading-body); text-align: center; text-wrap: balance; }
.text { min-width: 0; }
/* The countdown is one run of text: every cell shares the line box, so digits, colons, and the day mark sit on the message baseline. */
.countdown { display: inline-flex; align-items: baseline; gap: 6px; color: var(--muted); white-space: nowrap; }
.countdownLabel { color: inherit; }
.time { display: inline-flex; align-items: baseline; color: var(--foreground); font-variant-numeric: tabular-nums; font-weight: 500; line-height: inherit; }
.time[data-pending] { opacity: 0; }
.bar[data-tone="inverted"] .time { color: var(--background); }
.digit { position: relative; display: inline-block; overflow: hidden; overflow: clip; text-align: center; }
.digitSizer { visibility: hidden; }
.digitFace { position: absolute; inset: 0; display: block; }
.unit { display: inline-flex; align-items: baseline; }
.unitMark { margin-left: 1px; color: var(--muted); font-weight: 400; }
/* "case" lifts the colon to the middle of the figures, which it loses once the digits are split into cells. */
.sep { display: inline-block; padding-inline: 1px; color: var(--muted); font-feature-settings: "case"; font-weight: 400; }
.gap { display: inline-block; width: .4em; }
.cta { display: inline-flex; align-items: center; gap: 4px; padding: 0; border: 0; background: none; color: inherit; cursor: pointer; font: inherit; font-weight: 500; text-decoration: none; white-space: nowrap; -webkit-tap-highlight-color: transparent; }
.cta svg { transition: transform var(--duration-fast) var(--ease-standard); }
.controls { display: flex; align-items: center; gap: var(--control-gap); }
.icon, .ring { position: relative; display: grid; width: var(--icon); height: var(--icon); flex: none; place-items: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: var(--muted); cursor: pointer; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }
.icon:active, .ring:active { background: color-mix(in oklab, currentColor 12%, transparent); color: inherit; }
.ring svg { width: 20px; height: 20px; overflow: visible; transform: rotate(-90deg); }
.ringTrack { fill: none; stroke: currentColor; stroke-opacity: .22; stroke-width: 1.75; }
.ringFill { fill: none; stroke: currentColor; stroke-linecap: round; stroke-width: 1.75; }
.ringIcon { position: absolute; inset: 0; display: grid; place-items: center; }
.ringIcon svg { width: 9px; height: 9px; transform: none; fill: currentColor; }
.bar[data-tone="inverted"] .icon, .bar[data-tone="inverted"] .ring { color: var(--muted); }
@media (hover: hover) and (pointer: fine) {
.icon:hover, .ring:hover { background: color-mix(in oklab, currentColor 10%, transparent); }
.bar .icon:hover, .bar .ring:hover { color: var(--foreground); }
.bar[data-tone="inverted"] .icon:hover, .bar[data-tone="inverted"] .ring:hover { color: var(--background); }
.cta:hover svg { transform: translateX(2px); }
}
/* Narrow bars drop the mirror column and read from the start edge. */
@container (max-width: 560px) {
.bar { grid-template-columns: 0 minmax(0, 1fr) auto; gap: 0 var(--space-2); padding-left: 14px; }
.face { justify-content: flex-start; }
.message { justify-content: flex-start; column-gap: 10px; text-align: left; }
}
@media (prefers-reduced-motion: reduce) {
.icon, .ring, .cta svg { transition: none; }
}
announcement-bar.tsxannouncement-bar.tsxannouncement-bar.tsxtsx
"use client";
import { forwardRef, useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import { AnimatePresence, animate, motion, useIsPresent, useMotionValue, useTransform } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import type { AnimationPlaybackControls, Transition, Variants } from "motion/react";
import { motionTokens } from "../lib/motion-tokens";
import styles from "./announcement-bar.module.css";
import { UiIcon } from "../theme/ui-icon";
import { playUiSound } from "../sound/play";
export interface AnnouncementAction {
label: string;
href?: string;
onClick?: () => void;
}
export interface Announcement {
/** Stable id, reported to callbacks. */
id: string;
message: ReactNode;
action?: AnnouncementAction;
/** Counts down to a moment, for example the end of a sale. */
countdown?: { to: Date | string | number; label?: string };
}
/**
* A slim bar for the top of a page. It can rotate several messages, each rising in from below while the bar springs to
* the new height; rotation pauses on hover, focus, or a hidden tab, and `controls` adds previous, next, and a pause ring. Messages can carry a
* call to action and a live countdown. Dismissing collapses the height smoothly so the page below eases up, and with an
* `id` the dismissal is remembered in `localStorage`.
*/
export interface AnnouncementBarProps {
messages: Announcement[];
/** Remembers dismissal under this id. Change the id to show a new campaign to everyone again. */
id?: string;
/** Whether the bar is shown. Leave it out to let the component manage it. */
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
/** Index of the visible message. */
index?: number;
defaultIndex?: number;
onIndexChange?: (index: number) => void;
/** Milliseconds each message stays before the next one. Defaults to 6000. */
interval?: number;
/** Rotate automatically. Pauses on hover, focus, or a hidden tab, and is turned off when the visitor prefers reduced motion. Defaults to true. */
autoPlay?: boolean;
/** Show previous, next, and pause controls when there are several messages. Defaults to false: only the close button shows. */
controls?: boolean;
dismissible?: boolean;
tone?: "neutral" | "inverted";
onAction?: (announcement: Announcement) => void;
onCountdownEnd?: (announcement: Announcement) => void;
/** Accessible name of the region. */
label?: string;
className?: string;
}
const storageKey = (id: string) => `arc-announcement:${id}`;
/** Forgets a remembered dismissal so the bar with this id shows again. */
export function clearAnnouncementDismissal(id: string) {
try { window.localStorage.removeItem(storageKey(id)); } catch { /* Storage can be blocked. */ }
}
type Bezier = [number, number, number, number];
const enter = [...motionTokens.ease.enter] as Bezier;
const standard = [...motionTokens.ease.standard] as Bezier;
const physical = (visualDuration: number, bounce: number): Transition => {
const root = 2 * Math.PI / (visualDuration * 1.2);
return { type: "spring", stiffness: root * root, damping: 2 * (1 - bounce) * root, mass: 1 };
};
const RISE = physical(.46, .1), HEIGHT = physical(.42, 0), COLLAPSE = physical(.44, 0);
/** Next rises from below and leaves upward; previous runs the other way. */
const faceVariants: Variants = {
hidden: (direction: number) => ({ opacity: 0, y: `${direction * 70}%`, filter: `blur(${motionTokens.blur.soft}px)` }),
shown: { opacity: 1, y: "0%", filter: "blur(0px)", transition: { y: RISE, opacity: { duration: motionTokens.duration.standard, ease: enter }, filter: { duration: motionTokens.duration.standard, ease: enter } } },
gone: (direction: number) => ({ opacity: 0, y: `${direction * -60}%`, filter: `blur(${motionTokens.blur.soft}px)`, transition: { y: RISE, opacity: { duration: motionTokens.duration.fast, ease: standard }, filter: { duration: motionTokens.duration.fast, ease: standard } } }),
};
const fadeVariants: Variants = { hidden: { opacity: 0 }, shown: { opacity: 1, transition: { duration: motionTokens.duration.standard, ease: standard } }, gone: { opacity: 0, transition: { duration: motionTokens.duration.instant, ease: standard } } };
function parts(ms: number) {
const total = Math.max(0, Math.floor(ms / 1000));
return { d: Math.floor(total / 86400), h: Math.floor(total % 86400 / 3600), m: Math.floor(total % 3600 / 60), s: total % 60 };
}
const pad = (value: number) => String(value).padStart(2, "0");
/**
* One character cell. A changed digit drops in from above while the old one falls away, like a counter winding down.
* The invisible sizer keeps the cell in the text flow, so it shares the baseline of the words around it.
*/
function Digit({ char, reduced }: { char: string; reduced: boolean }) {
return <span className={styles.digit}>
<span className={styles.digitSizer} aria-hidden="true">0</span>
<AnimatePresence initial={false}>
<motion.span key={char} className={styles.digitFace}
initial={reduced ? { opacity: 0 } : { y: "-70%", opacity: 0 }} animate={{ y: "0%", opacity: 1 }} exit={reduced ? { opacity: 0 } : { y: "70%", opacity: 0 }}
transition={reduced ? { duration: motionTokens.duration.instant } : { y: physical(.32, .08), opacity: { duration: motionTokens.duration.fast, ease: standard } }}>{char}</motion.span>
</AnimatePresence>
</span>;
}
function Countdown({ to, label, reduced, onEnd }: { to: Date | string | number; label?: string; reduced: boolean; onEnd?: () => void }) {
const target = new Date(to).getTime();
const [now, setNow] = useState<number | null>(null);
const ended = useRef(false);
const endRef = useRef(onEnd);
useEffect(() => { endRef.current = onEnd; }, [onEnd]);
useEffect(() => {
let timer = 0;
const tick = () => {
const current = Date.now();
setNow(current);
if (current >= target) {
if (!ended.current) { ended.current = true; endRef.current?.(); }
return;
}
// Wake just after each whole second so digits change on the beat.
timer = window.setTimeout(tick, 1000 - (current % 1000) + 8);
};
timer = window.setTimeout(tick, 0);
return () => window.clearTimeout(timer);
}, [target]);
const { d, h, m, s } = parts(now === null ? 0 : target - now);
const pending = now === null;
const spoken = pending ? "" : `${d ? `${d}일 ` : ""}${h}시간 ${m}분`;
const digits = (value: string, key: string) => value.split("").map((char, index) => <Digit key={`${key}${value.length - index}`} char={pending ? "0" : char} reduced={reduced} />);
return <span className={styles.countdown}>
{label ? <span className={styles.countdownLabel}>{label}</span> : null}
<span role="timer" aria-live="off" aria-label={spoken} className={styles.time} data-pending={pending || undefined}>
{d ? <><span className={styles.unit}>{digits(String(d), "d")}<span className={styles.unitMark} aria-hidden="true">d</span></span><span className={styles.gap} aria-hidden="true" /></> : null}
{digits(pad(h), "h")}<span className={styles.sep} aria-hidden="true">:</span>{digits(pad(m), "m")}<span className={styles.sep} aria-hidden="true">:</span>{digits(pad(s), "s")}
</span>
</span>;
}
function Face({ announcement, direction, reduced, position, total, onSize, onAction, onCountdownEnd }: { announcement: Announcement; direction: number; reduced: boolean; position: number; total: number; onSize: (height: number) => void; onAction?: (announcement: Announcement) => void; onCountdownEnd?: (announcement: Announcement) => void }) {
const ref = useRef<HTMLDivElement>(null);
const present = useIsPresent();
useLayoutEffect(() => {
const node = ref.current;
if (!node || !present) return;
const report = () => onSize(node.offsetHeight);
report();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(report);
observer.observe(node);
return () => observer.disconnect();
}, [onSize, present]);
const { action, countdown } = announcement;
const cta = action ? action.href
? <a className={styles.cta} href={action.href} onClick={() => { playUiSound("click"); action.onClick?.(); onAction?.(announcement); }}>{action.label}<UiIcon iconRole="arrow-right" size={14} strokeWidth={1.75} aria-hidden="true" /></a>
: <button type="button" className={styles.cta} onClick={() => { playUiSound("click"); action.onClick?.(); onAction?.(announcement); }}>{action.label}<UiIcon iconRole="arrow-right" size={14} strokeWidth={1.75} aria-hidden="true" /></button> : null;
return <motion.div ref={ref} className={styles.face} custom={direction} variants={reduced ? fadeVariants : faceVariants} initial="hidden" animate="shown" exit="gone"
role={total > 1 ? "group" : undefined} aria-roledescription={total > 1 ? "slide" : undefined} aria-label={total > 1 ? `${total}개 중 ${position}번째` : undefined} inert={!present || undefined}>
<p className={styles.message}>
<span className={styles.text}>{announcement.message}</span>
{countdown ? <Countdown to={countdown.to} label={countdown.label} reduced={reduced} onEnd={() => onCountdownEnd?.(announcement)} /> : null}
{cta}
</p>
</motion.div>;
}
const RING = 2 * Math.PI * 8;
export const AnnouncementBar = forwardRef<HTMLElement, AnnouncementBarProps>(function AnnouncementBar({
messages, id, open: openProp, defaultOpen = true, onOpenChange, index: indexProp, defaultIndex = 0, onIndexChange,
interval = 6000, autoPlay = true, controls = false, dismissible = true, tone = "neutral", onAction, onCountdownEnd, label = "공지", className,
}, ref) {
const reduced = !!useReducedMotion();
const uid = useId();
const total = messages.length;
/* Open state: controlled, or internal and hidden at once when this id was dismissed before. */
const [openInternal, setOpenInternal] = useState(defaultOpen);
const [remembered, setRemembered] = useState(false);
useLayoutEffect(() => {
if (!id || openProp !== undefined) return;
let stored = false;
try { stored = window.localStorage.getItem(storageKey(id)) === "dismissed"; } catch { /* Storage can be blocked. */ }
// Storage is only readable on the client; hide before paint so a dismissed bar never flashes.
// eslint-disable-next-line react-hooks/set-state-in-effect
if (stored) setRemembered(true);
}, [id, openProp]);
const open = openProp ?? (openInternal && !remembered);
const dismiss = () => {
playUiSound("close");
if (id) { try { window.localStorage.setItem(storageKey(id), "dismissed"); } catch { /* Storage can be blocked. */ } }
if (openProp === undefined) setOpenInternal(false);
onOpenChange?.(false);
};
/* Rotation. */
const [indexInternal, setIndexInternal] = useState(defaultIndex);
const current = total ? ((indexProp ?? indexInternal) % total + total) % total : 0;
const [direction, setDirection] = useState(1);
const go = useCallback((step: number) => {
if (total < 2) return;
const next = ((current + step) % total + total) % total;
setDirection(step > 0 ? 1 : -1);
if (indexProp === undefined) setIndexInternal(next);
onIndexChange?.(next);
}, [current, indexProp, onIndexChange, total]);
const [userPaused, setUserPaused] = useState(false);
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
const [hidden, setHidden] = useState(false);
useEffect(() => {
const read = () => setHidden(document.visibilityState === "hidden");
document.addEventListener("visibilitychange", read);
return () => document.removeEventListener("visibilitychange", read);
}, []);
const rotating = autoPlay && !reduced && total > 1;
const running = rotating && open && !userPaused && !hovered && !focused && !hidden;
/* The ring is the clock: its progress runs to one, then advances. Pausing stops it where it is; resuming finishes the rest. */
const progress = useMotionValue(0);
const dash = useTransform(progress, value => RING * (1 - value));
const goRef = useRef(go);
useEffect(() => { goRef.current = go; }, [go]);
useEffect(() => { progress.jump(0); }, [current, progress]);
useEffect(() => {
if (!running) return;
let controls: AnimationPlaybackControls | null = animate(progress, 1, {
duration: interval / 1000 * (1 - progress.get()), ease: "linear",
onComplete: () => { controls = null; goRef.current(1); },
});
return () => controls?.stop();
}, [running, current, interval, progress]);
/* The viewport springs to the height of the current message, which can wrap on narrow screens. */
const height = useMotionValue<number | "auto">("auto");
const measured = useRef(0);
const onSize = useCallback((next: number) => {
if (Math.abs(next - measured.current) < .5) return;
const first = measured.current === 0;
measured.current = next;
if (first || reduced) height.jump(next);
else animate(height, next, HEIGHT);
}, [height, reduced]);
const announcement = messages[current];
if (!announcement) return null;
const navigable = controls && total > 1;
const controlCount = (navigable ? 2 + (rotating ? 1 : 0) : 0) + (dismissible ? 1 : 0);
return <AnimatePresence initial={false}>
{open ? <motion.section key="bar" ref={ref} className={[styles.collapse, className].filter(Boolean).join(" ")} aria-label={label}
aria-roledescription={total > 1 ? "carousel" : undefined}
initial={reduced ? { opacity: 0 } : { height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }}
exit={reduced ? { opacity: 0, transition: { duration: motionTokens.duration.fast } } : { height: 0, opacity: 0, transition: { height: COLLAPSE, opacity: { duration: motionTokens.duration.exit, ease: standard } } }}
transition={reduced ? { duration: motionTokens.duration.fast } : { height: COLLAPSE, opacity: { duration: motionTokens.duration.standard, ease: enter } }}
onPointerEnter={event => { if (event.pointerType === "mouse") setHovered(true); }} onPointerLeave={() => setHovered(false)}
onFocus={() => setFocused(true)} onBlur={event => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setFocused(false); }}>
<div className={styles.bar} data-tone={tone} style={{ "--controls": controlCount } as CSSProperties}>
<div className={styles.side} aria-hidden="true" />
<motion.div className={styles.viewport} style={{ height }} id={`${uid}-slides`} aria-live={running ? "off" : "polite"} aria-atomic="false">
<AnimatePresence initial={false} custom={direction}>
<Face key={announcement.id} announcement={announcement} direction={direction} reduced={reduced} position={current + 1} total={total}
onSize={onSize} onAction={onAction} onCountdownEnd={onCountdownEnd} />
</AnimatePresence>
</motion.div>
<div className={styles.controls}>
{navigable ? <>
<button type="button" className={styles.icon} aria-label="이전 공지" aria-controls={`${uid}-slides`} onClick={() => { playUiSound("tick"); go(-1); }}><UiIcon iconRole="chevron-up" size={16} strokeWidth={1.75} aria-hidden="true" /></button>
{rotating ? <button type="button" className={styles.ring} aria-label={userPaused ? "공지 다시 넘기기" : "공지 멈추기"} aria-pressed={userPaused} onClick={() => setUserPaused(paused => !paused)}>
<svg viewBox="0 0 20 20" aria-hidden="true">
<circle cx="10" cy="10" r="8" className={styles.ringTrack} />
<motion.circle cx="10" cy="10" r="8" className={styles.ringFill} strokeDasharray={RING} style={{ strokeDashoffset: dash }} />
</svg>
<span className={styles.ringIcon}>{userPaused ? <UiIcon iconRole="play" size={9} strokeWidth={2.4} aria-hidden="true" /> : <UiIcon iconRole="pause" size={9} strokeWidth={2.4} aria-hidden="true" />}</span>
</button> : null}
<button type="button" className={styles.icon} aria-label="다음 공지" aria-controls={`${uid}-slides`} onClick={() => { playUiSound("tick"); go(1); }}><UiIcon iconRole="chevron-down" size={16} strokeWidth={1.75} aria-hidden="true" /></button>
</> : null}
{dismissible ? <button type="button" className={styles.icon} aria-label="닫기" onClick={dismiss}><UiIcon iconRole="close" size={16} strokeWidth={1.75} aria-hidden="true" /></button> : null}
</div>
</div>
</motion.section> : null}
</AnimatePresence>;
});
export default AnnouncementBar;