{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"empty-state","type":"registry:component","title":"Empty state","description":"What a list says before there is anything in it: an isometric line drawing in your theme that draws itself, changes with the state and acts out the action.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/status-button.json?as=dependency","https://hairlineui.com/r/text-morph.json?as=dependency"],"files":[{"path":"registry/empty-state.tsx","type":"registry:component","content":"// Hairline UI · empty-state · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { StatusButton, type ActionStatus } from \"./status-button\";\nimport { TextMorph } from \"./text-morph\";\nimport { EASE_OUT, EASE_SETTLE, INTERACTION_FOCUS, INTERACTION_PRESS, useInteractionReducedMotion } from \"./ui-interaction-motion\";\nimport { SCENES, TILE, VIEW, poseOffset, type EmptyStateScene, type SceneFill, type SceneShape, type SceneStroke } from \"./empty-state-scenes\";\n\nexport type { EmptyStateScene } from \"./empty-state-scenes\";\n\n/* ─────────────────────────────────────────────────────────\n * EMPTY STATE: what a list says before there's anything in it\n *\n *   drawing   an isometric line drawing built in code on one tile,\n *             in the theme's own colours: it draws itself the first\n *             time it's seen, and when the state changes (nothing\n *             yet → no results → all done) the old object un-draws\n *             and the new one rises from the same tile\n *   action    the main action is a Status button, and the drawing\n *             acts it out: the first sheet lands in the tray, the\n *             plug goes into its socket, the lens sweeps the cards;\n *             if it throws, the piece springs back with a shake and\n *             the reason folds in under the buttons\n *   text      the title morphs letter by letter, the description\n *             cross-fades, and the height eases between them\n * ───────────────────────────────────────────────────────── */\n\nexport interface EmptyStateAction {\n  label: string;\n  /** Runs the action. Return a promise to show it running, then done; throw to say why it failed. */\n  onAction?: () => unknown;\n  /** Labels while it runs, once it worked and after it failed (Working, Done, Try again). */\n  pendingLabel?: string;\n  successLabel?: string;\n  errorLabel?: string;\n  /** Shown before the label while idle. */\n  icon?: React.ReactNode;\n}\n\nexport interface EmptyStateSecondaryAction {\n  label: string;\n  onAction?: () => void;\n  /** A link instead of a button. */\n  href?: string;\n}\n\nexport interface EmptyStateProps {\n  /** Which drawing: nothing yet (inbox), no results (search), all done, can't connect (offline) or no data (chart). */\n  scene?: EmptyStateScene;\n  /** Your own art in place of the drawing. */\n  illustration?: React.ReactNode;\n  title: string;\n  description?: React.ReactNode;\n  /** The main action, run as a Status button; or your own button. */\n  action?: EmptyStateAction | React.ReactNode;\n  /** A quieter second action, such as Clear filters or Learn more. */\n  secondaryAction?: EmptyStateSecondaryAction;\n  /** Anything under the text, such as suggested searches. */\n  children?: React.ReactNode;\n  className?: string;\n}\n\nconst FILL: Record<SceneFill, string> = {\n  top: \"color-mix(in oklab, var(--foreground) 3%, var(--background))\",\n  left: \"color-mix(in oklab, var(--foreground) 7%, var(--background))\",\n  right: \"color-mix(in oklab, var(--foreground) 12%, var(--background))\",\n  hole: \"color-mix(in oklab, var(--foreground) 16%, var(--background))\",\n  glass: \"color-mix(in oklab, var(--primary) 7%, var(--background))\",\n  accent: \"color-mix(in oklab, var(--primary) 16%, var(--background))\",\n  \"accent-side\": \"color-mix(in oklab, var(--primary) 32%, var(--background))\",\n  none: \"none\",\n};\nconst STROKE: Record<SceneStroke, string> = {\n  line: \"color-mix(in oklab, var(--foreground) 46%, var(--background))\",\n  faint: \"color-mix(in oklab, var(--foreground) 20%, var(--background))\",\n  accent: \"var(--primary)\",\n};\n\n// Slightly longer than the path, so a drawn line never shows a hairline gap where it closes.\nconst DASH = 1.02;\nconst DRAW_EASE = \"cubic-bezier(0.65, 0, 0.35, 1)\";\n\nconst isAction = (action: unknown): action is EmptyStateAction =>\n  Boolean(action) && typeof action === \"object\" && !React.isValidElement(action) && \"label\" in (action as object);\n\n/** Lines draw themselves back to front, then their faces fill; ghosts fade in. */\nfunction drawIn(root: Element, delay = 0) {\n  root.querySelectorAll<SVGPathElement>(\"[data-shape]\").forEach((el, i) => {\n    const at = delay + Math.min(i * 34, 560);\n    if (el.dataset.dashed) {\n      el.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 420, delay: at, easing: EASE_OUT, fill: \"backwards\" });\n      return;\n    }\n    el.animate(\n      [\n        { strokeDashoffset: DASH, fillOpacity: 0 },\n        { strokeDashoffset: 0, fillOpacity: 0, offset: 0.62 },\n        { strokeDashoffset: 0, fillOpacity: 1 },\n      ],\n      { duration: 640, delay: at, easing: DRAW_EASE, fill: \"backwards\" },\n    );\n  });\n}\n\n/** The reverse, front to back and quicker: lines retract and faces empty. */\nfunction drawOut(root: Element) {\n  const shapes = [...root.querySelectorAll<SVGPathElement>(\"[data-shape]\")].reverse();\n  shapes.forEach((el, i) => {\n    const delay = Math.min(i * 14, 200);\n    if (el.dataset.dashed) {\n      el.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 200, delay, easing: EASE_OUT, fill: \"forwards\" });\n      return;\n    }\n    el.animate(\n      [\n        { strokeDashoffset: 0, fillOpacity: 1 },\n        { strokeDashoffset: 0, fillOpacity: 0, offset: 0.35 },\n        { strokeDashoffset: -DASH, fillOpacity: 0 },\n      ],\n      { duration: 340, delay, easing: DRAW_EASE, fill: \"forwards\" },\n    );\n  });\n}\n\nfunction Shape({ shape, clipId }: { shape: SceneShape; clipId?: string }) {\n  const el = (\n    <path\n      data-shape=\"\"\n      data-dashed={shape.dashed ? \"\" : undefined}\n      d={shape.d}\n      fillRule={shape.ring ? \"evenodd\" : undefined}\n      pathLength={shape.dashed ? undefined : 1}\n      strokeDasharray={shape.dashed ? \"2 2.5\" : `${DASH} ${DASH}`}\n      strokeLinejoin=\"round\"\n      strokeLinecap=\"round\"\n      strokeWidth={shape.stroke === \"accent\" && shape.fill === \"none\" ? 1.5 : 1}\n      style={{ fill: FILL[shape.fill], stroke: STROKE[shape.stroke] }}\n    />\n  );\n  return clipId ? <g clipPath={`url(#${clipId})`}>{el}</g> : el;\n}\n\nfunction Shapes({ shapes, id }: { shapes: SceneShape[]; id: string }) {\n  const clips = [...new Set(shapes.flatMap((s) => (s.clip ? [s.clip] : [])))];\n  return (\n    <>\n      {clips.length > 0 && (\n        <defs>\n          {clips.map((d, i) => (\n            <clipPath key={d} id={`${id}-${i}`}>\n              <path d={d} />\n            </clipPath>\n          ))}\n        </defs>\n      )}\n      {shapes.map((shape, i) => (\n        <Shape key={i} shape={shape} clipId={shape.clip ? `${id}-${clips.indexOf(shape.clip)}` : undefined} />\n      ))}\n    </>\n  );\n}\n\n/** Plays its drawing in once `seen` (the first time on screen, or at once for a scene that replaces another). */\nfunction useDrawIn(seen: boolean, reduced: boolean, delay: number) {\n  const ref = React.useRef<SVGGElement>(null);\n  const drawn = React.useRef(false);\n  React.useLayoutEffect(() => {\n    if (!seen || drawn.current) return;\n    drawn.current = true;\n    if (!reduced && ref.current) drawIn(ref.current, delay);\n  }, [seen, reduced, delay]);\n  return ref;\n}\n\nconst Tile = React.memo(function Tile({ seen, reduced, id }: { seen: boolean; reduced: boolean; id: string }) {\n  const ref = useDrawIn(seen, reduced, 0);\n  return (\n    <g ref={ref}>\n      <Shapes shapes={TILE} id={id} />\n    </g>\n  );\n});\n\ninterface LayerProps {\n  scene: EmptyStateScene;\n  leaving: boolean;\n  seen: boolean;\n  reduced: boolean;\n  status: ActionStatus;\n  /** The first scene waits for the tile; later ones wait for the old one to start leaving. */\n  delay: number;\n  id: string;\n  heroRef?: React.Ref<SVGGElement>;\n}\n\nconst Layer = React.memo(function Layer({ scene, leaving, seen, reduced, status, delay, id, heroRef }: LayerProps) {\n  const def = React.useMemo(() => SCENES[scene](), [scene]);\n  const ref = useDrawIn(seen, reduced, delay);\n\n  React.useLayoutEffect(() => {\n    if (leaving && !reduced && ref.current) drawOut(ref.current);\n  }, [leaving, reduced, ref]);\n\n  const pose = (status === \"pending\" && def.poses?.pending) || (status === \"success\" && def.poses?.success) || ([0, 0, 0] as [number, number, number]);\n  const [dx, dy] = poseOffset(pose);\n  const landing = status === \"success\";\n  const base = def.shapes.filter((s) => !s.hero);\n  const hero = def.shapes.filter((s) => s.hero);\n\n  return (\n    <g\n      ref={ref}\n      style={{\n        opacity: leaving && reduced ? 0 : 1,\n        transition: reduced ? \"opacity 200ms linear\" : undefined,\n      }}\n    >\n      <Shapes shapes={base} id={`${id}-b`} />\n      <g\n        ref={heroRef}\n        style={{\n          transform: `translate(${dx}px, ${dy}px)`,\n          transition: reduced ? \"none\" : `transform ${landing ? 620 : 520}ms ${landing ? EASE_SETTLE : EASE_OUT}`,\n        }}\n      >\n        <Shapes shapes={hero} id={`${id}-h`} />\n      </g>\n    </g>\n  );\n});\n\n/** Keeps the old content a moment so it can cross-fade out while the new one comes in, with the height eased between. */\nfunction Swap({ swapKey, reduced, children }: { swapKey: string; reduced: boolean; children: React.ReactNode }) {\n  const [shown, setShown] = React.useState({ key: swapKey, node: children });\n  const [prev, setPrev] = React.useState<{ key: string; node: React.ReactNode } | null>(null);\n  if (swapKey !== shown.key) {\n    setPrev(shown);\n    setShown({ key: swapKey, node: children });\n  }\n  const inner = React.useRef<HTMLDivElement>(null);\n  const [height, setHeight] = React.useState<number | null>(null);\n\n  React.useEffect(() => {\n    if (!prev) return;\n    const timer = window.setTimeout(() => setPrev(null), 260);\n    return () => window.clearTimeout(timer);\n  }, [prev]);\n\n  // The inner box is a new element for each description, so it's observed again after every swap.\n  React.useLayoutEffect(() => {\n    const el = inner.current;\n    if (!el) return;\n    const observer = new ResizeObserver(() => setHeight((h) => (h === el.offsetHeight ? h : el.offsetHeight)));\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [shown.key]);\n\n  return (\n    <div\n      className=\"relative w-full\"\n      style={{ height: height ?? undefined, transition: reduced || height === null ? \"none\" : `height 380ms ${EASE_OUT}` }}\n    >\n      {prev && !reduced && (\n        <div\n          key={prev.key}\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-x-0 top-0 animate-[ui-empty-out_200ms_cubic-bezier(0.16,1,0.3,1)_forwards]\"\n        >\n          {prev.node}\n        </div>\n      )}\n      <div ref={inner} key={shown.key} className={prev && !reduced ? \"animate-[ui-empty-in_360ms_cubic-bezier(0.16,1,0.3,1)_80ms_backwards]\" : undefined}>\n        {children}\n      </div>\n    </div>\n  );\n}\n\nexport function EmptyState({ scene = \"inbox\", illustration, title, description, action, secondaryAction, children, className = \"\" }: EmptyStateProps) {\n  const reduced = useInteractionReducedMotion();\n  const id = React.useId().replace(/:/g, \"\");\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const heroRef = React.useRef<SVGGElement>(null);\n  const [seen, setSeen] = React.useState(false);\n  const [status, setStatus] = React.useState<ActionStatus>(\"idle\");\n  const [error, setError] = React.useState<string | null>(null);\n  const [layers, setLayers] = React.useState([{ key: 0, scene, leaving: false }]);\n  const [lastScene, setLastScene] = React.useState(scene);\n  const [generation, setGeneration] = React.useState(0);\n  const generationRef = React.useRef(generation);\n\n  // A new scene: the old drawing starts leaving, the new one comes in over it, and the action starts over.\n  if (scene !== lastScene) {\n    setLastScene(scene);\n    setLayers((prev) => [...prev.filter((l) => !l.leaving).map((l) => ({ ...l, leaving: true })), { key: Math.max(...prev.map((l) => l.key)) + 1, scene, leaving: false }]);\n    setStatus(\"idle\");\n    setError(null);\n    setGeneration((g) => g + 1);\n  }\n\n  React.useEffect(() => {\n    generationRef.current = generation;\n  }, [generation]);\n\n  React.useEffect(() => {\n    if (!layers.some((l) => l.leaving)) return;\n    const timer = window.setTimeout(() => setLayers((prev) => prev.filter((l) => !l.leaving)), reduced ? 220 : 560);\n    return () => window.clearTimeout(timer);\n  }, [layers, reduced]);\n\n  // The drawing waits until it's on screen, so it's never played out of sight.\n  React.useEffect(() => {\n    const el = rootRef.current;\n    if (!el) return;\n    const observer = new IntersectionObserver(\n      ([entry]) => {\n        if (!entry.isIntersecting) return;\n        setSeen(true);\n        observer.disconnect();\n      },\n      { threshold: 0.25 },\n    );\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, []);\n\n  // The piece that acts the action out springs back with a shake when it fails.\n  React.useEffect(() => {\n    if (status !== \"error\" || reduced) return;\n    heroRef.current?.animate(\n      [{ transform: \"translateX(0)\" }, { transform: \"translateX(-4px)\" }, { transform: \"translateX(4px)\" }, { transform: \"translateX(-2px)\" }, { transform: \"translateX(0)\" }],\n      { duration: 380, easing: \"ease-out\", composite: \"add\" },\n    );\n  }, [status, reduced]);\n\n  const act = isAction(action) ? action : null;\n  const run = async () => {\n    if (!act?.onAction || status === \"pending\") return;\n    const mine = generationRef.current;\n    setStatus(\"pending\");\n    setError(null);\n    try {\n      await act.onAction();\n      if (generationRef.current === mine) setStatus(\"success\");\n    } catch (reason) {\n      if (generationRef.current !== mine) return;\n      setStatus(\"error\");\n      setError(reason instanceof Error ? reason.message : typeof reason === \"string\" ? reason : null);\n    }\n  };\n\n  const descriptionKey = typeof description === \"string\" ? description : `${scene}:${title}`;\n  const hasActions = Boolean(action || secondaryAction);\n  const secondaryClass = `inline-flex h-9 cursor-pointer items-center rounded-full px-4 text-[13px] font-medium text-muted-foreground hover:bg-accent hover:text-foreground ${INTERACTION_PRESS} ${INTERACTION_FOCUS}`;\n\n  return (\n    <div ref={rootRef} className={`@container w-full ${className}`}>\n      <div className=\"flex flex-col items-center px-6 py-10 text-center @max-[22rem]:px-4 @max-[22rem]:py-7\">\n        <div className=\"mb-5 flex h-[140px] w-[168px] items-center justify-center @max-[22rem]:mb-4 @max-[22rem]:h-[110px] @max-[22rem]:w-[132px]\" aria-hidden=\"true\">\n          {illustration ?? (\n            <svg\n              viewBox={`${VIEW.x} ${VIEW.y} ${VIEW.width} ${VIEW.height}`}\n              className=\"size-full overflow-visible\"\n              style={{ opacity: seen || reduced ? 1 : 0 }}\n            >\n              <Tile seen={seen || reduced} reduced={reduced} id={`${id}-t`} />\n              {layers.map((layer) => (\n                <Layer\n                  key={layer.key}\n                  scene={layer.scene}\n                  leaving={layer.leaving}\n                  seen={seen || reduced}\n                  reduced={reduced}\n                  status={layer.leaving ? \"idle\" : status}\n                  delay={layer.key === 0 ? 260 : 160}\n                  id={`${id}-${layer.key}`}\n                  heroRef={layer.leaving ? undefined : heroRef}\n                />\n              ))}\n            </svg>\n          )}\n        </div>\n\n        <h3 className=\"max-w-full text-[15px] font-medium leading-6 tracking-[-0.01em] text-foreground\">\n          <TextMorph>{title}</TextMorph>\n        </h3>\n        {description !== undefined && (\n          <div className=\"mt-1 w-full max-w-[36ch] text-[13.5px] leading-[1.55] text-muted-foreground\">\n            <Swap swapKey={descriptionKey} reduced={reduced}>\n              {description}\n            </Swap>\n          </div>\n        )}\n        {children && <div className=\"mt-4 flex w-full flex-wrap items-center justify-center gap-2\">{children}</div>}\n\n        <div\n          className=\"grid w-full\"\n          style={{ gridTemplateRows: hasActions ? \"1fr\" : \"0fr\", transition: reduced ? \"none\" : `grid-template-rows 380ms ${EASE_OUT}` }}\n        >\n          <div className=\"min-h-0 overflow-clip\">\n            <div className=\"flex flex-wrap items-center justify-center gap-2 pt-5\">\n              {act ? (\n                <StatusButton\n                  status={status}\n                  icon={act.icon}\n                  labels={{ idle: act.label, pending: act.pendingLabel ?? \"Working\", success: act.successLabel ?? \"Done\", error: act.errorLabel ?? \"Try again\" }}\n                  onClick={run}\n                />\n              ) : (\n                (action as React.ReactNode)\n              )}\n              {secondaryAction &&\n                (secondaryAction.href ? (\n                  <a href={secondaryAction.href} onClick={secondaryAction.onAction} className={secondaryClass}>\n                    {secondaryAction.label}\n                  </a>\n                ) : (\n                  <button type=\"button\" onClick={secondaryAction.onAction} className={secondaryClass}>\n                    {secondaryAction.label}\n                  </button>\n                ))}\n            </div>\n          </div>\n        </div>\n\n        <div\n          className=\"grid w-full\"\n          style={{ gridTemplateRows: error ? \"1fr\" : \"0fr\", transition: reduced ? \"none\" : `grid-template-rows 320ms ${EASE_OUT}` }}\n        >\n          <div className=\"min-h-0 overflow-clip\">\n            <p role=\"alert\" className=\"pt-3 text-[12.5px] text-red-600 dark:text-red-400\">\n              {error}\n            </p>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nexport default EmptyState;\n"},{"path":"registry/empty-state-scenes.ts","type":"registry:component","content":"// Hairline UI · empty-state · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n/* ─────────────────────────────────────────────────────────\n * EMPTY STATE SCENES: the isometric line drawings, as data\n *\n * Every scene is built in three dimensions on the same 6 × 6 tile\n * and projected with the standard isometric angle (30°). A shape is\n * one closed face or one line, listed back to front, so faces drawn\n * later cover what's behind them and the drawing keeps only the\n * lines you'd see. Shapes in the \"hero\" part move together for the\n * action's poses (a sheet that lands, a plug that goes in).\n * ───────────────────────────────────────────────────────── */\n\nexport type EmptyStateScene = \"inbox\" | \"search\" | \"done\" | \"offline\" | \"chart\";\n\n/** What a face is filled with; the component maps these to theme tokens. */\nexport type SceneFill = \"top\" | \"left\" | \"right\" | \"hole\" | \"glass\" | \"accent\" | \"accent-side\" | \"none\";\nexport type SceneStroke = \"line\" | \"faint\" | \"accent\";\n\nexport interface SceneShape {\n  d: string;\n  fill: SceneFill;\n  stroke: SceneStroke;\n  /** Dashed outlines are ghosts (nothing there yet); they fade in instead of drawing. */\n  dashed?: boolean;\n  /** Even-odd fill, for faces with a hole in them (the tray's rim, the lens). */\n  ring?: boolean;\n  /** Clipped to this outline (the inside of the tray). */\n  clip?: string;\n  /** Moves with the action's poses. */\n  hero?: boolean;\n}\n\ntype Vec3 = [number, number, number];\n\nexport interface SceneDef {\n  shapes: SceneShape[];\n  /** Where the hero part stands while the action runs and once it has worked, in tile units. */\n  poses?: { pending?: Vec3; success?: Vec3 };\n}\n\n/** One tile unit in px. */\nexport const UNIT = 14;\nconst C = Math.cos(Math.PI / 6);\n/** The drawing's box: the tile and everything that stands on it, same for every scene so the tile never moves. */\nexport const VIEW = { x: -78, y: -36, width: 156, height: 130 };\n\nexport const project = ([x, y, z]: Vec3): [number, number] => [(x - y) * C * UNIT, (x + y) * 0.5 * UNIT - z * UNIT];\n\nconst n = (v: number) => (Math.round(v * 100) / 100).toString();\nconst path = (points: Vec3[], close = true) =>\n  points.map((p, i) => `${i ? \"L\" : \"M\"}${n(project(p)[0])} ${n(project(p)[1])}`).join(\" \") + (close ? \" Z\" : \"\");\n\ntype Style = { fills?: [SceneFill, SceneFill, SceneFill]; stroke?: SceneStroke; dashed?: boolean; hero?: boolean };\n\n/** The three faces you can see of an upright box: the left-front, the right-front and the top. */\nfunction box(x: number, y: number, z: number, w: number, d: number, h: number, style: Style = {}): SceneShape[] {\n  const [X, Y, Z] = [x + w, y + d, z + h];\n  const [left, right, top] = style.fills ?? [\"left\", \"right\", \"top\"];\n  const base = { stroke: style.stroke ?? \"line\", dashed: style.dashed, hero: style.hero };\n  return [\n    { ...base, fill: left, d: path([[x, Y, z], [X, Y, z], [X, Y, Z], [x, Y, Z]]) },\n    { ...base, fill: right, d: path([[X, y, z], [X, Y, z], [X, Y, Z], [X, y, Z]]) },\n    { ...base, fill: top, d: path([[x, y, Z], [X, y, Z], [X, Y, Z], [x, Y, Z]]) },\n  ];\n}\n\nconst line = (points: Vec3[], stroke: SceneStroke = \"line\", hero = false): SceneShape => ({ d: path(points, false), fill: \"none\", stroke, hero });\n\n/** A circle standing upright and turned to face you (the plane x + y = constant), so it reads round. */\nconst facingCircle = (cx: number, cy: number, cz: number, r: number): Vec3[] =>\n  Array.from({ length: 48 }, (_, i) => {\n    const a = (i / 48) * Math.PI * 2;\n    const u = (r * Math.cos(a)) / Math.SQRT2;\n    return [cx + u, cy - u, cz + r * Math.sin(a)];\n  });\n\n/** The tile everything stands on, with a faint drafting grid. Drawn once; scenes change on top of it. */\nexport const TILE: SceneShape[] = [\n  ...box(0, 0, -0.45, 6, 6, 0.45),\n  ...[2, 4].flatMap((v) => [line([[v, 0, 0], [v, 6, 0]], \"faint\"), line([[0, v, 0], [6, v, 0]], \"faint\")]),\n];\n\n/** Nothing here yet: an open tray, and the first sheet waiting above it. */\nfunction inbox(): SceneDef {\n  const [x0, x1, top, floor, inset] = [1.3, 4.7, 0.8, 0.25, 0.3];\n  const [i0, i1] = [x0 + inset, x1 - inset];\n  const hole = path([[i0, i0, top], [i1, i0, top], [i1, i1, top], [i0, i1, top]]);\n  const outer = path([[x0, x0, top], [x1, x0, top], [x1, x1, top], [x0, x1, top]]);\n  const sheet: [number, number, number] = [2.0, 1.95, 2.05];\n  const textTop = sheet[2] + 0.06;\n  return {\n    shapes: [\n      ...box(x0, x0, 0, x1 - x0, x1 - x0, top).slice(0, 2),\n      { d: `${outer} ${hole}`, fill: \"top\", stroke: \"line\", ring: true },\n      { d: path([[i0, i0, floor], [i1, i0, floor], [i1, i1, floor], [i0, i1, floor]]), fill: \"hole\", stroke: \"faint\", clip: hole },\n      { d: path([[i0, i0, top], [i0, i1, top], [i0, i1, floor], [i0, i0, floor]]), fill: \"right\", stroke: \"line\", clip: hole },\n      { d: path([[i0, i0, top], [i1, i0, top], [i1, i0, floor], [i0, i0, floor]]), fill: \"left\", stroke: \"line\", clip: hole },\n      ...box(sheet[0], sheet[1], sheet[2], 2.0, 2.5, 0.06, { hero: true }),\n      ...[2.45, 2.95, 3.45].map((y, i) => line([[2.35, y, textTop], [i === 2 ? 3.1 : 3.65, y, textTop]], \"accent\", true)),\n    ],\n    // It hovers a little lower while the action runs, then lands on the rim.\n    poses: { pending: [0, 0, -0.55], success: [0, 0, top - sheet[2]] },\n  };\n}\n\n/** No results: a lens held over three empty result rows. */\nfunction search(): SceneDef {\n  const [cx, cy, cz, r, t] = [3.15, 2.85, 1.85, 1.3, 0.26];\n  const outer = facingCircle(cx, cy, cz, r);\n  const inner = facingCircle(cx, cy, cz, r - t);\n  // The handle leaves the rim down and to the right, in the lens's own plane.\n  const a = -0.95;\n  const along = (d: number): Vec3 => {\n    const u = (d * Math.cos(a)) / Math.SQRT2;\n    return [cx + u, cy - u, cz + d * Math.sin(a)];\n  };\n  const side = (p: Vec3, w: number): Vec3 => {\n    const u = (w * Math.cos(a + Math.PI / 2)) / Math.SQRT2;\n    return [p[0] + u, p[1] - u, p[2] + w * Math.sin(a + Math.PI / 2)];\n  };\n  const [h0, h1] = [along(r - 0.05), along(r + 1.15)];\n  const rows = [0.9, 2.35, 3.8].flatMap((y, i) => box(0.9, y, 0, i === 2 ? 3.3 : 4.2, 1.05, 0.1, { fills: [\"none\", \"none\", \"none\"], stroke: \"line\", dashed: true }));\n  return {\n    shapes: [\n      ...rows,\n      { d: path([side(h0, 0.16), side(h1, 0.16), side(h1, -0.16), side(h0, -0.16)]), fill: \"right\", stroke: \"line\", hero: true },\n      { d: path(inner), fill: \"glass\", stroke: \"faint\", hero: true },\n      { d: `${path(outer)} ${path(inner)}`, fill: \"left\", stroke: \"accent\", ring: true, hero: true },\n    ],\n    // The lens sweeps across the empty rows while a search runs again.\n    poses: { pending: [-0.7, 0.7, 0] },\n  };\n}\n\n/** All caught up: a thick check standing on a low platform, turned to face you. */\nfunction done(): SceneDef {\n  const [A, B, Cp] = [[-1.35, 1.95], [-0.45, 1.0], [1.7, 3.15]] as [number, number][];\n  const unit = (v: [number, number]): [number, number] => {\n    const l = Math.hypot(v[0], v[1]);\n    return [v[0] / l, v[1] / l];\n  };\n  const h = 0.26;\n  const d1 = unit([B[0] - A[0], B[1] - A[1]]);\n  const d2 = unit([Cp[0] - B[0], Cp[1] - B[1]]);\n  const n1: [number, number] = [-d1[1], d1[0]];\n  const n2: [number, number] = [-d2[1], d2[0]];\n  const m0 = unit([n1[0] + n2[0], n1[1] + n2[1]]);\n  const mLen = h / (m0[0] * n1[0] + m0[1] * n1[1]);\n  const m: [number, number] = [m0[0] * mLen, m0[1] * mLen];\n  // The outline in the check's own plane (u across, z up), with each edge's outward normal.\n  const outline: [number, number][] = [\n    [A[0] + n1[0] * h, A[1] + n1[1] * h],\n    [B[0] + m[0], B[1] + m[1]],\n    [Cp[0] + n2[0] * h, Cp[1] + n2[1] * h],\n    [Cp[0] - n2[0] * h, Cp[1] - n2[1] * h],\n    [B[0] - m[0], B[1] - m[1]],\n    [A[0] - n1[0] * h, A[1] - n1[1] * h],\n  ];\n  const normals: [number, number][] = [n1, n2, d2, [-n2[0], -n2[1]], [-n1[0], -n1[1]], [-d1[0], -d1[1]]];\n  // Its plane is x + y = 6 through the platform's middle; the thickness runs toward you, so the faces that look up show.\n  const at = (depth: number) => outline.map(([u, z]) => [3 + u / Math.SQRT2 + depth, 3 - u / Math.SQRT2 + depth, z] as Vec3);\n  const [front, back] = [0.16, -0.16];\n  const edge = (p: [number, number], q: [number, number]): Vec3[] => {\n    const [pb, qb] = [at(back), at(back)];\n    const [pf, qf] = [at(front), at(front)];\n    const i = outline.indexOf(p);\n    const j = outline.indexOf(q);\n    return [pb[i], qb[j], qf[j], pf[i]];\n  };\n  const sides = outline\n    .map((p, i) => ({ p, q: outline[(i + 1) % outline.length], nrm: normals[i] }))\n    .filter(({ nrm }) => nrm[1] > 0.02)\n    .map(({ p, q }) => ({ d: path(edge(p, q)), fill: \"accent-side\" as const, stroke: \"accent\" as const, hero: true }));\n  return {\n    shapes: [\n      ...box(1.3, 1.3, 0, 3.4, 3.4, 0.55),\n      { d: path(at(back)), fill: \"accent-side\", stroke: \"accent\", hero: true },\n      ...sides,\n      { d: path(at(front)), fill: \"accent\", stroke: \"accent\", hero: true },\n    ],\n  };\n}\n\n/** Can't connect: a plug lying short of its socket, its cable loose on the tile. */\nfunction offline(): SceneDef {\n  const face = 2.2;\n  const slot = (x: number) => ({ d: path([[x, face, 0.8], [x + 0.2, face, 0.8], [x + 0.2, face, 1.2], [x, face, 1.2]]), fill: \"hole\" as const, stroke: \"line\" as const });\n  const prong = (x: number) => line([[x, 3.9, 1.0], [x, 2.9, 1.0]], \"accent\", true);\n  return {\n    shapes: [\n      ...box(3.1, 0.7, 0, 2.3, face - 0.7, 2.0),\n      slot(3.75),\n      slot(4.55),\n      prong(3.85),\n      prong(4.65),\n      ...box(3.5, 3.9, 0, 1.5, 1.2, 1.6, { hero: true }),\n      line([[4.25, 5.1, 0.55], [4.25, 5.5, 0.03], [3.1, 5.6, 0.03], [2.0, 5.15, 0.03], [0.8, 5.4, 0.03]], \"line\", true),\n    ],\n    // It slides toward the socket while you retry, and goes in when it works.\n    poses: { pending: [0, -0.4, 0], success: [0, -0.72, 0] },\n  };\n}\n\n/** No data yet: a chart's back wall with ghost bars waiting to be filled. */\nfunction chart(): SceneDef {\n  const wall = 0.8;\n  return {\n    shapes: [\n      ...box(0.6, wall - 0.2, 0, 4.8, 0.2, 2.2),\n      ...[0.75, 1.5].map((z) => line([[0.8, wall, z], [5.2, wall, z]], \"faint\")),\n      ...[\n        [0.9, 0.9],\n        [2.05, 1.6],\n        [3.2, 1.2],\n        [4.35, 2.1],\n      ].flatMap(([x, h]) => box(x, 2.2, 0, 0.85, 0.85, h, { stroke: \"line\", dashed: true, hero: true })),\n    ],\n    // Ghost bars rise a touch while the first data is fetched.\n    poses: { pending: [0, 0, 0.3] },\n  };\n}\n\nexport const SCENES: Record<EmptyStateScene, () => SceneDef> = { inbox, search, done, offline, chart };\n\n/** A pose in tile units, as a screen offset in px. */\nexport const poseOffset = ([x, y, z]: Vec3): [number, number] => [(x - y) * C * UNIT, (x + y) * 0.5 * UNIT - z * UNIT];\n"},{"path":"registry/ui-interaction-motion.ts","type":"registry:component","content":"// Hairline UI · empty-state · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\nimport * as React from 'react';\n\nexport const EASE_OUT = 'cubic-bezier(0.16, 1, 0.3, 1)';\nexport const EASE_SETTLE = 'cubic-bezier(0.22, 1.18, 0.36, 1)';\n/** Thrown things (indicators, thumbs): a slight overshoot on transform only. */\nexport const EASE_THROW = 'cubic-bezier(0.34, 1.36, 0.64, 1)';\n/**\n * The press every button primitive shares: a 3% give that eases in and settles back.\n * Tailwind v4's scale-* writes the `scale` property, so `scale` has to be in the list\n * or the press snaps.\n */\nexport const INTERACTION_PRESS =\n  'transition-[background-color,color,box-shadow,opacity,scale] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] active:scale-[0.97] motion-reduce:transition-none motion-reduce:active:scale-100';\nexport const INTERACTION_FOCUS ='focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background';\n\nconst query = '(prefers-reduced-motion: reduce)';\nconst subscribe = (notify: () => void) => {\n  const media = window.matchMedia(query);\n  media.addEventListener('change', notify);\n  return () => media.removeEventListener('change', notify);\n};\nexport const useInteractionReducedMotion = () => React.useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);\n\nexport function useInteractionActivity<T extends Element>() {\n  const ref = React.useRef<T>(null);\n  const [visible, setVisible] = React.useState(false);\n  const [hidden, setHidden] = React.useState(false);\n  React.useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));\n    observer.observe(el);\n    const visibility = () => setHidden(document.hidden);\n    visibility(); document.addEventListener('visibilitychange', visibility);\n    return () => { observer.disconnect(); document.removeEventListener('visibilitychange', visibility); };\n  }, []);\n  return { ref, active: visible && !hidden };\n}\n\nexport function interactionIcon(on: boolean, reduced: boolean): React.CSSProperties {\n  return { opacity: on ? 1 : 0, transform: on ? 'none' : 'scale(0.6)', filter: on ? 'blur(0px)' : 'blur(3px)', transition: reduced ? 'none' : `opacity 220ms ${EASE_OUT}, transform 380ms ${EASE_OUT}, filter 220ms ${EASE_OUT}` };\n}\n\n/** Sample recent movement rather than averaging a long drag and losing its release velocity. */\nexport type PointerSample = { x: number; time: number };\nexport function samplePointer(samples: PointerSample[], x: number, time: number) {\n  samples.push({ x, time });\n  while (samples.length > 2 && time - samples[0].time > 100) samples.shift();\n}\nexport function releaseVelocity(samples: PointerSample[], now: number) {\n  if (samples.length < 2 || now - samples[samples.length - 1].time > 100) return 0;\n  const first = samples[0], last = samples[samples.length - 1];\n  return (last.x - first.x) / Math.max(1, last.time - first.time);\n}\n\n/** Smooth Apple/rational rubber-band curve: continuous exponential-like resistance without linear kinks. */\nexport function rubberBand(overshoot: number, dimension = 320, constant = 0.55): number {\n  const d = dimension || 320;\n  const abs = Math.abs(overshoot);\n  return Math.sign(overshoot) * (1 - 1 / ((abs * constant) / d + 1)) * d;\n}\n\n/** Projects resting travel from release velocity (velocity in px/ms; time in seconds). */\nexport function projectInertia(distance: number, velocity: number, time = 0.12): number {\n  return distance + velocity * 1000 * time;\n}\n\n/** Smooths incoming release velocity toward a boundary so springs don't slam or clip into walls. */\nexport function softenVelocity(velocity: number, distanceToTarget: number, factor = 10): number {\n  const maxAllowed = Math.abs(distanceToTarget) * factor;\n  return Math.sign(velocity) * Math.min(Math.abs(velocity), maxAllowed);\n}\n\n/** A damped spring sampled by WAAPI: tuned for organic lightness with a hint of life (3% breath). */\nexport function springFrames(\n  from: number,\n  to: number,\n  velocity = 0,\n  duration = 480,\n  config: { omega?: number; damping?: number; steps?: number } = {},\n) {\n  const { omega = 16, damping = 0.74, steps = 36 } = config;\n  const damped = omega * Math.sqrt(Math.max(0.001, 1 - damping * damping));\n  const displacement = from - to;\n  const coefficient = (velocity * 1000 + damping * omega * displacement) / damped;\n  return Array.from({ length: steps + 1 }, (_, i) => {\n    const t = (i / steps) * (duration / 1000);\n    const value = to + Math.exp(-damping * omega * t) * (displacement * Math.cos(damped * t) + coefficient * Math.sin(damped * t));\n    return { value: i === steps ? to : value, offset: i / steps };\n  });\n}\n\n"}],"css":{"@keyframes ui-empty-in":{"from":{"opacity":"0","transform":"translateY(4px)","filter":"blur(4px)"},"to":{"opacity":"1","transform":"none","filter":"none"}},"@keyframes ui-empty-out":{"from":{"opacity":"1","transform":"none","filter":"none"},"to":{"opacity":"0","transform":"translateY(-4px)","filter":"blur(4px)"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/empty-state. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}