{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "empty-state",
  "type": "registry:ui",
  "title": "Empty state",
  "description": "A useful next step when there is nothing to show yet.",
  "categories": [
    "data"
  ],
  "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/empty-state/empty-state.tsx",
      "content": "\"use client\";\n\nimport { isValidElement, useEffect, useLayoutEffect, useRef, type 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 { Folder } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\nexport interface EmptyStateProps {\n  title: string;\n  description: string;\n  action?: ReactNode;\n  icon?: ReactNode;\n  className?: string;\n  /** Optional accessible label for the state region. */\n  label?: string;\n}\n\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. */\nfunction Swap(props: HTMLMotionProps<\"span\">) {\n  const present = useIsPresent();\n  return <motion.span {...props} aria-hidden={present ? props[\"aria-hidden\"] : true} />;\n}\n\n/** A new icon component crossfades in; re-rendering the same icon stays still. */\nfunction iconKey(icon: ReactNode) {\n  if (!isValidElement(icon)) return \"icon\";\n  const type = icon.type as string | { displayName?: string; name?: string };\n  return typeof type === \"string\" ? type : (type.displayName ?? type.name ?? \"icon\");\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  reduce,\n  morphKey,\n  children,\n}: {\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=\"w-full max-w-full\" style={{ height }}>\n      <div ref={content} className=\"flow-root\">\n        {children}\n      </div>\n    </motion.div>\n  );\n}\n\nexport function EmptyState({ title, description, action, icon, className, label }: EmptyStateProps) {\n  const reduce = useReducedMotion();\n  const glyph = icon ?? <Folder width={24} height={24} strokeWidth={1.5} />;\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  // The icon settles in once when the state first appears. The result of an action morphs in place: the icon crossfades and the copy rises in while the old copy leaves.\n  // Centered copy wraps into balanced lines, so a new description never leaves a single word hanging.\n  return (\n    <section\n      className={cn(\n        \"flex w-full flex-col items-center px-5 py-[clamp(var(--space-8),8vw,var(--space-12))] text-center\",\n        className,\n      )}\n      aria-label={label}\n    >\n      <div\n        className=\"relative grid size-12 animate-empty-state-icon place-items-center rounded-panel border border-line bg-hover text-ink-2 motion-reduce:animate-none\"\n        aria-hidden=\"true\"\n      >\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          <Swap\n            key={iconKey(glyph)}\n            className=\"grid 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            {glyph}\n          </Swap>\n        </AnimatePresence>\n      </div>\n      <HeightFrame reduce={reduce} morphKey={`${title}\\n${description}`}>\n        <h3 className=\"relative mt-5 text-base leading-body font-medium text-ink\">\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            <Swap key={title} className=\"block text-balance\" {...swap}>\n              {title}\n            </Swap>\n          </AnimatePresence>\n        </h3>\n        <p className=\"relative mx-auto mt-2 max-w-[18rem] text-sm leading-body text-ink-2\">\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            <Swap key={description} className=\"block text-balance\" {...swap}>\n              {description}\n            </Swap>\n          </AnimatePresence>\n        </p>\n      </HeightFrame>\n      {action && <div className=\"mt-5 flex flex-wrap justify-center gap-3\">{action}</div>}\n    </section>\n  );\n}\n\nexport default EmptyState;\n",
      "type": "registry:component",
      "target": "@components/iris/empty-state/empty-state.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/empty-state",
  "meta": {
    "kind": "component",
    "group": "Data display",
    "export": "EmptyState",
    "tags": [
      "data",
      "guidance"
    ],
    "docs": "https://irisui.dev/components/empty-state"
  }
}
