{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "empty-states",
  "type": "registry:block",
  "title": "Empty states",
  "description": "Four empty states in one illustration whose shapes morph between scenes as you switch tabs.",
  "categories": [
    "blocks"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json",
    "https://irisui.dev/r/button.json"
  ],
  "files": [
    {
      "path": "src/registry/blocks/empty-states/empty-states-art.ts",
      "content": "/**\n * Geometry for the empty states illustration. Every scene is drawn from the same ten primitives, in the same paint order:\n * two back tiles, one morphing body, four front tiles, two line accents and one two-stroke mark. A scene only moves them.\n * Tiles are rounded rectangles described by center, size, radius, rotation and skew, so a circle can become a panel.\n * The body, lines and mark are equal-count point lists, sampled clockwise from the top left, so any outline can become any other.\n */\n\nexport type SceneId = \"search\" | \"offline\" | \"inbox\" | \"map\";\nexport type Phase = \"idle\" | \"loading\" | \"done\";\nexport type Kind = \"tile\" | \"body\" | \"stroke\" | \"mark\";\n/** `geo` springs as one vector; `fx` holds opacity and how much of a stroke is drawn. */\nexport type Prim = { kind: Kind; geo: number[]; fx: [number, number]; tone: string; redraw: boolean };\nexport type Frame = { prims: Prim[]; label: string };\n\nexport const KINDS: Kind[] = [\"tile\", \"tile\", \"body\", \"tile\", \"tile\", \"tile\", \"tile\", \"stroke\", \"stroke\", \"mark\"];\n\ntype Pt = readonly [number, number];\ntype Seg = { len: number; at: (t: number) => Pt };\n\nconst N = 176,\n  K = 40,\n  M = 12;\nconst TAU = Math.PI * 2,\n  RAD = Math.PI / 180;\n\nconst seg = (a: Pt, b: Pt): Seg => {\n  const dx = b[0] - a[0],\n    dy = b[1] - a[1];\n  return { len: Math.hypot(dx, dy), at: (t) => [a[0] + dx * t, a[1] + dy * t] };\n};\n\n/** Arcs always run clockwise on screen, from `from` to `to` in radians. */\nfunction arc(c: Pt, r: number, from: number, to: number): Seg {\n  let end = to;\n  while (end < from - 1e-9) end += TAU;\n  const span = end - from;\n  return { len: span * r, at: (t) => [c[0] + r * Math.cos(from + span * t), c[1] + r * Math.sin(from + span * t)] };\n}\nconst arcDeg = (c: Pt, r: number, from: number, to: number) => arc(c, r, from * RAD, to * RAD);\nconst polar = (c: Pt, r: number, deg: number): Pt => [c[0] + r * Math.cos(deg * RAD), c[1] + r * Math.sin(deg * RAD)];\n\nconst chain = (points: Pt[], closed = false) => {\n  const segs: Seg[] = [];\n  for (let i = 0; i < (closed ? points.length : points.length - 1); i++)\n    segs.push(seg(points[i]!, points[(i + 1) % points.length]!));\n  return segs;\n};\n\n/** A Catmull-Rom curve through the points, walked by arc length. */\nfunction curve(points: Pt[]): Seg {\n  const dense: Pt[] = [];\n  for (let i = 0; i < points.length - 1; i++) {\n    const p0 = points[Math.max(0, i - 1)]!,\n      p1 = points[i]!,\n      p2 = points[i + 1]!,\n      p3 = points[Math.min(points.length - 1, i + 2)]!;\n    for (let s = 0; s < 16; s++) {\n      const t = s / 16,\n        t2 = t * t,\n        t3 = t2 * t;\n      const axis = (k: 0 | 1) =>\n        0.5 *\n        (2 * p1[k] +\n          (p2[k] - p0[k]) * t +\n          (2 * p0[k] - 5 * p1[k] + 4 * p2[k] - p3[k]) * t2 +\n          (3 * p1[k] - p0[k] - 3 * p2[k] + p3[k]) * t3);\n      dense.push([axis(0), axis(1)]);\n    }\n  }\n  dense.push(points[points.length - 1]!);\n  const cum = [0];\n  for (let i = 1; i < dense.length; i++)\n    cum.push(cum[i - 1]! + Math.hypot(dense[i]![0] - dense[i - 1]![0], dense[i]![1] - dense[i - 1]![1]));\n  const len = cum[cum.length - 1]!;\n  return {\n    len,\n    at: (t) => {\n      const goal = t * len;\n      let i = 1;\n      while (i < cum.length - 1 && cum[i]! < goal) i++;\n      const a = dense[i - 1]!,\n        b = dense[i]!,\n        u = (goal - cum[i - 1]!) / (cum[i]! - cum[i - 1]! || 1);\n      return [a[0] + (b[0] - a[0]) * u, a[1] + (b[1] - a[1]) * u];\n    },\n  };\n}\n\n/** Shares `n` points between segments by length. Every segment starts on a point, so sharp corners survive the resample. */\nfunction allot(lens: number[], n: number) {\n  const total = lens.reduce((sum, len) => sum + len, 0);\n  if (total < 1e-6) return lens.map((_, i) => (i === 0 ? n : 0));\n  const exact = lens.map((len) => (len / total) * n);\n  const counts = exact.map((value, i) => (lens[i]! > 1e-6 ? Math.max(1, Math.floor(value)) : 0));\n  let sum = counts.reduce((a, b) => a + b, 0);\n  while (sum < n) {\n    let best = -1;\n    for (let i = 0; i < counts.length; i++)\n      if (lens[i]! > 1e-6 && (best < 0 || exact[i]! - counts[i]! > exact[best]! - counts[best]!)) best = i;\n    counts[best]!++;\n    sum++;\n  }\n  while (sum > n) {\n    let best = -1;\n    for (let i = 0; i < counts.length; i++)\n      if (counts[i]! > 1 && (best < 0 || counts[i]! - exact[i]! > counts[best]! - exact[best]!)) best = i;\n    counts[best]!--;\n    sum--;\n  }\n  return counts;\n}\n\nfunction sample(segs: Seg[], n: number, open: boolean) {\n  const counts = allot(\n    segs.map((s) => s.len),\n    open ? n - 1 : n,\n  );\n  const out: number[] = [];\n  segs.forEach((s, i) => {\n    for (let j = 0; j < counts[i]!; j++) {\n      const [x, y] = s.at(j / counts[i]!);\n      out.push(x, y);\n    }\n  });\n  if (open) {\n    const [x, y] = segs[segs.length - 1]!.at(1);\n    out.push(x, y);\n  }\n  return out;\n}\n\nfunction rrect(x: number, y: number, w: number, h: number, radius: number | [number, number, number, number]): Seg[] {\n  const [tl, tr, br, bl] = typeof radius === \"number\" ? [radius, radius, radius, radius] : radius;\n  return [\n    seg([x + tl, y], [x + w - tr, y]),\n    arcDeg([x + w - tr, y + tr], tr, -90, 0),\n    seg([x + w, y + tr], [x + w, y + h - br]),\n    arcDeg([x + w - br, y + h - br], br, 0, 90),\n    seg([x + w - br, y + h], [x + bl, y + h]),\n    arcDeg([x + bl, y + h - bl], bl, 90, 180),\n    seg([x, y + h - bl], [x, y + tl]),\n    arcDeg([x + tl, y + tl], tl, 180, 270),\n  ];\n}\n\n/** A cloud is a row of overlapping circles sitting on a flat base; the outline walks each circle's exposed top between neighbours. */\nfunction cloud(lobes: { c: Pt; r: number }[], base: number): Seg[] {\n  const meet = (a: { c: Pt; r: number }, b: { c: Pt; r: number }): Pt => {\n    const dx = b.c[0] - a.c[0],\n      dy = b.c[1] - a.c[1],\n      d = Math.hypot(dx, dy);\n    const along = (a.r * a.r - b.r * b.r + d * d) / (2 * d),\n      h = Math.sqrt(Math.max(0, a.r * a.r - along * along));\n    const mx = a.c[0] + (along * dx) / d,\n      my = a.c[1] + (along * dy) / d;\n    const p: Pt = [mx + (h * dy) / d, my - (h * dx) / d],\n      q: Pt = [mx - (h * dy) / d, my + (h * dx) / d];\n    return p[1] < q[1] ? p : q;\n  };\n  const angle = (c: Pt, p: Pt) => Math.atan2(p[1] - c[1], p[0] - c[0]);\n  const joins = lobes.slice(1).map((lobe, i) => meet(lobes[i]!, lobe));\n  const first = lobes[0]!,\n    last = lobes[lobes.length - 1]!;\n  const segs = [arc(first.c, first.r, -Math.PI / 2, angle(first.c, joins[0]!))];\n  for (let i = 1; i < lobes.length - 1; i++) {\n    const lobe = lobes[i]!;\n    segs.push(arc(lobe.c, lobe.r, angle(lobe.c, joins[i - 1]!), angle(lobe.c, joins[i]!)));\n  }\n  segs.push(arc(last.c, last.r, angle(last.c, joins[joins.length - 1]!), Math.PI / 2));\n  segs.push(seg([last.c[0], base], [first.c[0], base]));\n  segs.push(arc(first.c, first.r, Math.PI / 2, Math.PI * 1.5));\n  return segs;\n}\n\ntype TileOptions = { rot?: number; skew?: number; z?: number; o?: number };\nconst tile = (\n  cx: number,\n  cy: number,\n  w: number,\n  h: number,\n  r: number,\n  tone: string,\n  { rot = 0, skew = 0, z = 1, o = 1 }: TileOptions = {},\n): Prim => ({ kind: \"tile\", geo: [cx, cy, w, h, r, rot, skew, z], fx: [o, 1], tone, redraw: false });\nconst box = (x: number, y: number, w: number, h: number, r: number, tone: string, options?: TileOptions) =>\n  tile(x + w / 2, y + h / 2, w, h, r, tone, options);\nconst disc = (c: Pt, r: number, tone: string, options?: TileOptions) =>\n  tile(c[0], c[1], r * 2, r * 2, r, tone, options);\n/** Shapes a scene does not need shrink into a point inside something that stays, and fade on the way. */\nconst park = (c: Pt, z: number) => tile(c[0], c[1], 4, 4, 2, \"tuck\", { z, o: 0 });\nconst body = (segs: Seg[], z: number): Prim => ({\n  kind: \"body\",\n  geo: [...sample(segs, N, false), z],\n  fx: [1, 1],\n  tone: \"outline\",\n  redraw: false,\n});\n\ntype StrokeOptions = { dash?: number; z?: number; z1?: number; o?: number; draw?: number; redraw?: boolean };\nconst line = (\n  segs: Seg[],\n  tone: string,\n  { dash = 0, z = 1, z1 = z, o = 1, draw = 1, redraw = false }: StrokeOptions = {},\n): Prim => ({ kind: \"stroke\", geo: [...sample(segs, K, true), dash, z, z1], fx: [o, draw], tone, redraw });\nconst rest = (p: Pt, z: number) => line([seg(p, p)], \"ink\", { z, o: 0, draw: 0 });\nconst mark = (\n  [a, b]: [Seg[], Seg[]],\n  tone: string,\n  { z = 1, o = 1, draw = 1, redraw = true }: StrokeOptions = {},\n): Prim => ({\n  kind: \"mark\",\n  geo: [...sample(a, M, true), ...sample(b, M, true), 0, z, z],\n  fx: [o, draw],\n  tone,\n  redraw,\n});\n\nconst check = (c: Pt, s: number): [Seg[], Seg[]] => {\n  const a: Pt = [c[0] - 0.42 * s, c[1] + 0.02 * s],\n    b: Pt = [c[0] - 0.12 * s, c[1] + 0.32 * s],\n    e: Pt = [c[0] + 0.44 * s, c[1] - 0.3 * s];\n  return [[seg(a, b)], [seg(b, e)]];\n};\nconst cross = (c: Pt, h: number): [Seg[], Seg[]] => [\n  [seg([c[0] - h, c[1] - h], [c[0] + h, c[1] + h])],\n  [seg([c[0] + h, c[1] - h], [c[0] - h, c[1] + h])],\n];\nconst slashes = (c: Pt): [Seg[], Seg[]] => [\n  [seg([c[0] - 10, c[1] + 5], [c[0] - 2, c[1] - 5])],\n  [seg([c[0] + 2, c[1] + 5], [c[0] + 10, c[1] - 5])],\n];\nconst glare = (c: Pt, r: number): [Seg[], Seg[]] => [[arcDeg(c, r, 196, 236)], [arcDeg(c, r, 248, 256)]];\n\nfunction search(phase: Phase): Frame {\n  const card = body(rrect(58, 36, 172, 132, 18), 0.6);\n  const tucked = [tile(144, 102, 96, 58, 14, \"tuck\", { z: 0.3 }), tile(144, 102, 120, 76, 16, \"tuck\", { z: 0.3 })];\n  if (phase !== \"done\") {\n    const lens: Pt = [196, 118];\n    return {\n      label: \"A magnifying glass over three empty result rows\",\n      prims: [\n        ...tucked,\n        card,\n        box(78, 64, 132, 20, 8, \"slot\", { z: 0.6 }),\n        box(78, 92, 132, 20, 8, \"slot\", { z: 0.6 }),\n        box(78, 120, 132, 20, 8, \"slot\", { z: 0.6 }),\n        disc(lens, 30, \"outline\", { z: 1.6 }),\n        line([seg(polar(lens, 30, 45), [244, 166])], \"ink\", { z: 1.6 }),\n        rest([244, 166], 1.6),\n        mark(glare(lens, 20), \"muted\", { z: 1.6 }),\n      ],\n    };\n  }\n  // Filters cleared: the magnifier docks into the header beside the query and the rows fill in as results.\n  const lens: Pt = [204, 61];\n  return {\n    label: \"A results list with a search field and two results\",\n    prims: [\n      ...tucked,\n      card,\n      box(78, 56, 64, 10, 5, \"query\", { z: 0.6 }),\n      box(78, 100, 128, 10, 5, \"bar\", { z: 0.6 }),\n      box(78, 124, 96, 10, 5, \"bar\", { z: 0.6 }),\n      disc(lens, 9, \"outline\", { z: 1 }),\n      line([seg(polar(lens, 9, 45), polar(lens, 17, 45))], \"ink\", { z: 1 }),\n      line([seg([58, 84], [230, 84])], \"subtle\", { z: 0.6 }),\n      mark(glare(lens, 5), \"muted\", { z: 1, o: 0, draw: 0 }),\n    ],\n  };\n}\n\nfunction offline(phase: Phase): Frame {\n  const hub: Pt = [160, 92];\n  const [top, bottom] = phase === \"done\" ? [146, 146] : phase === \"loading\" ? [141, 151] : [134, 158];\n  const wire = phase === \"loading\" ? \"accent\" : \"ink\";\n  return {\n    label:\n      phase === \"done\"\n        ? \"A cloud joined to a device by an unbroken line, with a check on the device\"\n        : phase === \"loading\"\n          ? \"The broken line reaching back toward the cloud\"\n          : \"A cloud joined to a device by a broken line\",\n    prims: [\n      tile(160, 92, 80, 30, 15, \"tuck\", { z: 0.3 }),\n      tile(160, 92, 104, 36, 18, \"tuck\", { z: 0.3 }),\n      body(\n        cloud(\n          [\n            { c: [100, 96], r: 22 },\n            { c: [140, 72], r: 34 },\n            { c: [188, 76], r: 28 },\n            { c: [220, 98], r: 20 },\n          ],\n          118,\n        ),\n        1.3,\n      ),\n      park(hub, 1.3),\n      park(hub, 1.3),\n      park(hub, 1.3),\n      box(134, 168, 52, 34, 9, phase === \"done\" ? \"success\" : phase === \"loading\" ? \"accent\" : \"outline\", { z: 0.7 }),\n      line([seg([160, 118], [160, top])], wire, { z: 1.3, z1: 1 }),\n      line([seg([160, bottom], [160, 168])], wire, { z: 1, z1: 0.7 }),\n      phase === \"done\"\n        ? mark(check([160, 185], 18), \"success\", { z: 0.7 })\n        : mark(slashes([160, 146]), \"warning\", { z: 1, draw: phase === \"loading\" ? 0 : 1 }),\n    ],\n  };\n}\n\nfunction inbox(phase: Phase): Frame {\n  const lift = phase === \"loading\" ? 1 : 0;\n  const badge: Pt = [226, 84],\n    slot: Pt = [160, 140];\n  return {\n    label: phase === \"loading\" ? \"A stacked inbox while new mail is checked\" : \"A stacked inbox with a check mark\",\n    prims: [\n      box(112, 72 - 8 * lift, 96, 40, 12, \"back\", { z: 0.25 }),\n      box(100, 88 - 4 * lift, 120, 40, 12, \"back\", { z: 0.45 }),\n      body(rrect(88, 104, 144, 72, [8, 8, 16, 16]), 0.7),\n      park(slot, 0.7),\n      park(slot, 0.7),\n      park(slot, 0.7),\n      disc(badge, 17, phase === \"loading\" ? \"muted\" : \"success\", { z: 1.5 }),\n      line(\n        chain([\n          [88, 126],\n          [124, 126],\n          [133, 140],\n          [187, 140],\n          [196, 126],\n          [232, 126],\n        ]),\n        \"ink\",\n        { z: 0.7 },\n      ),\n      rest(slot, 0.7),\n      mark(check(badge, 16), \"success\", { z: 1.5, draw: phase === \"loading\" ? 0 : 1 }),\n    ],\n  };\n}\n\nfunction map(phase: Phase): Frame {\n  const start: Pt = [90, 142],\n    pin: Pt = [206, 110],\n    found = phase === \"done\";\n  const route = found\n    ? curve([start, [114, 152], [140, 140], [160, 122], [182, 128], polar(pin, 10, 135)])\n    : curve([start, [108, 124], [132, 130], [152, 108], [178, 100], [200, 84]]);\n  return {\n    label: found\n      ? \"A folded map with a dashed route that ends at a checked destination\"\n      : phase === \"loading\"\n        ? \"A folded map while the route is searched again\"\n        : \"A folded map with a dashed route that ends in an x\",\n    prims: [\n      tile(151, 107, 90, 56, 12, \"tuck\", { z: 0.3 }),\n      tile(151, 107, 110, 70, 14, \"tuck\", { z: 0.3 }),\n      body(\n        chain(\n          [\n            [70, 62],\n            [124, 48],\n            [178, 62],\n            [232, 48],\n            [232, 152],\n            [178, 166],\n            [124, 152],\n            [70, 166],\n          ],\n          true,\n        ),\n        0.8,\n      ),\n      park(start, 0.8),\n      tile(151, 107, 54, 104, 0, \"shade\", { skew: Math.atan2(14, 54) / RAD, z: 0.8 }),\n      found ? disc(pin, 10, \"success\", { z: 0.8 }) : park(start, 0.8),\n      disc(start, 4, \"dot\", { z: 0.8 }),\n      line([route], \"accent\", { dash: 5, z: 0.8, draw: phase === \"loading\" ? 0 : 1, redraw: true }),\n      rest(start, 0.8),\n      found\n        ? mark(check(pin, 10), \"success\", { z: 0.8 })\n        : mark(cross([208, 76], 5), \"ink\", { z: 0.8, draw: phase === \"loading\" ? 0 : 1 }),\n    ],\n  };\n}\n\n/** Moves every point of a frame; used to center each scene in the view box. */\nfunction move(frame: Frame, dx: number, dy: number): Frame {\n  return {\n    ...frame,\n    prims: frame.prims.map((p) => {\n      const geo = [...p.geo];\n      if (p.kind === \"tile\") {\n        geo[0]! += dx;\n        geo[1]! += dy;\n      } else\n        for (let i = 0; i < geo.length - (p.kind === \"body\" ? 1 : 3); i += 2) {\n          geo[i]! += dx;\n          geo[i + 1]! += dy;\n        }\n      return { ...p, geo };\n    }),\n  };\n}\n\nfunction center(frame: Frame) {\n  let x0 = Infinity,\n    y0 = Infinity,\n    x1 = -Infinity,\n    y1 = -Infinity;\n  const add = (x: number, y: number) => {\n    x0 = Math.min(x0, x);\n    y0 = Math.min(y0, y);\n    x1 = Math.max(x1, x);\n    y1 = Math.max(y1, y);\n  };\n  for (const p of frame.prims) {\n    if (p.fx[0] < 0.01) continue;\n    if (p.kind === \"tile\") {\n      const [cx, cy, w, h] = p.geo as [number, number, number, number];\n      add(cx - w / 2, cy - h / 2);\n      add(cx + w / 2, cy + h / 2);\n      continue;\n    }\n    if (p.kind !== \"body\" && p.fx[1] < 0.01) continue;\n    for (let i = 0; i < p.geo.length - (p.kind === \"body\" ? 1 : 3); i += 2) add(p.geo[i]!, p.geo[i + 1]!);\n  }\n  return [VIEW.width / 2 - (x0 + x1) / 2, VIEW.height / 2 - (y0 + y1) / 2] as const;\n}\n\nexport const VIEW = { width: 320, height: 216 };\n\nconst builders: Record<SceneId, (phase: Phase) => Frame> = { search, offline, inbox, map };\nconst phases: Phase[] = [\"idle\", \"loading\", \"done\"];\n\n/** Every frame is computed once at load, so the server and the first client render draw the same thing. */\nexport const FRAMES = Object.fromEntries(\n  (Object.keys(builders) as SceneId[]).map((id) => {\n    const [dx, dy] = center(builders[id](\"idle\"));\n    return [id, Object.fromEntries(phases.map((phase) => [phase, move(builders[id](phase), dx, dy)]))];\n  }),\n) as Record<SceneId, Record<Phase, Frame>>;\n\nconst fmt = (v: number) => String(Math.round(v * 100) / 100);\n\nfunction tilePath(g: ArrayLike<number>, ox: number, oy: number) {\n  const w = Math.max(0, g[2]!),\n    h = Math.max(0, g[3]!);\n  if (w < 0.05 || h < 0.05) return \"\";\n  const r = Math.min(Math.max(0, g[4]!), w / 2, h / 2),\n    tan = Math.tan(g[6]! * RAD),\n    cos = Math.cos(g[5]! * RAD),\n    sin = Math.sin(g[5]! * RAD);\n  const px = g[0]! + ox * g[7]!,\n    py = g[1]! + oy * g[7]!,\n    hw = w / 2,\n    hh = h / 2;\n  const corners: [number, number, number][] = [\n    [hw - r, -hh + r, -90],\n    [hw - r, hh - r, 0],\n    [-hw + r, hh - r, 90],\n    [-hw + r, -hh + r, 180],\n  ];\n  let d = \"\";\n  for (const [cx, cy, start] of corners) {\n    for (let s = 0; s <= 6; s++) {\n      const a = (start + s * 15) * RAD,\n        x = cx + r * Math.cos(a),\n        y = cy + r * Math.sin(a) + (cx + r * Math.cos(a)) * tan;\n      d += `${d ? \"L\" : \"M\"}${fmt(x * cos - y * sin + px)} ${fmt(x * sin + y * cos + py)}`;\n    }\n  }\n  return `${d}Z`;\n}\n\nfunction span(g: ArrayLike<number>, from: number, count: number) {\n  let len = 0;\n  for (let i = from + 1; i < from + count; i++)\n    len += Math.hypot(g[2 * i]! - g[2 * i - 2]!, g[2 * i + 1]! - g[2 * i - 1]!);\n  return len;\n}\n\n/** Draws the first `budget` units of a polyline. Each point drifts by its own depth, so a line can stretch between two layers. */\nfunction trace(\n  g: ArrayLike<number>,\n  from: number,\n  count: number,\n  budget: number,\n  ox: number,\n  oy: number,\n  z0: number,\n  z1: number,\n  total: number,\n) {\n  if (budget <= 0.05) return \"\";\n  const at = (i: number): Pt => {\n    const z = z0 + (z1 - z0) * (total > 1 ? (from + i) / (total - 1) : 0);\n    return [g[2 * (from + i)]! + ox * z, g[2 * (from + i) + 1]! + oy * z];\n  };\n  let [px, py] = at(0),\n    left = budget,\n    d = `M${fmt(px)} ${fmt(py)}`;\n  for (let i = 1; i < count; i++) {\n    const [x, y] = at(i),\n      len = Math.hypot(x - px, y - py);\n    if (len >= left) {\n      const u = len ? left / len : 0;\n      return `${d}L${fmt(px + (x - px) * u)} ${fmt(py + (y - py) * u)}`;\n    }\n    left -= len;\n    d += `L${fmt(x)} ${fmt(y)}`;\n    px = x;\n    py = y;\n  }\n  return d;\n}\n\n/** Turns a primitive's current vector into path data, plus a dash pattern for strokes. */\nexport function shapePath(\n  kind: Kind,\n  g: ArrayLike<number>,\n  drawn: number,\n  ox: number,\n  oy: number,\n): { d: string; dash: string } {\n  if (kind === \"tile\") return { d: tilePath(g, ox, oy), dash: \"none\" };\n  if (kind === \"body\") {\n    const z = g[2 * N]!,\n      dx = ox * z,\n      dy = oy * z;\n    let d = \"\";\n    for (let i = 0; i < N; i++) d += `${i ? \"L\" : \"M\"}${fmt(g[2 * i]! + dx)} ${fmt(g[2 * i + 1]! + dy)}`;\n    return { d: `${d}Z`, dash: \"none\" };\n  }\n  const draw = Math.min(1, Math.max(0, drawn)),\n    count = (g.length - 3) / 2,\n    gap = g[g.length - 3]!,\n    z0 = g[g.length - 2]!,\n    z1 = g[g.length - 1]!;\n  const dash = gap > 0.2 ? `3 ${fmt(gap)}` : \"none\";\n  if (kind === \"stroke\") return { d: trace(g, 0, count, draw * span(g, 0, count), ox, oy, z0, z1, count), dash };\n  const first = span(g, 0, M),\n    budget = draw * (first + span(g, M, M));\n  return {\n    d: trace(g, 0, M, Math.min(budget, first), ox, oy, z0, z1, 1) + trace(g, M, M, budget - first, ox, oy, z0, z1, 1),\n    dash,\n  };\n}\n",
      "type": "registry:component",
      "target": "@components/iris/blocks/empty-states/empty-states-art.ts"
    },
    {
      "path": "src/registry/blocks/empty-states/empty-states.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport type { KeyboardEvent, PointerEvent } from \"react\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from \"motion/react\";\nimport { motionTokens } from \"../../lib/motion-tokens\";\nimport { cn } from \"../../lib/utils\";\nimport { Button } from \"../../button/button\";\nimport { FRAMES, KINDS, VIEW, shapePath } from \"./empty-states-art\";\nimport type { Frame, Phase, SceneId } from \"./empty-states-art\";\n\n/**\n * One product illustration for four empty states. The tabs pick a state; every shape in the drawing travels to its new\n * position, size, radius, rotation and color on a spring, outlines morph point by point, and line accents draw in a beat later.\n * Each primary action does something local and visible: filters clear into results, a retry reconnects after a short wait,\n * a mail check redraws the check, and a lost page is found where it moved. Nothing here talks to a server.\n */\n\n/*\n * Strokes stay 1.5px on screen at any size. Colours come from each shape's `data-tone` (the engine rewrites it on the\n * ghost layer) and cross over on CSS transitions while the geometry springs. Lines and marks only ever take a stroke.\n */\nconst shapeBase =\n  \"fill-none stroke-none stroke-[1.5px] [vector-effect:non-scaling-stroke] [stroke-linecap:round] [stroke-linejoin:round] [transition:fill_var(--duration-considered)_var(--ease-standard),stroke_var(--duration-considered)_var(--ease-standard)] motion-reduce:transition-none\";\nconst strokeTones =\n  \"data-[tone=outline]:stroke-ink data-[tone=tuck]:stroke-line-strong data-[tone=slot]:stroke-line-strong data-[tone=query]:stroke-transparent data-[tone=bar]:stroke-transparent data-[tone=back]:stroke-ink-3 data-[tone=shade]:stroke-ink data-[tone=dot]:stroke-transparent data-[tone=ink]:stroke-ink data-[tone=muted]:stroke-ink-3 data-[tone=subtle]:stroke-line-strong data-[tone=accent]:stroke-ink data-[tone=success]:stroke-green data-[tone=warning]:stroke-orange\";\nconst fillTones =\n  \"data-[tone=outline]:fill-surface data-[tone=tuck]:fill-surface data-[tone=slot]:fill-surface data-[tone=query]:fill-ink-3 data-[tone=bar]:fill-line-strong data-[tone=back]:fill-surface data-[tone=shade]:fill-hover data-[tone=dot]:fill-ink data-[tone=ink]:fill-surface data-[tone=muted]:fill-surface data-[tone=subtle]:fill-surface data-[tone=accent]:fill-surface data-[tone=success]:fill-surface data-[tone=warning]:fill-surface\";\nconst shapeClass = (kind: string) => cn(shapeBase, strokeTones, kind !== \"stroke\" && kind !== \"mark\" && fillTones);\n\ntype View = { scene: SceneId; phase: Phase; from: Phase };\ntype Copy = { title: string; line: string; action: string };\n\nconst SCENES: { id: SceneId; tab: string }[] = [\n  { id: \"search\", tab: \"No results\" },\n  { id: \"offline\", tab: \"Offline\" },\n  { id: \"inbox\", tab: \"Caught up\" },\n  { id: \"map\", tab: \"Not found\" },\n];\n\n/** Each scene borrows a product: Moria file search, Meltemi's Outbox, Entropost's inbox zero and a moved notdocs page. */\nconst COPY: Record<SceneId, { idle: Copy; done: Copy; loading: string; wait: number }> = {\n  search: {\n    idle: {\n      title: \"No results for “lease”\",\n      line: \"Two filters are on: kind is image and the location is Downloads.\",\n      action: \"Clear filters\",\n    },\n    done: {\n      title: \"4 results for “lease”\",\n      line: \"Filters cleared. Showing matches from every location and kind.\",\n      action: \"Restore filters\",\n    },\n    loading: \"\",\n    wait: 0,\n  },\n  offline: {\n    idle: {\n      title: \"You are offline\",\n      line: \"Replies wait in your Outbox and send when the connection returns.\",\n      action: \"Try again\",\n    },\n    done: {\n      title: \"Back online\",\n      line: \"Three replies left the Outbox for your work account just now.\",\n      action: \"Go offline\",\n    },\n    loading: \"Reaching your mail accounts…\",\n    wait: 1400,\n  },\n  inbox: {\n    idle: {\n      title: \"Inbox zero\",\n      line: \"Every letter has its next step. New letters land on the stage first.\",\n      action: \"Check for mail\",\n    },\n    done: {\n      title: \"Still inbox zero\",\n      line: \"Checked 3 accounts just now. Nothing new since 08:30 this morning.\",\n      action: \"Check again\",\n    },\n    loading: \"Checking 3 accounts…\",\n    wait: 1100,\n  },\n  map: {\n    idle: {\n      title: \"Page not found\",\n      line: \"The link to /docs/pricing-notes is broken, or the page has moved.\",\n      action: \"Find the page\",\n    },\n    done: {\n      title: \"Found it in decisions\",\n      line: \"Seat-based pricing moved to docs/decisions/seat-based-pricing.md.\",\n      action: \"Show broken link\",\n    },\n    loading: \"Searching the project for Seat-based pricing…\",\n    wait: 1200,\n  },\n};\n\n/** Shapes lead, details trail: back tiles, body, front tiles, then lines and the mark. Totals stay under 0.4s. */\nconst DELAY = [0.05, 0.03, 0, 0.04, 0.07, 0.1, 0.06, 0.09, 0.11, 0.14];\nconst NUDGE = [0.03, 0.015, 0, 0.02, 0.035, 0.05, 0.02, 0.04, 0.05, 0.07];\nconst DRIFT = 2.5;\n\nconst first = FRAMES.search.idle;\nconst INITIAL = first.prims.map((p, i) => ({ ...shapePath(KINDS[i]!, p.geo, p.fx[1], 0, 0), o: String(p.fx[0]) }));\n\nconst enter = [...motionTokens.ease.enter] as [number, number, number, number];\nconst standard = [...motionTokens.ease.standard] as [number, number, number, number];\n\n/** Motion's visualDuration and bounce, turned into stiffness and damping for the vector springs below. */\nfunction springOf({ visualDuration, bounce }: { visualDuration: number; bounce: number }) {\n  const root = (2 * Math.PI) / (visualDuration * 1.2);\n  return { k: root * root, c: 2 * Math.min(1, Math.max(0.05, 1 - bounce)) * root };\n}\nconst MORPH = springOf(motionTokens.spring.morph),\n  SMOOTH = springOf(motionTokens.spring.smooth),\n  SWAY = { k: 90, c: 19 };\n\ntype Channel = {\n  pos: Float64Array;\n  vel: Float64Array;\n  target: Float64Array;\n  queue: { at: number; values: number[] }[];\n  k: number;\n  c: number;\n  still: boolean;\n};\nconst channel = (values: number[], spring: { k: number; c: number }): Channel => ({\n  pos: Float64Array.from(values),\n  vel: new Float64Array(values.length),\n  target: Float64Array.from(values),\n  queue: [],\n  ...spring,\n  still: true,\n});\n\n/** Integrates one spring vector. A new target keeps the current velocity, so shapes retarget mid-flight without a hitch. */\nfunction advance(ch: Channel, now: number, dt: number) {\n  while (ch.queue.length && ch.queue[0]!.at <= now) {\n    const next = ch.queue.shift();\n    if (next) {\n      ch.target.set(next.values);\n      ch.still = false;\n    }\n  }\n  if (!ch.still) {\n    const steps = Math.max(1, Math.ceil(dt * 240)),\n      h = dt / steps,\n      { pos, vel, target, k, c } = ch;\n    for (let s = 0; s < steps; s++)\n      for (let i = 0; i < pos.length; i++) {\n        vel[i]! += (-k * (pos[i]! - target[i]!) - c * vel[i]!) * h;\n        pos[i]! += vel[i]! * h;\n      }\n    let settled = true;\n    for (let i = 0; i < pos.length; i++)\n      if (Math.abs(pos[i]! - target[i]!) > 0.004 || Math.abs(vel[i]!) > 0.02) {\n        settled = false;\n        break;\n      }\n    if (settled) {\n      pos.set(target);\n      vel.fill(0);\n      ch.still = true;\n    }\n  }\n  return !ch.still || ch.queue.length > 0;\n}\n\ntype Nodes = { paths: SVGPathElement[]; ghosts: SVGPathElement[]; main: SVGGElement; ghost: SVGGElement };\n\n/** One animation frame loop drives every shape through refs. It sleeps once everything has settled. */\nfunction createEngine(nodes: Nodes, start: Frame) {\n  const geo = start.prims.map((p) => channel(p.geo, MORPH));\n  const fx = start.prims.map((p) => channel(p.fx, SMOOTH));\n  const sway = channel([0, 0], SWAY);\n  const written = INITIAL.map((item) => ({ d: item.d, o: item.o, dash: item.dash }));\n  let frame = start,\n    raf = 0,\n    last = 0,\n    parallax = false;\n\n  const paint = () => {\n    const ox = sway.pos[0]! * DRIFT,\n      oy = sway.pos[1]! * DRIFT;\n    nodes.paths.forEach((node, i) => {\n      const g = geo[i]!.pos,\n        f = fx[i]!.pos,\n        w = written[i]!;\n      const { d, dash } = shapePath(KINDS[i]!, g, f[1]!, ox, oy);\n      const o = String(Math.round(Math.min(1, Math.max(0, f[0]!)) * 1000) / 1000);\n      if (d !== w.d) {\n        node.setAttribute(\"d\", d);\n        w.d = d;\n      }\n      if (o !== w.o) {\n        node.setAttribute(\"opacity\", o);\n        w.o = o;\n      }\n      if (dash !== w.dash) {\n        node.setAttribute(\"stroke-dasharray\", dash);\n        w.dash = dash;\n      }\n    });\n  };\n  const tick = (time: number) => {\n    raf = 0;\n    const now = time / 1000,\n      dt = last ? Math.min(0.034, Math.max(0, now - last)) : 1 / 60;\n    last = now;\n    let busy = advance(sway, now, dt);\n    for (let i = 0; i < geo.length; i++) busy = advance(geo[i]!, now, dt) || busy;\n    for (let i = 0; i < fx.length; i++) busy = advance(fx[i]!, now, dt) || busy;\n    paint();\n    if (busy) raf = requestAnimationFrame(tick);\n    else last = 0;\n  };\n  const kick = () => {\n    if (!raf) raf = requestAnimationFrame(tick);\n  };\n\n  const pointer = (x: number, y: number) => {\n    sway.queue = [];\n    sway.target.set(parallax ? [x, y] : [0, 0]);\n    sway.still = false;\n    kick();\n  };\n\n  return {\n    pointer,\n    go(next: Frame, sceneChanged: boolean, reduced: boolean) {\n      if (reduced) {\n        // Crossfade without travel: freeze the old drawing in a ghost layer, snap to the new one, and fade between them.\n        nodes.paths.forEach((node, i) => {\n          const ghost = nodes.ghosts[i]!;\n          ghost.setAttribute(\"d\", node.getAttribute(\"d\") ?? \"\");\n          ghost.setAttribute(\"opacity\", node.getAttribute(\"opacity\") ?? \"1\");\n          ghost.setAttribute(\"stroke-dasharray\", node.getAttribute(\"stroke-dasharray\") ?? \"none\");\n          ghost.dataset.tone = frame.prims[i]!.tone;\n        });\n        next.prims.forEach((p, i) => {\n          for (const [ch, values] of [\n            [geo[i]!, p.geo],\n            [fx[i]!, p.fx],\n          ] as const) {\n            ch.queue = [];\n            ch.target.set(values);\n            ch.pos.set(values);\n            ch.vel.fill(0);\n            ch.still = true;\n          }\n        });\n        frame = next;\n        paint();\n        animate(nodes.ghost, { opacity: [1, 0] }, { duration: motionTokens.duration.standard, ease: standard });\n        animate(nodes.main, { opacity: [0, 1] }, { duration: motionTokens.duration.standard, ease: standard });\n        return;\n      }\n      const now = performance.now() / 1000,\n        delays = sceneChanged ? DELAY : NUDGE;\n      next.prims.forEach((p, i) => {\n        const delay = delays[i]!,\n          f = fx[i]!;\n        geo[i]!.queue = [{ at: now + delay, values: p.geo }];\n        if (sceneChanged && p.redraw)\n          f.queue = [\n            { at: now, values: [p.fx[0], 0] },\n            { at: now + delay + 0.18, values: p.fx },\n          ];\n        else f.queue = [{ at: now + delay + (p.fx[1] > f.target[1]! + 0.01 ? 0.12 : 0), values: p.fx }];\n      });\n      frame = next;\n      kick();\n    },\n    setParallax(on: boolean) {\n      parallax = on;\n      if (!on) pointer(0, 0);\n    },\n    destroy() {\n      if (raf) cancelAnimationFrame(raf);\n      raf = 0;\n    },\n  };\n}\n\nexport function EmptyStates() {\n  const uid = useId();\n  const reduced = useReducedMotion() ?? false;\n  const [view, setView] = useState<View>({ scene: \"search\", phase: \"idle\", from: \"idle\" });\n  const [announcement, setAnnouncement] = useState(\"\");\n  const tabs = useRef<(HTMLButtonElement | null)[]>([]);\n  const paths = useRef<(SVGPathElement | null)[]>([]);\n  const ghosts = useRef<(SVGPathElement | null)[]>([]);\n  const mainLayer = useRef<SVGGElement>(null);\n  const ghostLayer = useRef<SVGGElement>(null);\n  const engine = useRef<ReturnType<typeof createEngine> | null>(null);\n  const shown = useRef({ scene: view.scene, phase: view.phase });\n  const pending = useRef({ timer: 0 });\n  const copyInner = useRef<HTMLDivElement>(null);\n  const copyHeight = useMotionValue<number | \"auto\">(\"auto\");\n  const armedUntil = useRef(0);\n\n  const frame = FRAMES[view.scene][view.phase];\n  const text = COPY[view.scene];\n  const copy: Copy =\n    view.phase === \"loading\"\n      ? { ...text[view.from === \"done\" ? \"done\" : \"idle\"], line: text.loading }\n      : text[view.phase === \"done\" ? \"done\" : \"idle\"];\n  const copyKey = `${copy.title}|${copy.line}`;\n\n  useEffect(() => {\n    const main = mainLayer.current,\n      ghost = ghostLayer.current;\n    const nodes = paths.current.filter(Boolean) as SVGPathElement[],\n      ghostNodes = ghosts.current.filter(Boolean) as SVGPathElement[];\n    if (!main || !ghost || nodes.length !== KINDS.length || ghostNodes.length !== KINDS.length) return;\n    const instance = createEngine({ paths: nodes, ghosts: ghostNodes, main, ghost }, first);\n    engine.current = instance;\n    const bag = pending.current;\n    return () => {\n      instance.destroy();\n      engine.current = null;\n      window.clearTimeout(bag.timer);\n    };\n  }, []);\n\n  useEffect(() => {\n    const fine = typeof window !== \"undefined\" && window.matchMedia(\"(hover: hover) and (pointer: fine)\").matches;\n    engine.current?.setParallax(fine && !reduced);\n  }, [reduced]);\n\n  useEffect(() => {\n    const prev = shown.current;\n    if (prev.scene === view.scene && prev.phase === view.phase) return;\n    shown.current = { scene: view.scene, phase: view.phase };\n    engine.current?.go(FRAMES[view.scene][view.phase], prev.scene !== view.scene, reduced);\n  }, [view.scene, view.phase, reduced]);\n\n  // The copy block springs to the height of the incoming text, so the action never jumps.\n  useLayoutEffect(() => {\n    armedUntil.current = performance.now() + 700;\n  }, [copyKey]);\n  useEffect(() => {\n    const node = copyInner.current;\n    if (!node || typeof ResizeObserver === \"undefined\") return;\n    let measured = false;\n    const observer = new ResizeObserver(([entry]) => {\n      if (!entry) return;\n      const next = entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height;\n      if (!measured || reduced || performance.now() > armedUntil.current) {\n        measured = true;\n        copyHeight.jump(next);\n        return;\n      }\n      animate(copyHeight, next, motionTokens.spring.smooth);\n    });\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [reduced, copyHeight]);\n\n  function choose(scene: SceneId) {\n    if (scene === view.scene) return;\n    window.clearTimeout(pending.current.timer);\n    setView({ scene, phase: \"idle\", from: \"idle\" });\n    setAnnouncement(\"\");\n  }\n\n  function act() {\n    const { scene, phase } = view;\n    if (phase === \"loading\") return;\n    const entry = COPY[scene];\n    if (scene === \"search\" || (phase === \"done\" && scene !== \"inbox\")) {\n      const next: Phase = phase === \"idle\" ? \"done\" : \"idle\";\n      setView({ scene, phase: next, from: phase });\n      setAnnouncement(\n        `${entry[next === \"done\" ? \"done\" : \"idle\"].title}. ${entry[next === \"done\" ? \"done\" : \"idle\"].line}`,\n      );\n      return;\n    }\n    setView({ scene, phase: \"loading\", from: phase });\n    setAnnouncement(entry.loading);\n    window.clearTimeout(pending.current.timer);\n    pending.current.timer = window.setTimeout(() => {\n      setView((current) =>\n        current.scene === scene && current.phase === \"loading\" ? { scene, phase: \"done\", from: \"loading\" } : current,\n      );\n      setAnnouncement(`${entry.done.title}. ${entry.done.line}`);\n    }, entry.wait);\n  }\n\n  function onTabKey(event: KeyboardEvent<HTMLButtonElement>) {\n    const index = SCENES.findIndex((item) => item.id === view.scene);\n    const next =\n      event.key === \"ArrowRight\"\n        ? (index + 1) % SCENES.length\n        : event.key === \"ArrowLeft\"\n          ? (index + SCENES.length - 1) % SCENES.length\n          : event.key === \"Home\"\n            ? 0\n            : event.key === \"End\"\n              ? SCENES.length - 1\n              : -1;\n    if (next < 0) return;\n    event.preventDefault();\n    choose(SCENES[next]!.id);\n    tabs.current[next]?.focus();\n  }\n\n  function onPointerMove(event: PointerEvent<HTMLDivElement>) {\n    if (event.pointerType !== \"mouse\") return;\n    const rect = event.currentTarget.getBoundingClientRect();\n    engine.current?.pointer(\n      ((event.clientX - rect.left) / rect.width - 0.5) * 2,\n      ((event.clientY - rect.top) / rect.height - 0.5) * 2,\n    );\n  }\n\n  const rise = reduced\n    ? { initial: { opacity: 0 }, exit: { opacity: 0, transition: { duration: motionTokens.duration.exit } } }\n    : {\n        initial: { opacity: 0, y: 8, filter: `blur(${motionTokens.blur.soft}px)` },\n        exit: {\n          opacity: 0,\n          y: -6,\n          filter: `blur(${motionTokens.blur.soft}px)`,\n          transition: { duration: motionTokens.duration.exit, ease: standard },\n        },\n      };\n  const shownText = { opacity: 1, y: 0, filter: \"blur(0px)\" };\n  const textIn = (delay: number) => ({\n    duration: reduced ? motionTokens.duration.standard : motionTokens.duration.considered,\n    ease: enter,\n    delay: reduced ? 0 : delay,\n  });\n\n  return (\n    <section className=\"@container w-full font-body tracking-body text-ink\" aria-label=\"Empty states\">\n      <div className=\"flex flex-col items-center gap-[clamp(20px,4cqi,32px)] rounded-[20px] border border-line bg-surface px-[clamp(16px,4cqi,40px)] py-[clamp(16px,5cqi,48px)]\">\n        <div\n          className=\"isolate grid w-[min(100%,460px)] grid-cols-[repeat(4,minmax(max-content,1fr))] gap-0.5 overflow-x-auto rounded-button border border-line bg-hover p-[3px] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\"\n          role=\"tablist\"\n          aria-label=\"Empty state\"\n        >\n          {SCENES.map((item, index) => {\n            const selected = item.id === view.scene;\n            return (\n              <button\n                key={item.id}\n                ref={(node) => {\n                  tabs.current[index] = node;\n                }}\n                id={`${uid}-tab-${item.id}`}\n                type=\"button\"\n                role=\"tab\"\n                aria-selected={selected}\n                aria-controls={`${uid}-panel`}\n                tabIndex={selected ? 0 : -1}\n                className=\"relative min-h-[30px] cursor-pointer rounded-button px-[clamp(8px,2.6cqi,14px)] text-sm leading-body font-medium whitespace-nowrap text-ink-3 [-webkit-tap-highlight-color:transparent] transition-[color] duration-fast ease-standard hover:text-ink aria-selected:text-ink motion-reduce:transition-none @max-[420px]:px-1.5 @max-[420px]:text-xs\"\n                onClick={() => choose(item.id)}\n                onKeyDown={onTabKey}\n              >\n                {selected ? (\n                  <motion.span\n                    layoutId={`${uid}-highlight`}\n                    className=\"absolute inset-0 -z-1 rounded-[inherit] border border-line bg-surface shadow-resting\"\n                    transition={reduced ? { duration: 0 } : motionTokens.spring.morph}\n                  />\n                ) : null}\n                <span className=\"relative\">{item.tab}</span>\n              </button>\n            );\n          })}\n        </div>\n\n        <div\n          className=\"flex w-full max-w-[480px] flex-col items-center gap-5\"\n          role=\"tabpanel\"\n          id={`${uid}-panel`}\n          aria-labelledby={`${uid}-tab-${view.scene}`}\n        >\n          <div\n            className=\"aspect-[320/216] w-[min(100%,360px)]\"\n            onPointerMove={onPointerMove}\n            onPointerLeave={() => engine.current?.pointer(0, 0)}\n          >\n            <svg\n              className=\"block size-full overflow-visible\"\n              viewBox={`0 0 ${VIEW.width} ${VIEW.height}`}\n              role=\"img\"\n              aria-label={frame.label}\n            >\n              <g ref={ghostLayer} opacity={0} aria-hidden=\"true\">\n                {KINDS.map((kind, i) => (\n                  <path\n                    key={i}\n                    ref={(node) => {\n                      ghosts.current[i] = node;\n                    }}\n                    className={shapeClass(kind)}\n                    data-kind={kind}\n                    d=\"\"\n                  />\n                ))}\n              </g>\n              <g ref={mainLayer}>\n                {INITIAL.map((item, i) => (\n                  <path\n                    key={i}\n                    ref={(node) => {\n                      paths.current[i] = node;\n                    }}\n                    className={shapeClass(KINDS[i]!)}\n                    data-kind={KINDS[i]}\n                    data-tone={frame.prims[i]!.tone}\n                    d={item.d}\n                    opacity={item.o}\n                    strokeDasharray={item.dash}\n                    style={{ transitionDelay: `${DELAY[i]}s` }}\n                  />\n                ))}\n              </g>\n            </svg>\n          </div>\n\n          <div className=\"flex w-full flex-col items-center gap-4 text-center\">\n            <motion.div className=\"w-full\" style={{ height: copyHeight }}>\n              <div ref={copyInner} className=\"grid gap-1.5 px-2\">\n                <h2 className=\"relative m-0 text-lg leading-body font-medium tracking-body text-balance tabular-nums @min-[560px]:text-xl\">\n                  <AnimatePresence mode=\"popLayout\" initial={false}>\n                    <motion.span\n                      key={copy.title}\n                      className=\"block\"\n                      initial={rise.initial}\n                      animate={shownText}\n                      exit={rise.exit}\n                      transition={textIn(0.06)}\n                    >\n                      {copy.title}\n                    </motion.span>\n                  </AnimatePresence>\n                </h2>\n                <p className=\"relative mx-auto my-0 max-w-[40ch] text-sm leading-body text-ink-2 text-pretty\">\n                  <AnimatePresence mode=\"popLayout\" initial={false}>\n                    <motion.span\n                      key={copy.line}\n                      className=\"block\"\n                      initial={rise.initial}\n                      animate={shownText}\n                      exit={rise.exit}\n                      transition={textIn(0.06 + motionTokens.stagger.word)}\n                    >\n                      {copy.line}\n                    </motion.span>\n                  </AnimatePresence>\n                </p>\n              </div>\n            </motion.div>\n            <Button size=\"sm\" onClick={act} loading={view.phase === \"loading\"}>\n              {copy.action}\n            </Button>\n          </div>\n          <p className=\"sr-only\" aria-live=\"polite\">\n            {announcement}\n          </p>\n        </div>\n      </div>\n    </section>\n  );\n}\n\nexport default EmptyStates;\n",
      "type": "registry:component",
      "target": "@components/iris/blocks/empty-states/empty-states.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/empty-states",
  "meta": {
    "kind": "block",
    "group": "Blocks",
    "export": "EmptyStates",
    "docs": "https://irisui.dev/components/empty-states"
  }
}
