{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"image","type":"registry:ui","title":"Image","description":"A picture that never pops in: its box breathes while it loads, it fades in, and a new picture crossfades over the old one with a spinner meanwhile.","dependencies":["lucide-react"],"registryDependencies":["https://dashral.com/r/utils.json","https://dashral.com/r/lib-locale.json","https://dashral.com/r/lib-motion.json","https://dashral.com/r/spinner.json"],"files":[{"path":"registry/dashral/image.tsx","type":"registry:file","target":"components/dashral/image.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\nimport { ImageOffIcon } from \"lucide-react\"\n\nimport { useLocale } from \"./lib/locale\"\nimport { reducedMotion } from \"./lib/motion\"\nimport { cn } from \"./lib/utils\"\nimport { Spinner } from \"./spinner\"\n\ntype ImageProps = Omit<React.ComponentProps<\"img\">, \"src\" | \"alt\" | \"children\"> & {\n  src: string\n  /** Describes the picture; \"\" for one that's only decoration. */\n  alt: string\n  /**\n   * A fixed shape, width over height (16 / 9, 1): the box keeps it and the picture fills it. Without it (and without\n   * fill) the picture takes its own shape, as an img does.\n   */\n  ratio?: number\n  /** The picture fills a box you size yourself with className (\"size-10\", \"h-40 w-full\"). */\n  fill?: boolean\n  /** In a fixed box: \"cover\" fills it and crops what doesn't fit, \"contain\" shows the whole picture inside it. */\n  fit?: \"cover\" | \"contain\"\n  /** In place of the picture when it can't be loaded; a quiet broken-picture mark by default. */\n  fallback?: React.ReactNode\n  /**\n   * Your image component (Next.js Image) in place of img; it gets src, alt, className and the rest. In a fixed box\n   * (ratio or fill) it gets `fill` instead of width and height, as Next.js Image wants; for its own shape, pass width\n   * and height.\n   */\n  imageAs?: React.ElementType\n  classNames?: { image?: string }\n}\n\n/**\n * A picture that never pops in, in one tag: `<Image src={photo} alt=\"The valley at dawn\" ratio={16 / 9} />`. While\n * it's on its way its box breathes softly; once it has loaded it fades in. Give it another src and it answers at\n * once: a spinner comes over the picture still showing, and the new one fades in over it the moment it's there (one\n * the browser already has fades in straight away). A picture that can't be loaded leaves a quiet mark, never a\n * broken icon. className sizes and rounds the box; classNames.image styles the picture (a hover zoom,\n * `group-hover:scale-105`, glides with no transition of your own); the img's own props (srcSet, sizes, loading,\n * onLoad) pass through.\n */\nfunction Image({\n  src,\n  alt,\n  ratio,\n  fill = false,\n  fit = \"cover\",\n  fallback,\n  imageAs: Img = \"img\",\n  width,\n  height,\n  className,\n  classNames,\n  style,\n  onLoad,\n  onError,\n  ...props\n}: ImageProps) {\n  const { t } = useLocale()\n  // The picture on show, which stays until the one asked for has loaded and faded in over it.\n  const [shown, setShown] = React.useState(src)\n  const [loaded, setLoaded] = React.useState<Record<string, true>>({})\n  const [failed, setFailed] = React.useState<Record<string, true>>({})\n  const coming = shown !== src\n  const there = Boolean(loaded[shown])\n  const broken = Boolean(failed[shown]) && !coming\n  const waiting = coming && !loaded[src]\n  // A fixed box (a ratio, or one you size) holds the picture; else the picture takes its own shape.\n  const boxed = fill || ratio !== undefined\n  const known = typeof width === \"number\" && typeof height === \"number\" && height > 0 ? width / height : undefined\n  // An image component of your own (Next.js Image) fills a fixed box with its fill prop, not width and height; a plain\n  // img has no such attribute, and gets width and height as given.\n  const size = Img !== \"img\" && boxed ? { fill: true } : { width, height }\n\n  function arrived(source: string) {\n    setLoaded((all) => (all[source] ? all : { ...all, [source]: true }))\n    // No fade to wait for: the new picture is the one on show at once.\n    if (source !== shown && reducedMotion()) setShown(source)\n  }\n\n  function gone(source: string) {\n    setFailed((all) => (all[source] ? all : { ...all, [source]: true }))\n    if (source !== shown) setShown(source)\n  }\n\n  return (\n    <span\n      data-slot=\"image\"\n      data-state={broken ? \"error\" : there && !waiting ? \"loaded\" : \"loading\"}\n      role={broken ? \"img\" : undefined}\n      aria-label={broken && alt ? alt : undefined}\n      className={cn(\"relative block overflow-hidden\", (!there || broken) && \"bg-muted\", className)}\n      // Its own shape isn't known until it loads: the box holds a place meanwhile (the picture's, from width and\n      // height, when they're given), so the page doesn't jump more than it must.\n      style={{ aspectRatio: boxed ? ratio : there && !known ? undefined : (known ?? 3 / 2), ...style }}\n    >\n      {!there && !broken && <span aria-hidden className=\"absolute inset-0 bg-foreground/8 motion-safe:animate-pulse\" />}\n      {broken ? (\n        <span aria-hidden className=\"absolute inset-0 grid place-items-center text-muted-foreground/70 [&_svg:not([class*='size-'])]:size-[min(1.5rem,45%)]\">\n          {fallback ?? <ImageOffIcon />}\n        </span>\n      ) : (\n        (coming ? [shown, src] : [shown]).map((source) => {\n          const current = source === shown\n          const ready = Boolean(loaded[source])\n          return (\n            <Img\n              key={source}\n              ref={(node: HTMLImageElement | null) => {\n                // It may have loaded, or failed, before React was listening (a cached picture, a server-rendered page).\n                if (!node?.complete) return\n                if (node.naturalWidth > 0) arrived(source)\n                else gone(source)\n              }}\n              src={source}\n              alt={source === src ? alt : \"\"}\n              {...size}\n              draggable={false}\n              {...props}\n              onLoad={(event: React.SyntheticEvent<HTMLImageElement>) => {\n                arrived(source)\n                if (source === src) onLoad?.(event)\n              }}\n              // A picture that won't load takes its turn all the same, so the box never waits forever.\n              onError={(event: React.SyntheticEvent<HTMLImageElement>) => {\n                gone(source)\n                if (source === src) onError?.(event)\n              }}\n              onAnimationEnd={() => {\n                if (!current) setShown(source)\n              }}\n              className={cn(\n                // The one on show fills a fixed box, or stands in the page at its own shape; the one coming lies over it.\n                boxed || !current ? \"absolute inset-0 size-full\" : \"block h-auto w-full\",\n                (boxed || !current) && (fit === \"contain\" ? \"object-contain\" : \"object-cover\"),\n                current\n                  ? [\n                      // The fade, and any move you give it (a hover zoom through classNames.image) glides the same way.\n                      \"transition-[opacity,transform,translate,scale,rotate] duration-(--duration-considered) ease-standard motion-reduce:transition-none\",\n                      !ready && \"opacity-0\",\n                    ]\n                  : ready\n                    ? \"animate-in duration-(--duration-standard) ease-enter fade-in-0\"\n                    : \"opacity-0\",\n                classNames?.image\n              )}\n            />\n          )\n        })\n      )}\n      {waiting && (\n        <span className=\"absolute inset-0 grid animate-in place-items-center bg-black/25 duration-(--duration-fast) fade-in-0 motion-reduce:animate-none\">\n          <span className=\"grid size-[min(2.5rem,60%)] place-items-center rounded-full bg-black/45 backdrop-blur-sm\">\n            <Spinner size=\"sm\" label={t(\"loading\")} className=\"size-[min(1rem,60%)] text-white/85\" />\n          </span>\n        </span>\n      )}\n    </span>\n  )\n}\n\nexport { Image, type ImageProps }\n"}],"cssVars":{"theme":{"ease-standard":"var(--ease-standard)","ease-enter":"var(--ease-enter)","ease-exit":"var(--ease-exit)","ease-spring":"var(--ease-spring)"},"light":{"duration-fast":"160ms","duration-standard":"240ms","duration-considered":"480ms","ease-standard":"cubic-bezier(0.2, 0, 0, 1)","ease-enter":"cubic-bezier(0, 0, 0, 1)","ease-exit":"cubic-bezier(0.3, 0, 1, 1)","ease-spring":"linear(0, 0.071 4%, 0.234 8%, 0.433 12%, 0.626 17%, 0.791 21%, 0.917 25%, 1.004 29%, 1.055 33%, 1.079 38%, 1.083 42%, 1.075 46%, 1.06 50%, 1.043 54%, 1.027 58%, 1.015 62%, 1.005 67%, 0.998 71%, 0.995 75%, 0.993 79%, 0.993 83%, 0.994 88%, 0.995 92%, 0.997 96%, 1)"},"dark":{}}}