{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "empty-state-dashed",
  "title": "Empty State Dashed",
  "description": "Empty state whose dashed SVG boundary drifts like a plot marked out and not yet planted, and contracts toward the CTA on hover.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/empty-state-dashed/component.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useRef, useState, type ReactNode } from \"react\";\n\n// Empty state as a plot of land marked out and not yet planted. The panel's\n// boundary is a single SVG rect stroked with a dashed border-token line whose\n// stroke-dashoffset drifts on a seamless CSS loop, so the edge is alive while\n// the inside stays honestly empty — no ghost content rows. Pointing at the CTA\n// contracts the outline toward the button (transform-origin measured from the\n// button's real centre, so it works at any panel size), which makes the\n// affordance point at itself. Pure CSS + SVG: no canvas, no rAF, no timers.\n// Hover is tracked in React rather than :hover so the landing-card autoplay\n// driver's synthetic pointer events reach it. prefers-reduced-motion kills both\n// the drift and the contraction and leaves a static, readable outline.\n\nexport interface FallowPanelProps {\n  /** Headline — what is missing. */\n  title?: string;\n  /** One line of supporting copy. Keep it to one line. */\n  description?: string;\n  /** Label of the single accent CTA. Pass an empty string to omit the button. */\n  actionLabel?: string;\n  /** called when the accent CTA is pressed */\n  onAction?: () => void;\n  /** Optional mark above the headline — an icon, a glyph, anything small. */\n  icon?: ReactNode;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nexport function FallowPanel({\n  title = \"Nothing here yet\",\n  description = \"This list is empty. Add the first item and it will show up here.\",\n  actionLabel = \"Add the first item\",\n  onAction,\n  icon,\n  className = \"\",\n}: FallowPanelProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const ctaRef = useRef<HTMLButtonElement>(null);\n  const [hovered, setHovered] = useState(false);\n  const [focused, setFocused] = useState(false);\n  const near = hovered || focused;\n  // origin of the contraction, in % of the panel box — measured, not assumed,\n  // because the CTA sits wherever the copy above it pushes it\n  const [origin, setOrigin] = useState(\"50% 72%\");\n\n  const measure = useCallback(() => {\n    const root = rootRef.current;\n    const cta = ctaRef.current;\n    if (!root || !cta) return;\n    const r = root.getBoundingClientRect();\n    const c = cta.getBoundingClientRect();\n    if (!r.width || !r.height) return;\n    const x = ((c.left + c.width / 2 - r.left) / r.width) * 100;\n    const y = ((c.top + c.height / 2 - r.top) / r.height) * 100;\n    setOrigin(`${x.toFixed(2)}% ${y.toFixed(2)}%`);\n  }, []);\n\n  // the panel reflows (viewport, font load, copy change) — keep the origin true\n  useEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n    measure();\n    const ro = new ResizeObserver(measure);\n    ro.observe(root);\n    if (ctaRef.current) ro.observe(ctaRef.current);\n    return () => ro.disconnect();\n  }, [measure]);\n\n  return (\n    <div\n      ref={rootRef}\n      className={[\"relative isolate\", className].join(\" \")}\n    >\n      <style>{`@keyframes ns-fallow-drift{to{stroke-dashoffset:-36}}\n.ns-fallow-edge{stroke:var(--ns-muted);opacity:.45;transition:stroke 320ms ease-out,opacity 320ms ease-out,transform 560ms cubic-bezier(.22,1,.36,1);transform-box:fill-box;animation:ns-fallow-drift 9s linear infinite}\n.ns-fallow-edge[data-near=\"true\"]{stroke:var(--ns-accent);opacity:.85;transform:scale(.965)}\n@media (prefers-reduced-motion: reduce){.ns-fallow-edge{animation:none;transition:stroke 320ms ease-out,opacity 320ms ease-out}.ns-fallow-edge[data-near=\"true\"]{transform:none}}`}</style>\n\n      {/* the boundary. overflow-visible so the stroke straddling the box edge\n          isn't clipped in half by the SVG viewport */}\n      <svg aria-hidden className=\"pointer-events-none absolute inset-0 h-full w-full overflow-visible\">\n        <rect\n          className=\"ns-fallow-edge\"\n          data-near={near}\n          x=\"0\"\n          y=\"0\"\n          width=\"100%\"\n          height=\"100%\"\n          rx=\"12\"\n          ry=\"12\"\n          fill=\"none\"\n          strokeWidth=\"1.5\"\n          strokeDasharray=\"10 8\"\n          strokeLinecap=\"round\"\n          vectorEffect=\"non-scaling-stroke\"\n          style={{ transformOrigin: origin }}\n        />\n      </svg>\n\n      <div className=\"flex flex-col items-center px-8 py-14 text-center\">\n        {icon ? (\n          <div className=\"mb-5 flex h-11 w-11 items-center justify-center rounded-sm border border-border text-ns-muted\">\n            {icon}\n          </div>\n        ) : null}\n        <h2 className=\"text-base font-semibold tracking-tight text-foreground\">{title}</h2>\n        {description ? (\n          <p className=\"mt-2 max-w-[36ch] text-sm leading-relaxed text-ns-muted\">{description}</p>\n        ) : null}\n        {actionLabel ? (\n          <button\n            ref={ctaRef}\n            type=\"button\"\n            onClick={onAction}\n            onPointerEnter={() => setHovered(true)}\n            onPointerLeave={() => setHovered(false)}\n            onFocus={() => setFocused(true)}\n            onBlur={() => setFocused(false)}\n            className={[\n              \"ns-esd-btn mt-6 inline-flex items-center justify-center rounded-sm bg-ns-accent px-4 py-2\",\n              \"text-sm font-medium text-white\",\n              \"transition-[background-color,transform,box-shadow] duration-200 ease-out\",\n              \"hover:-translate-y-px hover:bg-ns-accent-hover hover:shadow-[0_0_0_1px_var(--ns-accent)]\",\n              \"active:translate-y-0 active:bg-ns-accent-hover\",\n              \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent\",\n            ].join(\" \")}\n          >\n            {actionLabel}\n          </button>\n        ) : null}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/empty-state-dashed.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)",
      "color-ns-accent-hover": "var(--ns-accent-hover)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff",
      "ns-accent-hover": "#0059d1"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "empty-state",
      "panel",
      "svg",
      "cta",
      "micro-interaction"
    ],
    "instruction": "An empty state for a list that has nothing in it yet: a headline, one line of muted supporting copy, one accent CTA, and an optional icon slot above the headline. The boundary is the interaction — a single SVG rect stroked with a dashed line in the muted token at low opacity, its stroke-dashoffset drifting on a seamless nine-second CSS loop so the panel reads as a plot marked out and waiting rather than a dead box. Pointing at (or keyboard-focusing) the CTA tints the outline to the accent token and contracts it toward the button on a spring-like ease, with the transform-origin measured from the button's real centre via a ResizeObserver so it stays true at any panel size or copy length. Deliberately renders no ghost or placeholder content rows — the emptiness is the message, and that silhouette belongs to a skeleton loader. CSS and SVG only: no canvas, no requestAnimationFrame, no timers, and the observer is disconnected on unmount. Colors come from --ns-muted, --ns-accent, --border and --foreground, so it reads correctly in both themes. prefers-reduced-motion leaves the outline static and un-contracting, still fully readable and usable."
  },
  "type": "registry:ui"
}