{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "alert",
  "type": "registry:ui",
  "title": "Alert",
  "description": "A persistent message that helps people recover or continue.",
  "categories": [
    "feedback"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/alert/alert.tsx",
      "type": "registry:component",
      "target": "@components/xzero/alert/alert.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport type { HTMLAttributes, ReactNode } from \"react\";\nimport { AnimatePresence, animate, motion, useIsPresent, useMotionValue, useReducedMotion, type AnimationPlaybackControls, type HTMLMotionProps, type MotionProps, type TargetAndTransition, type Transition } from \"motion/react\";\nimport { Check, Info as InfoCircle, TriangleAlert as WarningTriangle, CircleX as XmarkCircle, X } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./alert.module.css\";\n\nexport type AlertTone = \"info\" | \"success\" | \"warning\" | \"danger\";\nexport interface AlertProps extends HTMLAttributes<HTMLDivElement> {\n  tone?: AlertTone;\n  title: string;\n  children?: ReactNode;\n  /** Controls presence. Hiding the alert collapses its height and fades it out. */\n  open?: boolean;\n  /** Shows a dismiss button. An uncontrolled alert collapses first, then calls this. */\n  onDismiss?: () => void;\n}\ntype AlertBoxProps = Omit<AlertProps, \"open\"> & { reduce: boolean | null; animateIcon?: boolean };\n\nconst icons = { info: InfoCircle, success: Check, warning: WarningTriangle, danger: XmarkCircle };\nconst exitFast: Transition = { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] };\nconst textIn: TargetAndTransition = { opacity: 0, y: \"0.3em\", filter: `blur(${motionTokens.blur.soft}px)` };\nconst textOut: TargetAndTransition = { opacity: 0, y: \"-0.3em\", filter: `blur(${motionTokens.blur.subtle}px)`, transition: exitFast };\nconst iconIn: TargetAndTransition = { opacity: 0, scale: .6, filter: `blur(${motionTokens.blur.subtle}px)` };\nconst shown: TargetAndTransition = { opacity: 1, y: \"0em\", scale: 1, filter: \"blur(0px)\" };\nconst fadeOut: TargetAndTransition = { opacity: 0, transition: { duration: motionTokens.duration.instant } };\n\n/** Outgoing copies are hidden from assistive tech while they fade, so the live region reads only the current text. */\nfunction Swap(props: HTMLMotionProps<\"span\">) {\n  const present = useIsPresent();\n  return <motion.span {...props} aria-hidden={present ? props[\"aria-hidden\"] : true} />;\n}\n\n/** 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. */\nfunction HeightFrame({ className, reduce, morphKey, children }: { className?: string; reduce: boolean | null; morphKey: string; children: ReactNode }) {\n  const frame = useRef<HTMLDivElement>(null);\n  const content = useRef<HTMLDivElement>(null);\n  const height = useMotionValue<number | \"auto\">(\"auto\");\n  const changedAt = useRef(0);\n  useLayoutEffect(() => { changedAt.current = performance.now(); }, [morphKey]);\n  useEffect(() => {\n    const node = content.current;\n    if (!node || typeof ResizeObserver === \"undefined\") return;\n    let last: number | undefined;\n    let controls: AnimationPlaybackControls | undefined;\n    const settle = () => { height.jump(\"auto\"); if (frame.current) Object.assign(frame.current.style, { overflow: \"\", height: \"auto\" }); };\n    const observer = new ResizeObserver(([entry]) => {\n      if (!entry) return;\n      const next = entry.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight;\n      const current = height.get();\n      const from = typeof current === \"number\" ? current : last;\n      last = next;\n      controls?.stop();\n      if (reduce || from === undefined || from === next || performance.now() - changedAt.current > 120) return settle();\n      // Pin the old height before this frame paints, then spring to the new one.\n      if (frame.current) Object.assign(frame.current.style, { overflow: \"hidden\", height: `${from}px` });\n      controls = animate(height, [from, next], { ...motionTokens.spring.smooth, onComplete: settle });\n    });\n    observer.observe(node);\n    return () => { observer.disconnect(); controls?.stop(); };\n  }, [height, reduce]);\n  return <motion.div ref={frame} className={className} style={{ height }}>\n    <div ref={content} className={styles.content}>{children}</div>\n  </motion.div>;\n}\n\nfunction AlertBox({ tone = \"info\", title, children, className, reduce, onDismiss, animateIcon = false, ...props }: AlertBoxProps) {\n  const Icon = icons[tone];\n  const text = typeof children === \"string\" || typeof children === \"number\" ? String(children) : null;\n  const hasDetails = children !== undefined && children !== null && children !== false && children !== \"\";\n  const enter: Transition = reduce ? { duration: motionTokens.duration.instant } : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] };\n  const swap: MotionProps = { initial: reduce ? { opacity: 0 } : textIn, animate: shown, exit: reduce ? fadeOut : textOut, transition: enter };\n  return <div {...props} className={[styles.alert, styles[tone], className].filter(Boolean).join(\" \")} role={tone === \"danger\" ? \"alert\" : \"status\"}>\n    {/* A new tone morphs its icon in place; a newly shown alert gives the icon a small settle. */}\n    <motion.span className={styles.icon} initial={animateIcon && !reduce ? iconIn : false} animate={shown} transition={motionTokens.spring.snappy}>\n      <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}><Icon width={18} height={18} aria-hidden=\"true\"/></Swap></AnimatePresence>\n    </motion.span>\n    {/* The copy column follows its content height, so longer or shorter messages never snap the alert. */}\n    <HeightFrame className={styles.copy} reduce={reduce} morphKey={`${title}\\n${hasDetails}\\n${text ?? \"\"}`}>\n      <strong className={styles.title}><AnimatePresence mode=\"popLayout\" initial={false}><Swap key={title} className={styles.line} {...swap}>{title}</Swap></AnimatePresence></strong>\n      <AnimatePresence mode=\"popLayout\" initial={false}>{hasDetails && <motion.p key=\"details\" className={styles.description} {...swap}>\n        {text === null ? children : <AnimatePresence mode=\"popLayout\" initial={false}><Swap key={text} className={styles.line} {...swap}>{text}</Swap></AnimatePresence>}\n      </motion.p>}</AnimatePresence>\n    </HeightFrame>\n    {onDismiss && <button type=\"button\" className={styles.dismiss} aria-label={`Dismiss: ${title}`} onClick={onDismiss}><X width={16} height={16} strokeWidth={1.75} aria-hidden=\"true\"/></button>}\n  </div>;\n}\n\nexport function Alert({ open, onDismiss, ...props }: AlertProps) {\n  const reduce = useReducedMotion();\n  const [dismissed, setDismissed] = useState(false);\n  const shell = useRef<HTMLDivElement>(null);\n  const setClip = (value: string) => { if (shell.current) shell.current.style.overflow = value; };\n  if (open === undefined && !onDismiss) return <AlertBox {...props} reduce={reduce}/>;\n  const isOpen = open ?? !dismissed;\n  const dismiss = onDismiss && (() => open === undefined ? setDismissed(true) : onDismiss());\n  const presence: Transition = reduce ? { duration: 0 } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] } };\n  const exit: TargetAndTransition = { height: 0, opacity: 0, transition: reduce ? { duration: 0 } : { height: motionTokens.spring.smooth, opacity: exitFast } };\n  // Presence collapses the height with the fade, so the content below closes the gap instead of jumping.\n  return <AnimatePresence initial={false} onExitComplete={() => { if (open === undefined) onDismiss?.(); }}>\n    {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(\"\")}>\n      <AlertBox {...props} reduce={reduce} onDismiss={dismiss} animateIcon/>\n    </motion.div>}\n  </AnimatePresence>;\n}\n\nexport default Alert;\n"
    },
    {
      "path": "registry/components/alert/alert.module.css",
      "type": "registry:component",
      "target": "@components/xzero/alert/alert.module.css",
      "content": ".presence { min-width: 0; }\n.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); }\n.icon, .glyph { position: relative; display: grid; flex: 0 0 auto; place-items: center; }\n.icon { width: 18px; height: 18px; margin-top: 1px; }\n.icon svg { display: block; }\n.copy { flex: 1; min-width: 0; }\n.content { position: relative; }\n.title { position: relative; display: block; font-size: var(--text-sm); font-weight: 500; }\n.line { display: block; }\n.description { position: relative; margin: var(--space-1) 0 0; color: var(--text-secondary); font-size: var(--text-xs); }\n.info .icon { color: var(--accent); } .success .icon { color: var(--success); } .warning .icon { color: var(--warning); } .danger .icon { color: var(--danger); }\n.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); }\n.dismiss:active { transform: scale(.96); transition-duration: var(--duration-instant); transition-timing-function: var(--ease-standard); }\n@media (hover: hover) and (pointer: fine) { .dismiss:hover { background: var(--surface-muted); color: var(--foreground); } }\n@media (prefers-reduced-motion: reduce) { .dismiss { transition: none; } .dismiss:active { transform: none; } }\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/alert",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "status",
      "message"
    ],
    "docs": "https://ui.xzero.co.uk/components/alert",
    "markdown": "https://ui.xzero.co.uk/components/alert/markdown"
  }
}