컴포넌트
알림 배너
정보, 성공, 경고, 위험 톤으로 상황을 알리고 닫을 수 있는 배너예요.
- #arc
- #component
- #message
- #status
- #feedback
- #dismissible
1280px
설치
이 명령 한 줄이면 필요한 파일과 패키지가 함께 설치돼요.
터미널터미널터미널bash
npx shadcn@latest add https://dsui.vercel.app/r/alert.json파일
components/arc/alert/alert.module.csscomponents/arc/alert/alert.tsx
npm 패키지
motion@^14.2.0
함께 오는 항목
alert.module.cssalert.module.cssalert.module.csscss
.presence { min-width: 0; }
.alert { display: flex; align-items: start; gap: var(--space-3); border: 1px solid var(--border); border-radius: var(--radius-control); padding: var(--space-4); background: var(--surface); box-shadow: var(--shadow-resting); }
.icon, .glyph { position: relative; display: grid; flex: 0 0 auto; place-items: center; }
.icon { width: 18px; height: 18px; margin-top: 1px; }
.icon svg { display: block; }
.copy { flex: 1; min-width: 0; }
.content { position: relative; }
.title { position: relative; display: block; font-size: var(--text-sm); font-weight: 500; }
.line { display: block; }
.description { position: relative; margin: var(--space-1) 0 0; color: var(--text-secondary); font-size: var(--text-xs); }
.info .icon { color: var(--accent); } .success .icon { color: var(--success); } .warning .icon { color: var(--warning); } .danger .icon { color: var(--danger); }
.dismiss { display: grid; width: 28px; height: 28px; flex: 0 0 auto; place-items: center; margin: -5px -6px -5px 0; border: 0; border-radius: var(--radius-pill); background: transparent; color: var(--text-muted); cursor: pointer; transition: transform var(--duration-spring) var(--ease-spring), background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard); }
.dismiss:active { transform: scale(.96); transition-duration: var(--duration-instant); transition-timing-function: var(--ease-standard); }
@media (hover: hover) and (pointer: fine) { .dismiss:hover { background: var(--surface-muted); color: var(--foreground); } }
@media (prefers-reduced-motion: reduce) { .dismiss { transition: none; } .dismiss:active { transform: none; } }
alert.tsxalert.tsxalert.tsxtsx
"use client";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import type { HTMLAttributes, ReactNode } from "react";
import { AnimatePresence, animate, motion, useIsPresent, useMotionValue, type AnimationPlaybackControls, type HTMLMotionProps, type MotionProps, type TargetAndTransition, type Transition } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import { motionTokens } from "../lib/motion-tokens";
import styles from "./alert.module.css";
import { UiIcon } from "../theme/ui-icon";
import { playUiSound } from "../sound/play";
export type AlertTone = "info" | "success" | "warning" | "danger";
export interface AlertProps extends HTMLAttributes<HTMLDivElement> {
tone?: AlertTone;
title: string;
children?: ReactNode;
/** Controls presence. Hiding the alert collapses its height and fades it out. */
open?: boolean;
/** Shows a dismiss button. An uncontrolled alert collapses first, then calls this. */
onDismiss?: () => void;
}
type AlertBoxProps = Omit<AlertProps, "open"> & { reduce: boolean | null; animateIcon?: boolean };
const icons = { info: "info", success: "check", warning: "warning", danger: "error" } as const;
const exitFast: Transition = { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] };
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 } };
/** Outgoing copies are hidden from assistive tech while they fade, so the live region reads only the current text. */
function Swap(props: HTMLMotionProps<"span">) {
const present = useIsPresent();
return <motion.span {...props} aria-hidden={present ? props["aria-hidden"] : true} />;
}
/** Follows its content height. After `morphKey` changes, the height springs from the old size to the new one and then returns to auto, so passive reflows (a resize, a font swap) follow instantly. It clips only while moving, so focus rings stay visible at rest. */
function HeightFrame({ className, reduce, morphKey, children }: { className?: string; reduce: boolean | null; morphKey: string; children: ReactNode }) {
const frame = useRef<HTMLDivElement>(null);
const content = useRef<HTMLDivElement>(null);
const height = 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 = () => { height.jump("auto"); if (frame.current) Object.assign(frame.current.style, { overflow: "", height: "auto" }); };
const observer = new ResizeObserver(([entry]) => {
if (!entry) return;
const next = entry.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight;
const current = height.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 height before this frame paints, then spring to the new one.
if (frame.current) Object.assign(frame.current.style, { overflow: "hidden", height: `${from}px` });
controls = animate(height, [from, next], { ...motionTokens.spring.smooth, onComplete: settle });
});
observer.observe(node);
return () => { observer.disconnect(); controls?.stop(); };
}, [height, reduce]);
return <motion.div ref={frame} className={className} style={{ height }}>
<div ref={content} className={styles.content}>{children}</div>
</motion.div>;
}
function AlertBox({ tone = "info", title, children, className, reduce, onDismiss, animateIcon = false, ...props }: AlertBoxProps) {
const icon = icons[tone];
const text = typeof children === "string" || typeof children === "number" ? String(children) : null;
const hasDetails = children !== undefined && children !== null && children !== false && children !== "";
const enter: Transition = reduce ? { duration: motionTokens.duration.instant } : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] };
const swap: MotionProps = { initial: reduce ? { opacity: 0 } : textIn, animate: shown, exit: reduce ? fadeOut : textOut, transition: enter };
return <div {...props} className={[styles.alert, styles[tone], className].filter(Boolean).join(" ")} role={tone === "danger" ? "alert" : "status"}>
{/* A new tone morphs its icon in place; a newly shown alert gives the icon a small settle. */}
<motion.span className={styles.icon} initial={animateIcon && !reduce ? iconIn : false} animate={shown} transition={motionTokens.spring.snappy}>
<AnimatePresence mode="popLayout" initial={false}><Swap key={tone} className={styles.glyph} initial={reduce ? { opacity: 0 } : iconIn} animate={shown} exit={reduce ? fadeOut : { ...iconIn, transition: exitFast }} transition={reduce ? enter : motionTokens.spring.snappy}><UiIcon iconRole={icon} width={18} height={18} aria-hidden="true"/></Swap></AnimatePresence>
</motion.span>
{/* The copy column follows its content height, so longer or shorter messages never snap the alert. */}
<HeightFrame className={styles.copy} reduce={reduce} morphKey={`${title}\n${hasDetails}\n${text ?? ""}`}>
<strong className={styles.title}><AnimatePresence mode="popLayout" initial={false}><Swap key={title} className={styles.line} {...swap}>{title}</Swap></AnimatePresence></strong>
<AnimatePresence mode="popLayout" initial={false}>{hasDetails && <motion.p key="details" className={styles.description} {...swap}>
{text === null ? children : <AnimatePresence mode="popLayout" initial={false}><Swap key={text} className={styles.line} {...swap}>{text}</Swap></AnimatePresence>}
</motion.p>}</AnimatePresence>
</HeightFrame>
{onDismiss && <button type="button" className={styles.dismiss} aria-label={`${title} 닫기`} onClick={onDismiss}><UiIcon iconRole="close" width={16} height={16} strokeWidth={1.75} aria-hidden="true"/></button>}
</div>;
}
export function Alert({ open, onDismiss, ...props }: AlertProps) {
const reduce = useReducedMotion();
const [dismissed, setDismissed] = useState(false);
const shell = useRef<HTMLDivElement>(null);
const setClip = (value: string) => { if (shell.current) shell.current.style.overflow = value; };
if (open === undefined && !onDismiss) return <AlertBox {...props} reduce={reduce}/>;
const isOpen = open ?? !dismissed;
const dismiss = onDismiss && (() => { playUiSound("close"); if (open === undefined) setDismissed(true); else onDismiss(); });
const presence: Transition = reduce ? { duration: 0 } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] } };
const exit: TargetAndTransition = { height: 0, opacity: 0, transition: reduce ? { duration: 0 } : { height: motionTokens.spring.smooth, opacity: exitFast } };
// Presence collapses the height with the fade, so the content below closes the gap instead of jumping.
return <AnimatePresence initial={false} onExitComplete={() => { if (open === undefined) onDismiss?.(); }}>
{isOpen && <motion.div key="alert" className={styles.presence} initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={exit} transition={presence} ref={shell} onAnimationStart={() => setClip("hidden")} onAnimationComplete={() => setClip("")}>
<AlertBox {...props} reduce={reduce} onDismiss={dismiss} animateIcon/>
</motion.div>}
</AnimatePresence>;
}
export default Alert;
이 항목을 쓰는 조합
- 생성형 UI컴포넌트
AI 모델이 OpenUI나 json-render로 쓴 화면을 dsui 컴포넌트로 그려 주는 컴포넌트예요.