{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "media",
  "type": "registry:ui",
  "title": "Media",
  "description": "One picture in a frame with a fixed shape, so the page never jumps when it arrives: still surface-2 while it loads, a fade in on the instant tier, and its words when it fails. A hairline lies over the image, and with onOpen the frame is a button that opens a Lightbox.",
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/icons.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/media.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/media 2026.11.06. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Media: one picture in a frame, such as a screenshot, a photograph or a poster, that keeps its shape while it loads\n * and says so when it fails (components.md, \"Media\"). The frame every image in litt sits in: a card cover, a carousel\n * slide, a lightbox.\n *\n * - Frame: a fixed shape (16/9, 8/5, 4/3, 1/1, 4/5, or a number such as 1.5 for an image's own), so the page never\n *   jumps when the image arrives. `auto` lets the image set the height: pass its width and height then, so the space\n *   is kept. The surface radius by default, the control radius inside a padded card, or none. An inset --litt-line\n *   hairline lies over the image, so a white image keeps its edge, as an avatar's photo does.\n * - Loading: the frame is surface-2 and still, the skeleton's look. No pulse, no shimmer, no blur-up. When the image\n *   has loaded it fades in on the instant tier (opacity only). Under reduced motion it is simply there.\n * - Failed: the frame stays its size and says \"Image didn't load\" beside an image mark, in --litt-fg-subtle; in a frame too\n *   narrow for the words only the mark shows. The alt text still names it.\n * - Lazy by default: `loading=\"lazy\"` and `decoding=\"async\"`. `priority` loads at once, with high fetch priority, for\n *   the one image the page opens on (a hero).\n * - Open: with `onOpen` the frame is a button that opens a larger view (a Lightbox), named by the alt text and marked\n *   aria-haspopup=\"dialog\". Hover strengthens the hairline to --litt-line-strong on the instant tier and the pointer is a\n *   zoom; nothing lifts or scales, and hover reveals nothing, so touch loses nothing. Focus draws the 2px ring at a\n *   2px offset around the frame.\n * - Caption: a line under the frame, at the small size in --litt-fg-subtle, 8px below it, in a figure.\n * - Children replace the image: a <video controls>, or drawn art. They fill the frame; Media keeps the shape, the\n *   radius and the hairline, and reads them as loaded. A video never autoplays (foundations.md, Motion).\n */\nimport { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from \"react\";\nimport { ImageIcon } from \"./icons\";\n\nexport type MediaRatio = \"16/9\" | \"8/5\" | \"4/3\" | \"1/1\" | \"4/5\" | \"auto\";\n\n/* Literal classes, because Tailwind only generates what it can read. */\nconst RATIO: Record<Exclude<MediaRatio, \"auto\">, string> = {\n  \"16/9\": \"aspect-video\",\n  \"8/5\": \"aspect-[8/5]\",\n  \"4/3\": \"aspect-[4/3]\",\n  \"1/1\": \"aspect-square\",\n  \"4/5\": \"aspect-[4/5]\",\n};\nconst RADIUS = { surface: \"rounded-litt-surface\", control: \"rounded-litt-control\", none: \"\" } as const;\n\ntype Status = \"loading\" | \"loaded\" | \"failed\";\n\nexport type MediaProps = {\n  /** The image's address. Leave it out when you pass children instead. */\n  src?: string;\n  /** What the image shows, in a sentence: \"The Halo home page on a phone\". Pass \"\" for an image that only decorates; it is then hidden from screen readers. */\n  alt: string;\n  /** Other sizes of the same image, for the browser to pick from. Pair it with sizes. */\n  srcSet?: string;\n  /** How wide the image is drawn, for srcSet: \"(min-width: 64rem) 50vw, 100vw\". */\n  sizes?: string;\n  /** The image's own width and height in pixels. With ratio=\"auto\" they keep its space before it loads. */\n  width?: number;\n  height?: number;\n  /** The frame's shape: 16/9, 8/5 (the default, a card cover's), 4/3, 1/1, 4/5, a number such as 1.5 (width over height), or auto to let the image set the height. */\n  ratio?: MediaRatio | number;\n  /** cover fills the frame and crops the edges; contain shows the whole image on surface-2. */\n  fit?: \"cover\" | \"contain\";\n  /** surface (8px) on its own in the page, control (4px) inside a card that pads it by 4px, none at the edge of a full-bleed area. */\n  radius?: \"surface\" | \"control\" | \"none\";\n  /** Load at once with high priority. Only for the image the page opens on, such as a hero; every other image waits until it is near the screen. */\n  priority?: boolean;\n  /** A line under the image. It wraps the frame in a figure. */\n  caption?: ReactNode;\n  /** Makes the image a button that opens a larger view, such as a Lightbox. The alt text names the button, so it must not be empty. */\n  onOpen?: () => void;\n  /** Called with the image element once it has loaded, for its natural size. */\n  onLoad?: (image: HTMLImageElement) => void;\n  /** Replaces the image: a <video controls> or drawn art, sized to fill (size-full). */\n  children?: ReactNode;\n  /** Extra classes for the outermost element (the figure, the button or the frame), such as a width. */\n  className?: string;\n  /** Inline style for the outermost element, such as a computed width. */\n  style?: CSSProperties;\n};\n\n/** One picture in a frame that keeps its shape while it loads, says so when it fails, and can open a larger view. */\nexport function Media({\n  src,\n  alt,\n  srcSet,\n  sizes,\n  width,\n  height,\n  ratio = \"8/5\",\n  fit = \"cover\",\n  radius = \"surface\",\n  priority = false,\n  caption,\n  onOpen,\n  onLoad,\n  children,\n  className = \"\",\n  style,\n}: MediaProps) {\n  const img = useRef<HTMLImageElement>(null);\n  /* The status belongs to one address: a new src starts loading again. */\n  const [state, setState] = useState<{ src?: string; status: Status }>({ src, status: \"loading\" });\n  const status: Status = children !== undefined ? \"loaded\" : state.src === src ? state.status : \"loading\";\n  const loaded = (el: HTMLImageElement) => {\n    setState({ src, status: \"loaded\" });\n    onLoad?.(el);\n  };\n  const failed = () => setState({ src, status: \"failed\" });\n\n  /* An image the browser already had (a cached one, or one that loaded before hydration) fires no load event.\n     decode() settles either way, and rejects for an image that is broken. */\n  useLayoutEffect(() => {\n    const el = img.current;\n    if (!el || !el.complete) return;\n    let live = true;\n    el.decode().then(\n      () => live && loaded(el),\n      () => live && failed(),\n    );\n    return () => {\n      live = false;\n    };\n  }, [src]); // once per address\n\n  const auto = ratio === \"auto\";\n  const shape = typeof ratio === \"number\" ? \"\" : auto ? \"\" : RATIO[ratio];\n  const corners = RADIUS[radius];\n  const interactive = onOpen !== undefined;\n\n  const frame = (\n    <span\n      className={[\n        \"@container relative block overflow-hidden bg-litt-surface-2\",\n        corners,\n        shape,\n        auto && status === \"failed\" ? \"min-h-32\" : \"\",\n        caption || interactive ? \"\" : className,\n      ].join(\" \")}\n      style={{ ...(typeof ratio === \"number\" ? { aspectRatio: ratio } : {}), ...(caption || interactive ? {} : style) }}\n    >\n      {children !== undefined ? (\n        <span className={auto ? \"block\" : \"absolute inset-0 block *:size-full\"}>{children}</span>\n      ) : (\n        src !== undefined && (\n          <img\n            ref={img}\n            src={src}\n            srcSet={srcSet}\n            sizes={sizes}\n            width={width}\n            height={height}\n            alt={alt}\n            loading={priority ? \"eager\" : \"lazy\"}\n            fetchPriority={priority ? \"high\" : undefined}\n            decoding=\"async\"\n            draggable={false}\n            onLoad={(e) => loaded(e.currentTarget)}\n            onError={failed}\n            className={[\n              auto ? \"block h-auto w-full\" : \"absolute inset-0 size-full\",\n              fit === \"contain\" ? \"object-contain\" : \"object-cover\",\n              \"transition-opacity duration-(--litt-dur-instant) ease-litt\",\n              status === \"loaded\" ? \"opacity-100\" : \"opacity-0\",\n            ].join(\" \")}\n          />\n        )\n      )}\n      {status === \"failed\" && (\n        <span aria-hidden=\"true\" className=\"absolute inset-0 flex items-center justify-center gap-2 p-4 text-litt-fg-subtle\">\n          <ImageIcon className=\"shrink-0\" />\n          <span className=\"truncate text-litt-small @max-[10rem]:hidden\">Image didn&apos;t load</span>\n        </span>\n      )}\n      {/* The hairline lies over the image, so a white image keeps its edge. An open button strengthens it on hover. */}\n      <span\n        aria-hidden=\"true\"\n        className={[\n          \"pointer-events-none absolute inset-0 shadow-[inset_0_0_0_1px_var(--litt-line)] in-[[data-material=flat]]:shadow-none in-[[data-material=flat]]:group-hover/media:shadow-none in-[[data-material=flat]]:group-active/media:shadow-none\",\n          corners,\n          interactive\n            ? \"transition-[box-shadow] duration-(--litt-dur-instant) ease-litt group-hover/media:shadow-[inset_0_0_0_1px_var(--litt-line-strong)] group-active/media:shadow-[inset_0_0_0_1px_var(--litt-line-strong)]\"\n            : \"\",\n        ].join(\" \")}\n      />\n    </span>\n  );\n\n  const body = interactive ? (\n    <button\n      type=\"button\"\n      aria-haspopup=\"dialog\"\n      onClick={onOpen}\n      className={[\n        \"group/media relative block w-full cursor-zoom-in text-left [-webkit-tap-highlight-color:transparent]\",\n        \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-litt-focus\",\n        corners,\n        caption ? \"\" : className,\n      ].join(\" \")}\n      style={caption ? undefined : style}\n    >\n      {frame}\n    </button>\n  ) : (\n    frame\n  );\n\n  if (!caption) return body;\n  return (\n    <figure className={\"grid min-w-0 content-start gap-2 \" + className} style={style}>\n      {body}\n      <figcaption className=\"text-litt-small text-litt-fg-subtle\">{caption}</figcaption>\n    </figure>\n  );\n}\n",
      "target": "components/litt/media.tsx"
    }
  ],
  "docs": "Import Media from components/litt/media. Pass src, a sentence of alt text and a ratio; priority only for the image the page opens on. Use it for every image in the page flow instead of a bare img.",
  "meta": {
    "version": "2026.11.06",
    "released": "2026-11-06",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
