{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "empty-state",
  "title": "Empty State",
  "author": "ilinxa",
  "description": "The designed answer for empty surfaces — icon or illustration, title, description, capability-gated actions, and a hint, across 6 variants and 3 sizes.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "button"
  ],
  "files": [
    {
      "path": "src/registry/components/feedback/empty-state/empty-state.tsx",
      "content": "\"use client\";\n\nimport { isValidElement, type ReactNode } from \"react\";\nimport {\n  Inbox,\n  Lock,\n  SearchX,\n  Sparkles,\n  TriangleAlert,\n  WifiOff,\n} from \"lucide-react\";\nimport { Button, buttonVariants } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\nimport type {\n  EmptyStateAction,\n  EmptyStateActionConfig,\n  EmptyStateProps,\n  EmptyStateSize,\n  EmptyStateVariant,\n} from \"./types\";\n\n/** Per-variant default lucide icon (used when `icon`/`media` are both omitted). */\nconst VARIANT_ICON: Record<EmptyStateVariant, typeof Inbox> = {\n  default: Inbox,\n  search: SearchX,\n  error: TriangleAlert,\n  offline: WifiOff,\n  permission: Lock,\n  \"first-use\": Sparkles,\n};\n\n/** Variants that render `role=\"status\" aria-live=\"polite\"` — transient, state-change-driven (I4). */\nconst TRANSIENT_VARIANTS: ReadonlySet<EmptyStateVariant> = new Set([\n  \"search\",\n  \"error\",\n  \"offline\",\n]);\n\nconst SIZE_ROOT_CLASS: Record<EmptyStateSize, string> = {\n  sm: \"max-w-sm py-6\",\n  md: \"max-w-md py-10\",\n  lg: \"max-w-lg py-16\",\n};\n\nconst SIZE_TILE_CLASS: Record<EmptyStateSize, string> = {\n  sm: \"size-12 rounded-xl\",\n  md: \"size-14 rounded-2xl\",\n  lg: \"size-16 rounded-2xl\",\n};\n\nconst SIZE_ICON_CLASS: Record<EmptyStateSize, string> = {\n  sm: \"size-5\",\n  md: \"size-6\",\n  lg: \"size-7\",\n};\n\nconst SIZE_TITLE_CLASS: Record<EmptyStateSize, string> = {\n  sm: \"text-sm\",\n  md: \"text-base\",\n  lg: \"text-xl\",\n};\n\nconst SIZE_DESCRIPTION_CLASS: Record<EmptyStateSize, string> = {\n  sm: \"text-xs\",\n  md: \"text-sm\",\n  lg: \"text-base\",\n};\n\n/** 60ms-stagger entrance reveal (tw-animate-css). Never the app-level `reveal-up` keyframe — P-F5. */\nconst REVEAL = \"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-2\";\n\nfunction isActionConfig(action: EmptyStateAction): action is EmptyStateActionConfig {\n  return (\n    typeof action === \"object\" &&\n    action !== null &&\n    !isValidElement(action) &&\n    \"label\" in action\n  );\n}\n\nfunction renderAction(action: EmptyStateAction | undefined, kind: \"primary\" | \"secondary\") {\n  // Also swallow `false`/`true`/`\"\"` so the conditional-render idiom\n  // `action={isAdmin && {…}}` doesn't mount an empty actions row (C5 F2).\n  if (action == null || typeof action === \"boolean\" || action === \"\") return null;\n\n  if (!isActionConfig(action)) {\n    return action as ReactNode;\n  }\n\n  const { label, onClick, href, disabled } = action;\n  const variant = kind === \"primary\" ? \"default\" : \"outline\";\n\n  if (href) {\n    // F-cross-13: no `asChild` — the anchor IS the styled surface via buttonVariants.\n    return (\n      <a\n        href={disabled ? undefined : href}\n        onClick={disabled ? (event) => event.preventDefault() : onClick}\n        aria-disabled={disabled || undefined}\n        tabIndex={disabled ? -1 : undefined}\n        className={cn(buttonVariants({ variant }), disabled && \"pointer-events-none opacity-50\")}\n      >\n        {label}\n      </a>\n    );\n  }\n\n  return (\n    <Button type=\"button\" variant={variant} onClick={onClick} disabled={disabled}>\n      {label}\n    </Button>\n  );\n}\n\n/**\n * EmptyState — the designed answer for any surface that can be empty: no\n * data yet, no search results, a failed fetch, offline, a permission wall,\n * or first-use onboarding. Icon-or-illustration slot, title, description,\n * up to two capability-gated actions, and an optional footer hint.\n *\n * Host owns all state — this renders the answer, it never fetches or\n * detects emptiness itself. Omit `action`/`secondaryAction` and the actions\n * row (and every `<button>`/`<a>`) is absent from the DOM (I2) — a\n * read-only empty state falls out for free.\n */\nexport function EmptyState(props: EmptyStateProps) {\n  const {\n    variant = \"default\",\n    size = \"md\",\n    icon,\n    media,\n    title,\n    description,\n    action,\n    secondaryAction,\n    hint,\n    headingLevel = 3,\n    animated = true,\n    frame = \"none\",\n    className,\n  } = props;\n\n  const HeadingTag = `h${headingLevel}` as \"h1\" | \"h2\" | \"h3\" | \"h4\" | \"h5\" | \"h6\";\n  const isTransient = TRANSIENT_VARIANTS.has(variant);\n  const isFirstUse = variant === \"first-use\";\n  const isError = variant === \"error\";\n  const reveal = animated ? REVEAL : \"\";\n  // fill-mode \"both\" is load-bearing: tw-animate-css's `animate-in` defaults\n  // animation-fill-mode to none, so a delay-staggered element would render\n  // visible during its delay, snap to opacity 0, then animate back in (flash).\n  const delayStyle = (ms: number) =>\n    animated ? { animationDelay: `${ms}ms`, animationFillMode: \"both\" as const } : undefined;\n\n  const Icon = VARIANT_ICON[variant];\n  const primaryAction = renderAction(action, \"primary\");\n  const secondaryActionNode = renderAction(secondaryAction, \"secondary\");\n  const hasActions = primaryAction !== null || secondaryActionNode !== null;\n\n  return (\n    <div\n      role={isTransient ? \"status\" : undefined}\n      aria-live={isTransient ? \"polite\" : undefined}\n      className={cn(\n        \"mx-auto flex flex-col items-center px-4 text-center\",\n        SIZE_ROOT_CLASS[size],\n        frame === \"dashed\" && \"rounded-2xl border border-dashed border-border\",\n        frame === \"card\" && \"rounded-2xl border border-border bg-card text-card-foreground shadow-sm\",\n        className,\n      )}\n    >\n      {media ? (\n        // No aria-hidden here: `media` is consumer content — the host's own\n        // alt/aria attributes on the node decide its AT semantics (C5 F3).\n        <div className={cn(\"mb-4\", reveal)} style={delayStyle(0)}>\n          {media}\n        </div>\n      ) : (\n        <div\n          className={cn(\"relative mb-4 flex items-center justify-center\", reveal)}\n          style={delayStyle(0)}\n          aria-hidden=\"true\"\n        >\n          {isFirstUse ? (\n            <span\n              className={cn(\n                \"absolute -inset-1.5 rounded-2xl border-2 border-dashed border-primary/30\",\n                animated && \"motion-safe:animate-spin\",\n              )}\n              style={animated ? { animationDuration: \"8s\" } : undefined}\n            />\n          ) : null}\n          <span\n            className={cn(\n              \"absolute inset-0 rounded-2xl blur-lg\",\n              (variant === \"default\" || isFirstUse) && \"bg-primary/12\",\n            )}\n          />\n          <span\n            className={cn(\n              \"relative flex items-center justify-center rounded-2xl bg-muted text-muted-foreground ring-1 ring-border\",\n              SIZE_TILE_CLASS[size],\n              isError && \"text-destructive\",\n            )}\n          >\n            {icon ?? <Icon className={SIZE_ICON_CLASS[size]} />}\n          </span>\n        </div>\n      )}\n\n      <HeadingTag\n        className={cn(\n          \"text-balance font-semibold text-foreground\",\n          SIZE_TITLE_CLASS[size],\n          reveal,\n        )}\n        style={delayStyle(60)}\n      >\n        {title}\n      </HeadingTag>\n\n      {description ? (\n        <p\n          className={cn(\n            \"mt-1.5 max-w-prose text-pretty text-muted-foreground\",\n            SIZE_DESCRIPTION_CLASS[size],\n            reveal,\n          )}\n          style={delayStyle(120)}\n        >\n          {description}\n        </p>\n      ) : null}\n\n      {hasActions ? (\n        <div\n          className={cn(\"mt-5 flex flex-wrap items-center justify-center gap-2\", reveal)}\n          style={delayStyle(180)}\n        >\n          {primaryAction}\n          {secondaryActionNode}\n        </div>\n      ) : null}\n\n      {hint ? (\n        <div\n          className={cn(\"mt-4 text-xs text-muted-foreground/80\", reveal)}\n          style={delayStyle(240)}\n        >\n          {hint}\n        </div>\n      ) : null}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/empty-state/empty-state.tsx"
    },
    {
      "path": "src/registry/components/feedback/empty-state/types.ts",
      "content": "import type { ReactNode } from \"react\";\n\n/** Semantic tone. Drives default icon + `role=\"status\"` gating (I4). */\nexport type EmptyStateVariant =\n  | \"default\"\n  | \"search\"\n  | \"error\"\n  | \"offline\"\n  | \"permission\"\n  | \"first-use\";\n\n/** Controls type scale, icon-tile scale, and vertical padding. */\nexport type EmptyStateSize = \"sm\" | \"md\" | \"lg\";\n\n/** Object form of an action — renders a real `<Button>` (or `<a>` when `href` is set). */\nexport interface EmptyStateActionConfig {\n  label: string;\n  onClick?: () => void;\n  /** Renders `<a className={cn(buttonVariants(...))}>` — never `asChild` (F-cross-13). */\n  href?: string;\n  disabled?: boolean;\n}\n\n/** Object form for the terse 90% case, or a ReactNode escape hatch for full control. */\nexport type EmptyStateAction = EmptyStateActionConfig | ReactNode;\n\nexport interface EmptyStateProps {\n  /** Semantic tone; changes default icon + transient-status ARIA gating. Default: `\"default\"`. */\n  variant?: EmptyStateVariant;\n  /** Type scale / icon scale / padding. Default: `\"md\"`. */\n  size?: EmptyStateSize;\n  /** Overrides the per-variant default lucide icon. Ignored when `media` is set (I3). */\n  icon?: ReactNode;\n  /** Illustration slot. When set, the icon tile + bloom are omitted entirely (I3). */\n  media?: ReactNode;\n  /** Required. ReactNode so hosts can mix markup / i18n components. */\n  title: ReactNode;\n  description?: ReactNode;\n  /** Primary action. Object form → `<Button>`; ReactNode → rendered as-is. */\n  action?: EmptyStateAction;\n  /** Secondary action. Object form → `<Button variant=\"outline\">`. */\n  secondaryAction?: EmptyStateAction;\n  /** Footer micro-copy row (e.g. a keyboard-shortcut hint). */\n  hint?: ReactNode;\n  /** Semantic heading level for `title`. Default: `3` (renders `<h3>`). */\n  headingLevel?: 1 | 2 | 3 | 4 | 5 | 6;\n  /** Entrance reveal on mount. Default: `true`. `false` renders SSR-stable with no reveal classes. */\n  animated?: boolean;\n  /** Root surface treatment. `\"dashed\"` = dropzone-style border; `\"card\"` = raised `bg-card`. Default: `\"none\"`. */\n  frame?: \"none\" | \"dashed\" | \"card\";\n  className?: string;\n}\n",
      "type": "registry:component",
      "target": "components/empty-state/types.ts"
    },
    {
      "path": "src/registry/components/feedback/empty-state/index.ts",
      "content": "export { EmptyState } from \"./empty-state\";\nexport type {\n  EmptyStateAction,\n  EmptyStateActionConfig,\n  EmptyStateProps,\n  EmptyStateSize,\n  EmptyStateVariant,\n} from \"./types\";\n",
      "type": "registry:component",
      "target": "components/empty-state/index.ts"
    }
  ],
  "categories": [
    "feedback"
  ],
  "type": "registry:block"
}