{
  "$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://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/empty-state/empty-state.tsx",
      "type": "registry:component",
      "target": "@components/xzero/empty-state/empty-state.tsx",
      "content": "\"use client\";\n\nimport { isValidElement, useEffect, useLayoutEffect, useRef, type ReactNode } from \"react\";\nimport { AnimatePresence, animate, motion, useIsPresent, useMotionValue, useReducedMotion, type AnimationPlaybackControls, type HTMLMotionProps, type MotionProps, type TargetAndTransition, type Transition } from \"motion/react\";\nimport { Folder } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./empty-state.module.css\";\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 = { 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. */\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({ reduce, morphKey, children }: { 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={styles.frame} style={{ height }}>\n    <div ref={content} className={styles.copy}>{children}</div>\n  </motion.div>;\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 ? { 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  // The result of an action morphs in place: the icon crossfades and the copy rises in while the old copy leaves.\n  return <section className={[styles.root, className].filter(Boolean).join(\" \")} aria-label={label}>\n    <div className={styles.icon} aria-hidden=\"true\"><AnimatePresence mode=\"popLayout\" initial={false}><Swap key={iconKey(glyph)} className={styles.glyph} initial={reduce ? { opacity: 0 } : iconIn} animate={shown} exit={reduce ? fadeOut : { ...iconIn, transition: exitFast }} transition={reduce ? enter : motionTokens.spring.snappy}>{glyph}</Swap></AnimatePresence></div>\n    <HeightFrame reduce={reduce} morphKey={`${title}\\n${description}`}>\n      <h3><AnimatePresence mode=\"popLayout\" initial={false}><Swap key={title} className={styles.line} {...swap}>{title}</Swap></AnimatePresence></h3>\n      <p><AnimatePresence mode=\"popLayout\" initial={false}><Swap key={description} className={styles.line} {...swap}>{description}</Swap></AnimatePresence></p>\n    </HeightFrame>\n    {action && <div className={styles.action}>{action}</div>}\n  </section>;\n}\n\nexport default EmptyState;\n"
    },
    {
      "path": "registry/components/empty-state/empty-state.module.css",
      "type": "registry:component",
      "target": "@components/xzero/empty-state/empty-state.module.css",
      "content": ".root { display: flex; width: 100%; flex-direction: column; align-items: center; padding: clamp(var(--space-8), 8vw, var(--space-12)) var(--space-5); text-align: center; }\n.icon { position: relative; display: grid; width: var(--space-12); height: var(--space-12); place-items: center; border: 1px solid var(--border); border-radius: var(--radius-panel); background: var(--surface-muted); color: var(--text-secondary); animation: empty-state-icon var(--duration-considered) var(--ease-enter) both; }\n.glyph { display: grid; place-items: center; }\n.frame { width: 100%; max-width: 100%; }\n.copy { display: flow-root; }\n.root h3 { position: relative; margin: var(--space-5) 0 0; color: var(--foreground); font-size: var(--text-base); font-weight: 500; line-height: var(--leading-body); }\n.root p { position: relative; max-width: 18rem; margin: var(--space-2) auto 0; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); }\n/* Centered copy wraps into even lines, so a new description never leaves a single word hanging. */\n.line { display: block; text-wrap: balance; }\n.action { display: flex; flex-wrap: wrap; justify-content: center; gap: var(--space-3); margin-top: var(--space-5); }\n/* The icon settles in once when the state first appears; later changes crossfade in place. */\n@keyframes empty-state-icon { from { opacity: 0; transform: scale(.92); } to { opacity: 1; transform: scale(1); } }\n@media (prefers-reduced-motion: reduce) { .icon { animation: none; } }\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/empty-state",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "data",
      "guidance"
    ],
    "docs": "https://ui.xzero.co.uk/components/empty-state",
    "markdown": "https://ui.xzero.co.uk/components/empty-state/markdown"
  }
}