{
  "$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://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/alert/alert.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport type { HTMLAttributes, ReactNode } from \"react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useIsPresent,\n  useMotionValue,\n  useReducedMotion,\n  type AnimationPlaybackControls,\n  type HTMLMotionProps,\n  type MotionProps,\n  type TargetAndTransition,\n  type Transition,\n} 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 { cn } from \"../lib/utils\";\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 toneIcon = { info: \"text-accent\", success: \"text-green\", warning: \"text-orange\", danger: \"text-red\" } as const;\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 = {\n  opacity: 0,\n  y: \"-0.3em\",\n  filter: `blur(${motionTokens.blur.subtle}px)`,\n  transition: exitFast,\n};\nconst iconIn: TargetAndTransition = { opacity: 0, scale: 0.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({\n  className,\n  reduce,\n  morphKey,\n  children,\n}: {\n  className?: string;\n  reduce: boolean | null;\n  morphKey: string;\n  children: ReactNode;\n}) {\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(() => {\n    changedAt.current = performance.now();\n  }, [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 = () => {\n      height.jump(\"auto\");\n      if (frame.current) Object.assign(frame.current.style, { overflow: \"\", height: \"auto\" });\n    };\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 () => {\n      observer.disconnect();\n      controls?.stop();\n    };\n  }, [height, reduce]);\n  return (\n    <motion.div ref={frame} className={className} style={{ height }}>\n      <div ref={content} className=\"relative\">\n        {children}\n      </div>\n    </motion.div>\n  );\n}\n\nfunction AlertBox({\n  tone = \"info\",\n  title,\n  children,\n  className,\n  reduce,\n  onDismiss,\n  animateIcon = false,\n  ...props\n}: 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\n    ? { duration: motionTokens.duration.instant }\n    : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] };\n  const swap: MotionProps = {\n    initial: reduce ? { opacity: 0 } : textIn,\n    animate: shown,\n    exit: reduce ? fadeOut : textOut,\n    transition: enter,\n  };\n  return (\n    <div\n      {...props}\n      className={cn(\n        \"flex items-start gap-3 rounded-control border border-line bg-surface p-4 shadow-resting\",\n        className,\n      )}\n      role={tone === \"danger\" ? \"alert\" : \"status\"}\n    >\n      {/* A new tone morphs its icon in place; a newly shown alert gives the icon a small settle. */}\n      <motion.span\n        className={cn(\n          \"relative mt-px grid size-[18px] flex-[0_0_auto] place-items-center [&_svg]:block\",\n          toneIcon[tone],\n        )}\n        initial={animateIcon && !reduce ? iconIn : false}\n        animate={shown}\n        transition={motionTokens.spring.snappy}\n      >\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          <Swap\n            key={tone}\n            className=\"relative grid flex-[0_0_auto] place-items-center\"\n            initial={reduce ? { opacity: 0 } : iconIn}\n            animate={shown}\n            exit={reduce ? fadeOut : { ...iconIn, transition: exitFast }}\n            transition={reduce ? enter : motionTokens.spring.snappy}\n          >\n            <Icon width={18} height={18} aria-hidden=\"true\" />\n          </Swap>\n        </AnimatePresence>\n      </motion.span>\n      {/* The copy column follows its content height, so longer or shorter messages never snap the alert. */}\n      <HeightFrame className=\"min-w-0 flex-1\" reduce={reduce} morphKey={`${title}\\n${hasDetails}\\n${text ?? \"\"}`}>\n        <strong className=\"relative block text-(length:--text-sm) font-medium\">\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            <Swap key={title} className=\"block\" {...swap}>\n              {title}\n            </Swap>\n          </AnimatePresence>\n        </strong>\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          {hasDetails && (\n            <motion.p key=\"details\" className=\"relative mx-0 mt-1 mb-0 text-(length:--text-xs) text-ink-2\" {...swap}>\n              {text === null ? (\n                children\n              ) : (\n                <AnimatePresence mode=\"popLayout\" initial={false}>\n                  <Swap key={text} className=\"block\" {...swap}>\n                    {text}\n                  </Swap>\n                </AnimatePresence>\n              )}\n            </motion.p>\n          )}\n        </AnimatePresence>\n      </HeightFrame>\n      {onDismiss && (\n        <button\n          type=\"button\"\n          className=\"-my-[5px] mr-[-6px] ml-0 grid size-7 flex-[0_0_auto] cursor-pointer place-items-center rounded-button border-0 bg-transparent text-ink-3 transition-[scale,background-color,color] duration-[var(--duration-spring),var(--duration-fast),var(--duration-fast)] ease-[var(--ease-spring),var(--ease-standard),var(--ease-standard)] hover:bg-hover hover:text-ink active:scale-96 active:duration-instant active:ease-standard motion-reduce:transition-none motion-reduce:active:scale-100\"\n          aria-label={`Dismiss: ${title}`}\n          onClick={onDismiss}\n        >\n          <X width={16} height={16} strokeWidth={1.75} aria-hidden=\"true\" />\n        </button>\n      )}\n    </div>\n  );\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) => {\n    if (shell.current) shell.current.style.overflow = value;\n  };\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\n    ? { duration: 0 }\n    : {\n        height: motionTokens.spring.smooth,\n        opacity: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] },\n      };\n  const exit: TargetAndTransition = {\n    height: 0,\n    opacity: 0,\n    transition: reduce ? { duration: 0 } : { height: motionTokens.spring.smooth, opacity: exitFast },\n  };\n  // Presence collapses the height with the fade, so the content below closes the gap instead of jumping.\n  return (\n    <AnimatePresence\n      initial={false}\n      onExitComplete={() => {\n        if (open === undefined) onDismiss?.();\n      }}\n    >\n      {isOpen && (\n        <motion.div\n          key=\"alert\"\n          className=\"min-w-0\"\n          initial={{ height: 0, opacity: 0 }}\n          animate={{ height: \"auto\", opacity: 1 }}\n          exit={exit}\n          transition={presence}\n          ref={shell}\n          onAnimationStart={() => setClip(\"hidden\")}\n          onAnimationComplete={() => setClip(\"\")}\n        >\n          <AlertBox {...props} reduce={reduce} onDismiss={dismiss} animateIcon />\n        </motion.div>\n      )}\n    </AnimatePresence>\n  );\n}\n\nexport default Alert;\n",
      "type": "registry:component",
      "target": "@components/iris/alert/alert.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/alert",
  "meta": {
    "kind": "component",
    "group": "Feedback",
    "export": "Alert",
    "tags": [
      "status",
      "message"
    ],
    "docs": "https://irisui.dev/components/alert"
  }
}
