{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "lazy-load",
  "type": "registry:ui",
  "title": "Lazy Load",
  "description": "A deferred-content container that fetches its own contents after the page paints (hx-trigger=\"load\") and swaps them in, with a reserved-space placeholder to prevent layout shift (CLS). Pairs with Skeleton for slow dashboard panels and per-tab content. Zero JS of its own — htmx owns the request and the IntersectionObserver.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/lazy-load.tsx",
      "type": "registry:ui",
      "target": "components/ui/lazy-load.tsx",
      "content": "/** @jsxImportSource hono/jsx */\nimport type { PropsWithChildren } from \"hono/jsx\"\nimport { cn, type ClassValue } from \"@/registry/lib/cn\"\n\n// Lazy Load — shadcn-htmx, htmx v4 + Tailwind v4.\n//\n// A deferred-content container. It renders a placeholder immediately, then\n// fetches its own contents after the page paints and swaps them in. The\n// placeholder reserves vertical space so the swap does not push the page\n// around (Cumulative Layout Shift). Pair it with <Skeleton> for slow\n// dashboard panels or per-tab content that you don't want to block first\n// paint on.\n//\n// shadcn/ui has no \"lazy load\" widget — it is a hypermedia loading pattern,\n// not a Radix primitive, so there is no React source of truth to mirror. We\n// build it straight from the htmx v4 lazy-load pattern and the platform docs:\n//   repos/htmx/www/src/content/patterns/01-loading/03-lazy-load.md\n//     (the canonical pattern: a placeholder div with hx-get + hx-trigger=\"load\";\n//      htmx swaps the response in when it arrives. The \"Layout shift\" note\n//      says to reserve space with min-height to protect the Lighthouse/CLS\n//      score — that is exactly what `reserve`/min-height does here. The\n//      \"Infinite loops\" note warns against echoing hx-trigger=\"load\" in the\n//      response: our default hx-swap=\"innerHTML\" keeps the trigger host in\n//      place, so the response body must NOT repeat the trigger.)\n//   repos/htmx/www/src/content/reference/01-attributes/06-hx-trigger.md\n//     (verified v4: synthetic `load` fires when the element enters the DOM —\n//      \"Useful for lazy-loading content\"; `revealed` fires when it scrolls\n//      into the viewport; use `intersect once` instead when the element lives\n//      inside an `overflow-y: scroll` container.)\n//   repos/htmx/www/src/content/reference/01-attributes/07-hx-swap.md\n//     (verified v4: `innerHTML` — the default — replaces the *contents* of the\n//      target, leaving our reserved-space wrapper in the DOM; `outerHTML`\n//      replaces the wrapper wholesale, for when the response brings its own\n//      box. We default to innerHTML so the reserved height survives the swap.)\n//   repos/htmx/www/src/content/reference/01-attributes/01-hx-get.md\n//     (the request the container issues for its own contents.)\n//   repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-busy/index.md\n//     (verified: aria-busy=\"true\" on a region tells AT \"this content is still\n//      being modified — wait before announcing\". The role=\"status\" live region\n//      announces once the busy content settles. Both ride away with the\n//      innerHTML swap because they live on the wrapper, which is fine — htmx\n//      flips nothing for us, so we keep them simple and static.)\n//   repos/mdn/files/en-us/web/api/intersection_observer_api/index.md\n//     (the platform API htmx's revealed/intersect triggers are built on —\n//      \"implementing infinite-scrolling websites … as you scroll\".)\n//\n// Zero JS of our own: htmx owns the request and the IntersectionObserver; the\n// in-flight state is the placeholder we render. No emulation of any platform\n// feature — the trigger IS htmx's `load` event / the platform's\n// IntersectionObserver.\n\nexport type LazyLoadTrigger = \"load\" | \"revealed\" | \"intersect\"\nexport type LazyLoadSwap = \"innerHTML\" | \"outerHTML\"\n\n// Maps our trigger prop to the literal hx-trigger value. `intersect once`\n// fires a single time when the element first crosses the viewport (the\n// overflow-container form); `revealed` is the page-viewport form; `load`\n// fires immediately on insertion.\nconst TRIGGER_MAP: Record<LazyLoadTrigger, string> = {\n  load: \"load\",\n  revealed: \"revealed\",\n  intersect: \"intersect once\",\n}\n\n// The reserved-space wrapper. min-h keeps a stable box so the swap doesn't\n// shift the page; centred so the default placeholder/spinner sits middle.\nconst rootClasses =\n  \"flex w-full items-center justify-center text-sm text-muted-foreground\"\n\n// Default placeholder: a muted inline spinner + caption. It is the visible\n// \"loading\" state until the response swaps in. Pass children to override it\n// (e.g. a composed <Skeleton> silhouette).\nfunction Spinner() {\n  return (\n    <span\n      class=\"size-4 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground\"\n      aria-hidden=\"true\"\n    />\n  )\n}\n\ntype LazyLoadProps = PropsWithChildren<{\n  // URL to fetch this container's contents from. Sets hx-get for you.\n  src?: string\n  // When the fetch fires. \"load\" → immediately on insertion (deferred but\n  // eager); \"revealed\" → when scrolled into the page viewport; \"intersect\"\n  // → first viewport crossing inside an overflow-y:scroll container.\n  trigger?: LazyLoadTrigger\n  // How the response lands. \"innerHTML\" (default) replaces the contents and\n  // keeps this reserved-space wrapper; \"outerHTML\" replaces the wrapper.\n  swap?: LazyLoadSwap\n  // Reserved minimum height (any CSS length, e.g. \"12rem\" or \"200px\"). Sets\n  // min-height inline so the box holds its size before content arrives —\n  // prevents layout shift (CLS).\n  reserve?: string\n  // Accessible name for the loading region (\"Loading sales report\").\n  ariaLabel?: string\n  class?: ClassValue\n  id?: string\n  // htmx / data / aria attributes ride onto the container. Forwarded so call\n  // sites can override hx-target, add hx-indicator, hx-vals, hx-swap timing,\n  // etc.\n  [key: `hx-${string}`]: any\n  [key: `data-${string}`]: any\n  [key: `aria-${string}`]: any\n}>\n\nexport function LazyLoad(props: LazyLoadProps) {\n  const {\n    src,\n    trigger = \"load\",\n    swap = \"innerHTML\",\n    reserve,\n    ariaLabel = \"Loading\",\n    class: className,\n    id,\n    children,\n    ...rest\n  } = props as any\n\n  return (\n    <div\n      id={id}\n      data-slot=\"lazy-load\"\n      data-trigger={trigger}\n      role=\"status\"\n      aria-busy=\"true\"\n      aria-label={ariaLabel}\n      hx-get={src}\n      hx-trigger={TRIGGER_MAP[trigger as LazyLoadTrigger]}\n      hx-swap={swap}\n      style={reserve ? `min-height:${reserve}` : undefined}\n      class={cn(rootClasses, className)}\n      {...rest}\n    >\n      {children ?? (\n        <span class=\"flex items-center gap-2\">\n          <Spinner />\n          Loading…\n        </span>\n      )}\n    </div>\n  )\n}\n"
    },
    {
      "path": "registry/jinja2/lazy-load.html",
      "type": "registry:file",
      "target": "templates/components/lazy-load.html",
      "content": "{# Lazy Load macro — shadcn-htmx, htmx v4 + Tailwind v4.\n   Mirrors registry/ui/lazy-load.tsx.\n\n   A deferred-content container. It renders a placeholder immediately, then\n   fetches its own contents after the page paints (hx-get + hx-trigger=\"load\")\n   and swaps them in. `reserve` sets min-height so the swap does not shift the\n   page (CLS). trigger=\"load\" fires on insertion; \"revealed\" fires on scroll\n   into the page viewport; \"intersect\" fires once inside an overflow-y:scroll\n   container. swap=\"innerHTML\" (default) keeps this wrapper; \"outerHTML\"\n   replaces it. Default hx-swap=\"innerHTML\" keeps the trigger host, so the\n   server response must NOT repeat hx-trigger=\"load\" (avoids an infinite loop).\n\n   Sources cited in lazy-load.tsx:\n     repos/htmx/.../patterns/01-loading/03-lazy-load.md\n     repos/htmx/.../reference/01-attributes/{01-hx-get,06-hx-trigger,07-hx-swap}.md\n     repos/mdn/.../web/accessibility/aria/reference/attributes/aria-busy/index.md\n     repos/mdn/.../web/api/intersection_observer_api/index.md\n\n   Usage:\n     {% from \"components/lazy-load.html\" import lazy_load %}\n\n     {{ lazy_load(src=\"/dashboard/sales\", reserve=\"12rem\", aria_label=\"Loading sales\") }}\n     {{ lazy_load(src=\"/comments\", trigger=\"revealed\", reserve=\"8rem\") }} #}\n\n{% macro lazy_load(src=none, trigger=\"load\", swap=\"innerHTML\", reserve=none, aria_label=\"Loading\", id=none, extra_class=\"\", **attrs) %}\n<div\n  {%- if id %} id=\"{{ id }}\"{% endif %}\n  data-slot=\"lazy-load\" data-trigger=\"{{ trigger }}\"\n  role=\"status\" aria-busy=\"true\" aria-label=\"{{ aria_label }}\"\n  {% if src %}hx-get=\"{{ src }}\"{% endif %}\n  hx-trigger=\"{{ 'intersect once' if trigger == 'intersect' else 'revealed' if trigger == 'revealed' else 'load' }}\" hx-swap=\"{{ swap }}\"\n  {% if reserve %}style=\"min-height:{{ reserve }}\"{% endif %}\n  class=\"flex w-full items-center justify-center text-sm text-muted-foreground {{ extra_class }}\"\n  {%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}=\"{{ v }}\"{% endfor -%}\n>{% if caller %}{{ caller() }}{% else %}<span class=\"flex items-center gap-2\"><span class=\"size-4 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground\" aria-hidden=\"true\"></span>Loading…</span>{% endif %}</div>\n{% endmacro %}\n"
    },
    {
      "path": "registry/go-templates/lazy-load.tmpl",
      "type": "registry:file",
      "target": "components/lazy-load.tmpl",
      "content": "{{/* Lazy Load template — shadcn-htmx, htmx v4 + Tailwind v4.\n     Mirrors registry/ui/lazy-load.tsx.\n\n     A deferred-content container. It renders a placeholder immediately, then\n     fetches its own contents after the page paints (hx-get + hx-trigger=\"load\")\n     and swaps them in. Reserve sets min-height so the swap does not shift the\n     page (CLS). Trigger \"load\" fires on insertion; \"revealed\" fires on scroll\n     into the page viewport; \"intersect\" fires once inside an overflow-y:scroll\n     container. Swap \"innerHTML\" (default) keeps this wrapper; \"outerHTML\"\n     replaces it. The default hx-swap=\"innerHTML\" keeps the trigger host, so\n     the server response must NOT repeat hx-trigger=\"load\" (infinite loop).\n\n     Sources cited in lazy-load.tsx:\n       repos/htmx/.../patterns/01-loading/03-lazy-load.md\n       repos/htmx/.../reference/01-attributes/{01-hx-get,06-hx-trigger,07-hx-swap}.md\n       repos/mdn/.../web/accessibility/aria/reference/attributes/aria-busy/index.md\n       repos/mdn/.../web/api/intersection_observer_api/index.md\n\n     Usage:\n       {{template \"lazy_load\" (dict \"Src\" \"/dashboard/sales\" \"Reserve\" \"12rem\" \"AriaLabel\" \"Loading sales\")}}\n       {{template \"lazy_load\" (dict \"Src\" \"/comments\" \"Trigger\" \"revealed\" \"Reserve\" \"8rem\")}} */}}\n\n{{define \"lazy_load\"}}\n{{- $trigger := or .Trigger \"load\" -}}\n{{- $swap := or .Swap \"innerHTML\" -}}\n<div {{if .ID}}id=\"{{.ID}}\"{{end}}\n     data-slot=\"lazy-load\" data-trigger=\"{{$trigger}}\"\n     role=\"status\" aria-busy=\"true\" aria-label=\"{{or .AriaLabel \"Loading\"}}\"\n     {{if .Src}}hx-get=\"{{.Src}}\"{{end}}\n     hx-trigger=\"{{if eq $trigger \"intersect\"}}intersect once{{else if eq $trigger \"revealed\"}}revealed{{else}}load{{end}}\" hx-swap=\"{{$swap}}\"\n     {{if .Reserve}}style=\"min-height:{{.Reserve}}\"{{end}}\n     class=\"flex w-full items-center justify-center text-sm text-muted-foreground {{.Class}}\">{{if .Body}}{{htmlSafe .Body}}{{else}}<span class=\"flex items-center gap-2\"><span class=\"size-4 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground\" aria-hidden=\"true\"></span>Loading…</span>{{end}}</div>\n{{end}}\n"
    },
    {
      "path": "registry/phoenix/lazy_load.ex",
      "type": "registry:file",
      "target": "lib/my_app_web/components/lazy_load.ex",
      "content": "defmodule ShadcnHtmx.Components.LazyLoad do\n  @moduledoc \"\"\"\n  Lazy Load — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.\n\n  A deferred-content container. It renders a placeholder immediately, then\n  fetches its own contents after the page paints (`hx-get` + `hx-trigger=\"load\"`)\n  and swaps them in. `reserve` sets `min-height` so the swap does not shift the\n  page (Cumulative Layout Shift). Pair it with `<.skeleton>` for slow dashboard\n  panels or per-tab content.\n\n    * `trigger=\"load\"` — fires immediately when the element enters the DOM.\n    * `trigger=\"revealed\"` — fires when it scrolls into the page viewport.\n    * `trigger=\"intersect\"` — fires once inside an `overflow-y: scroll`\n      container (`intersect once`).\n    * `swap=\"innerHTML\"` (default) keeps this reserved-space wrapper; the server\n      response must NOT repeat `hx-trigger=\"load\"` or it loops. `swap=\"outerHTML\"`\n      replaces the wrapper wholesale.\n\n  Sources (read, not copied) — see registry/ui/lazy-load.tsx:\n    repos/htmx/.../patterns/01-loading/03-lazy-load.md\n    repos/htmx/.../reference/01-attributes/{01-hx-get,06-hx-trigger,07-hx-swap}.md\n    repos/mdn/.../web/accessibility/aria/reference/attributes/aria-busy/index.md\n    repos/mdn/.../web/api/intersection_observer_api/index.md\n\n  ## Examples\n\n      <.lazy_load src={~p\"/dashboard/sales\"} reserve=\"12rem\" aria-label=\"Loading sales\" />\n\n      <.lazy_load src={~p\"/comments\"} trigger=\"revealed\" reserve=\"8rem\" />\n  \"\"\"\n\n  use Phoenix.Component\n\n  attr :src, :string, default: nil\n  attr :trigger, :string, default: \"load\", values: ~w(load revealed intersect)\n  attr :swap, :string, default: \"innerHTML\", values: ~w(innerHTML outerHTML)\n  attr :reserve, :string, default: nil\n  attr :\"aria-label\", :string, default: \"Loading\"\n  attr :class, :string, default: nil\n  attr :rest, :global\n  slot :inner_block\n\n  def lazy_load(assigns) do\n    ~H\"\"\"\n    <div\n      data-slot=\"lazy-load\"\n      data-trigger={@trigger}\n      role=\"status\"\n      aria-busy=\"true\"\n      aria-label={assigns[:\"aria-label\"]}\n      hx-get={@src}\n      hx-trigger={\n        case @trigger do\n          \"intersect\" -> \"intersect once\"\n          \"revealed\" -> \"revealed\"\n          _ -> \"load\"\n        end\n      }\n      hx-swap={@swap}\n      style={@reserve && \"min-height:#{@reserve}\"}\n      class={[\"flex w-full items-center justify-center text-sm text-muted-foreground\", @class]}\n      {@rest}\n    >\n      <%= if @inner_block != [] do %>\n        {render_slot(@inner_block)}\n      <% else %>\n        <span class=\"flex items-center gap-2\">\n          <span\n            class=\"size-4 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground\"\n            aria-hidden=\"true\"\n          />\n          Loading…\n        </span>\n      <% end %>\n    </div>\n    \"\"\"\n  end\nend\n"
    },
    {
      "path": "registry/html/lazy-load.html",
      "type": "registry:file",
      "target": "snippets/lazy-load.html",
      "content": "<!--\n  shadcn-htmx — raw HTML lazy-load snippet.\n  A deferred-content container. It renders a placeholder immediately, then\n  fetches its own contents after the page paints (hx-get + hx-trigger=\"load\")\n  and swaps them in. The inline style=\"min-height:…\" reserves vertical space\n  so the swap does not shift the page (Cumulative Layout Shift).\n\n  Triggers:\n    - hx-trigger=\"load\"          → fires immediately on insertion (deferred,\n                                    but eager — the default).\n    - hx-trigger=\"revealed\"      → fires when scrolled into the page viewport.\n    - hx-trigger=\"intersect once\"→ fires once inside an overflow-y:scroll box.\n\n  Swap: hx-swap=\"innerHTML\" (default) replaces the contents and keeps this\n  reserved-space wrapper in the DOM — so the server response must NOT repeat\n  hx-trigger=\"load\" or it loops (see the htmx \"Infinite loops\" note). Use\n  hx-swap=\"outerHTML\" if the response brings its own container.\n\n  role=\"status\" + aria-busy=\"true\" tell assistive tech the region is still\n  loading. Relies only on the theme tokens in styles.css — no JS of its own.\n\n  Sources (read, not copied):\n    repos/htmx/.../patterns/01-loading/03-lazy-load.md\n    repos/htmx/.../reference/01-attributes/{01-hx-get,06-hx-trigger,07-hx-swap}.md\n    repos/mdn/.../web/accessibility/aria/reference/attributes/aria-busy/index.md\n    repos/mdn/.../web/api/intersection_observer_api/index.md\n-->\n\n<!-- On insertion it GETs /dashboard/sales and replaces its own contents with\n     the response. The reserved 12rem height holds the box steady until then. -->\n<div data-slot=\"lazy-load\" data-trigger=\"load\"\n     role=\"status\" aria-busy=\"true\" aria-label=\"Loading sales report\"\n     hx-get=\"/dashboard/sales\" hx-trigger=\"load\" hx-swap=\"innerHTML\"\n     style=\"min-height:12rem\"\n     class=\"flex w-full items-center justify-center text-sm text-muted-foreground\">\n  <span class=\"flex items-center gap-2\">\n    <span class=\"size-4 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground\" aria-hidden=\"true\"></span>\n    Loading…\n  </span>\n</div>\n"
    }
  ]
}
