{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "favicon-badge",
  "type": "registry:ui",
  "registryDependencies": [],
  "dependencies": ["motion", "lucide-react"],
  "devDependencies": [],
  "files": [
    {
      "path": "favicon-badge.tsx",
      "content": "\"use client\";\n\nimport { Globe } from \"lucide-react\";\nimport { motion } from \"motion/react\";\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst componentThemeClassName =\n  \"[--ic-background:#ffffff] [--ic-foreground:#111111] [--ic-primary:#111111] [--ic-secondary:#646b75] [--ic-surface-border:#e9edf2] [--ic-border:#e3e7ec] [--ic-card:#ffffff] [--ic-card-foreground:#111111] [--ic-muted:#f5f7fa] [--ic-muted-foreground:#6d7480] [--ic-accent:#f3f5f8] [--color-accent:var(--ic-accent)] [--color-accent-foreground:var(--ic-accent-foreground)] [--ic-accent-foreground:#111111] [--ic-input:#e3e7ec] [--ic-ring:rgba(17,17,17,0.16)] [--ic-destructive:#dc2626] [--ic-paper:#fcfcfd] [--ic-popover-foreground:#111111] [--ic-brand:#0ea5e9] [--ic-brand-soft:#bae6fd] [--ic-shadow-soft:0_18px_38px_-24px_rgba(15,23,42,0.35)] [--ic-chart-1:oklch(0.52_0.19_254)] [--ic-chart-2:oklch(0.74_0.11_232)] [--ic-chart-3:oklch(0.42_0.16_262)] [--ic-chart-4:oklch(0.84_0.07_228)] [--ic-chart-5:oklch(0.62_0.14_240)] [--color-background:var(--ic-background)] [--color-foreground:var(--ic-foreground)] [--color-primary:var(--ic-primary)] [--color-secondary:var(--ic-secondary)] [--color-border:var(--ic-border)] [--color-card:var(--ic-card)] [--color-card-foreground:var(--ic-card-foreground)] [--color-muted:var(--ic-muted)] [--color-muted-foreground:var(--ic-muted-foreground)] [--color-accent:var(--ic-accent)] [--color-accent-foreground:var(--ic-accent-foreground)] [--color-input:var(--ic-input)] [--color-ring:var(--ic-ring)] [--color-destructive:var(--ic-destructive)] [--color-paper:var(--ic-paper)] [--color-popover-foreground:var(--ic-popover-foreground)] [--color-brand:var(--ic-brand)] [--color-brand-soft:var(--ic-brand-soft)] [--color-chart-1:var(--ic-chart-1)] [--color-chart-2:var(--ic-chart-2)] [--color-chart-3:var(--ic-chart-3)] [--color-chart-4:var(--ic-chart-4)] [--color-chart-5:var(--ic-chart-5)] dark:[--ic-background:#111111] dark:[--ic-foreground:#f6f3ec] dark:[--ic-primary:#f6f3ec] dark:[--ic-secondary:#cbc6bb] dark:[--ic-surface-border:#2a2a25] dark:[--ic-border:#2b2a25] dark:[--ic-card:#111111] dark:[--ic-card-foreground:#f6f3ec] dark:[--ic-muted:#171716] dark:[--ic-muted-foreground:#9a958a] dark:[--ic-accent:#1a1a18] [--color-accent:var(--ic-accent)] [--color-accent-foreground:var(--ic-accent-foreground)] dark:[--ic-accent-foreground:#f6f3ec] dark:[--ic-input:#2b2a25] dark:[--ic-ring:rgba(246,243,236,0.18)] dark:[--ic-destructive:#f87171] dark:[--ic-paper:#171716] dark:[--ic-popover-foreground:#f6f3ec] dark:[--ic-brand:#38bdf8] dark:[--ic-brand-soft:#0c4a6e] dark:[--ic-shadow-soft:0_20px_44px_-28px_rgba(0,0,0,0.6)] dark:[--ic-chart-1:oklch(0.68_0.17_250)] dark:[--ic-chart-2:oklch(0.82_0.09_225)] dark:[--ic-chart-3:oklch(0.58_0.15_260)] dark:[--ic-chart-4:oklch(0.75_0.12_235)] dark:[--ic-chart-5:oklch(0.88_0.06_220)]\";\n\nconst WWW_PREFIX_RE = /^www\\./;\nconst GOOGLE_FAVICON_RE = /google\\.com\\/s2\\/favicons/;\n\ntype FaviconResolveStatus = \"idle\" | \"loading\" | \"success\" | \"error\";\n\nfunction extractDomain(input: string): string | null {\n  if (!input.trim()) return null;\n  try {\n    const raw = input.includes(\"://\") ? input : `https://${input}`;\n    const url = new URL(raw);\n    const host = url.hostname.replace(WWW_PREFIX_RE, \"\");\n    if (host.includes(\".\") && host.split(\".\").every(Boolean)) return host;\n    return null;\n  } catch {\n    const cleaned = input.trim().replace(WWW_PREFIX_RE, \"\");\n    if (cleaned.includes(\".\") && cleaned.split(\".\").every(Boolean))\n      return cleaned;\n    return null;\n  }\n}\n\nfunction getFaviconCandidates(domain: string, size = 64): string[] {\n  const origin = `https://${domain}`;\n\n  return [\n    `https://www.google.com/s2/favicons?domain_url=${encodeURIComponent(origin)}&sz=${size}`,\n    `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=${size}`,\n    `https://icons.duckduckgo.com/ip3/${encodeURIComponent(domain)}.ico`,\n  ];\n}\n\nfunction getFaviconUrl(domain: string, size = 64): string {\n  const [primary, ...fallbacks] = getFaviconCandidates(domain, size);\n  return (\n    primary ??\n    fallbacks[0] ??\n    `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=${size}`\n  );\n}\n\nfunction isLikelyDefaultFavicon(url: string, image: HTMLImageElement): boolean {\n  if (!GOOGLE_FAVICON_RE.test(url)) return false;\n  return image.naturalWidth <= 16 && image.naturalHeight <= 16;\n}\n\nfunction getImageSourceUrl(image: HTMLImageElement): string {\n  return image.currentSrc || image.src;\n}\n\nfunction getInitialResolveStatus(\n  domain: string | null,\n  overrideUrl: string | undefined,\n  website: string\n): FaviconResolveStatus {\n  if (overrideUrl) return \"loading\";\n  if (!domain) return website.trim() ? \"error\" : \"idle\";\n  return \"loading\";\n}\n\nconst sizeConfig = {\n  sm: {\n    badge: \"size-6\",\n    icon: 14,\n    gap: \"gap-1.5\",\n    label: \"text-sm leading-none\",\n  },\n  md: {\n    badge: \"size-7\",\n    icon: 16,\n    gap: \"gap-2\",\n    label: \"text-base leading-none\",\n  },\n  lg: {\n    badge: \"size-8\",\n    icon: 18,\n    gap: \"gap-2\",\n    label: \"text-lg leading-none\",\n  },\n} as const;\n\nexport type FaviconBadgeSize = keyof typeof sizeConfig;\n\nexport interface FaviconBadgeProps\n  extends Omit<React.ComponentPropsWithoutRef<\"span\">, \"children\"> {\n  /** Domain or URL used to resolve the favicon. */\n  website: string;\n  /** Optional label shown beside the favicon badge. */\n  label?: string;\n  /** Skip provider resolution and render this favicon URL directly. */\n  faviconUrl?: string;\n  /** @default 64 */\n  faviconSize?: 16 | 32 | 64 | 128;\n  /** @default md */\n  size?: FaviconBadgeSize;\n  badgeClassName?: string;\n  labelClassName?: string;\n  /** Called when a favicon URL resolves successfully. */\n  onFaviconLoad?: (url: string) => void;\n  /** Called when resolution fails or an override URL fails to load. */\n  onFaviconError?: () => void;\n}\n\nfunction useLatestRef<T>(value: T) {\n  const ref = React.useRef(value);\n  ref.current = value;\n  return ref;\n}\n\nfunction useFaviconResolver({\n  domain,\n  faviconSize,\n  onFaviconError,\n  onFaviconLoad,\n  overrideUrl,\n  website,\n}: {\n  domain: string | null;\n  faviconSize: NonNullable<FaviconBadgeProps[\"faviconSize\"]>;\n  onFaviconError?: () => void;\n  onFaviconLoad?: (url: string) => void;\n  overrideUrl?: string;\n  website: string;\n}) {\n  const candidates = React.useMemo(\n    () => (domain ? getFaviconCandidates(domain, faviconSize) : []),\n    [domain, faviconSize]\n  );\n\n  const [status, setStatus] = React.useState<FaviconResolveStatus>(() =>\n    getInitialResolveStatus(domain, overrideUrl, website)\n  );\n  const [candidateIndex, setCandidateIndex] = React.useState(0);\n  const [displayUrl, setDisplayUrl] = React.useState<string | null>(null);\n\n  const probeImgRef = React.useRef<HTMLImageElement>(null);\n  const resolveGenerationRef = React.useRef(0);\n  const processedProbeRef = React.useRef<string | null>(null);\n  const statusRef = useLatestRef(status);\n  const candidateIndexRef = useLatestRef(candidateIndex);\n  const candidatesRef = useLatestRef(candidates);\n  const callbacksRef = useLatestRef({ onFaviconError, onFaviconLoad });\n  const overrideUrlRef = useLatestRef(overrideUrl);\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: faviconSize updates provider URLs and must reset probing.\n  React.useEffect(() => {\n    resolveGenerationRef.current += 1;\n    processedProbeRef.current = null;\n    setCandidateIndex(0);\n    setDisplayUrl(null);\n    setStatus(getInitialResolveStatus(domain, overrideUrl, website));\n\n    return () => {\n      resolveGenerationRef.current += 1;\n    };\n  }, [domain, faviconSize, overrideUrl, website]);\n\n  const probeUrl = overrideUrl ?? candidates[candidateIndex] ?? null;\n  const isLoading = status === \"loading\" && Boolean(probeUrl);\n  const isSuccess = status === \"success\" && Boolean(displayUrl);\n  const showImage = isLoading || isSuccess;\n  const imageSrc = isSuccess ? displayUrl : probeUrl;\n  const showError = status === \"error\";\n\n  const isActiveGeneration = React.useCallback(\n    (generation: number) => generation === resolveGenerationRef.current,\n    []\n  );\n\n  const failResolution = React.useCallback(\n    (generation: number) => {\n      if (!isActiveGeneration(generation)) return;\n      if (statusRef.current !== \"loading\") return;\n\n      processedProbeRef.current = null;\n      setDisplayUrl(null);\n      setStatus(\"error\");\n      callbacksRef.current.onFaviconError?.();\n    },\n    [callbacksRef, isActiveGeneration, statusRef]\n  );\n\n  const advanceCandidate = React.useCallback(\n    (generation: number) => {\n      if (!isActiveGeneration(generation)) return;\n      if (statusRef.current !== \"loading\") return;\n\n      processedProbeRef.current = null;\n\n      const nextIndex = candidateIndexRef.current + 1;\n\n      if (nextIndex >= candidatesRef.current.length) {\n        failResolution(generation);\n        return;\n      }\n\n      setCandidateIndex(nextIndex);\n    },\n    [\n      candidateIndexRef,\n      candidatesRef,\n      failResolution,\n      isActiveGeneration,\n      statusRef,\n    ]\n  );\n\n  const completeProbe = React.useCallback(\n    (image: HTMLImageElement, url: string, generation: number) => {\n      if (!isActiveGeneration(generation)) return;\n      if (statusRef.current !== \"loading\") return;\n\n      const probeKey = `${generation}:${url}`;\n      if (processedProbeRef.current === probeKey) return;\n      processedProbeRef.current = probeKey;\n\n      if (!overrideUrlRef.current && isLikelyDefaultFavicon(url, image)) {\n        advanceCandidate(generation);\n        return;\n      }\n\n      setDisplayUrl(url);\n      setStatus(\"success\");\n      callbacksRef.current.onFaviconLoad?.(url);\n    },\n    [\n      advanceCandidate,\n      callbacksRef,\n      isActiveGeneration,\n      overrideUrlRef,\n      statusRef,\n    ]\n  );\n\n  const handleImageLoad = React.useCallback(\n    (event: React.SyntheticEvent<HTMLImageElement>) => {\n      const image = event.currentTarget;\n      const url = getImageSourceUrl(image);\n\n      if (!url) return;\n\n      completeProbe(image, url, resolveGenerationRef.current);\n    },\n    [completeProbe]\n  );\n\n  const handleImageError = React.useCallback(\n    (event: React.SyntheticEvent<HTMLImageElement>) => {\n      const generation = resolveGenerationRef.current;\n      if (statusRef.current !== \"loading\") return;\n\n      const url = getImageSourceUrl(event.currentTarget);\n      const probeKey = `${generation}:${url}`;\n\n      if (processedProbeRef.current === probeKey) return;\n      processedProbeRef.current = probeKey;\n\n      if (overrideUrlRef.current) {\n        failResolution(generation);\n        return;\n      }\n\n      advanceCandidate(generation);\n    },\n    [advanceCandidate, failResolution, overrideUrlRef, statusRef]\n  );\n\n  React.useLayoutEffect(() => {\n    if (!(isLoading && probeUrl)) return;\n\n    const image = probeImgRef.current;\n    if (!image?.complete) return;\n\n    const generation = resolveGenerationRef.current;\n    const url = getImageSourceUrl(image);\n    if (!url) return;\n\n    if (image.naturalWidth > 0) {\n      completeProbe(image, url, generation);\n      return;\n    }\n\n    handleImageError({\n      currentTarget: image,\n    } as React.SyntheticEvent<HTMLImageElement>);\n  }, [completeProbe, handleImageError, isLoading, probeUrl]);\n\n  return {\n    handleImageError,\n    handleImageLoad,\n    imageSrc,\n    isLoading,\n    isSuccess,\n    probeImgRef,\n    showError,\n    showImage,\n    status,\n  };\n}\n\nconst FaviconBadge = React.forwardRef<HTMLSpanElement, FaviconBadgeProps>(\n  function FaviconBadge(\n    {\n      website,\n      label,\n      faviconUrl,\n      faviconSize = 64,\n      size = \"md\",\n      className,\n      badgeClassName,\n      labelClassName,\n      onFaviconLoad,\n      onFaviconError,\n      ...props\n    },\n    ref\n  ) {\n    const overrideUrl = React.useMemo(\n      () => faviconUrl?.trim() || undefined,\n      [faviconUrl]\n    );\n    const domain = React.useMemo(() => extractDomain(website), [website]);\n\n    const {\n      handleImageError,\n      handleImageLoad,\n      imageSrc,\n      isLoading,\n      isSuccess,\n      probeImgRef,\n      showError,\n      showImage,\n      status,\n    } = useFaviconResolver({\n      domain,\n      faviconSize,\n      onFaviconError,\n      onFaviconLoad,\n      overrideUrl,\n      website,\n    });\n\n    const config = sizeConfig[size];\n    const accessibleLabel = label ?? domain ?? website;\n\n    return (\n      <span\n        aria-busy={isLoading || undefined}\n        className={cn(\n          componentThemeClassName,\n          \"inline-flex items-center\",\n          label ? config.gap : undefined,\n          className\n        )}\n        ref={ref}\n        {...props}\n      >\n        <span\n          aria-hidden\n          className={cn(\n            \"relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full border-[0.5px] border-border/60 bg-muted p-1\",\n            showError && \"border-destructive/30\",\n            config.badge,\n            badgeClassName\n          )}\n          data-favicon-status={status}\n        >\n          {showImage && imageSrc ? (\n            <>\n              {/* biome-ignore lint/performance/noImgElement: external favicon URLs are resolved at runtime and must stay framework-agnostic. */}\n              <motion.img\n                alt=\"\"\n                animate={\n                  isSuccess\n                    ? { opacity: 1, scale: 1 }\n                    : { opacity: 0, scale: 0.96 }\n                }\n                className=\"relative z-[1] size-full rounded-full object-contain\"\n                decoding=\"async\"\n                height={config.icon}\n                initial={false}\n                key={imageSrc}\n                onError={isLoading ? handleImageError : undefined}\n                onLoad={isLoading ? handleImageLoad : undefined}\n                ref={isLoading ? probeImgRef : undefined}\n                referrerPolicy=\"no-referrer\"\n                src={imageSrc}\n                transition={{ type: \"spring\", stiffness: 400, damping: 28 }}\n                width={config.icon}\n              />\n              {isLoading ? (\n                <span\n                  aria-hidden\n                  className=\"absolute inset-0 z-[2] rounded-full bg-muted-foreground/15\"\n                >\n                  <span className=\"block size-full animate-pulse rounded-full bg-muted-foreground/20\" />\n                </span>\n              ) : null}\n            </>\n          ) : (\n            <span\n              className={cn(\n                \"flex size-full items-center justify-center\",\n                showError ? \"text-muted-foreground/70\" : \"text-muted-foreground\"\n              )}\n            >\n              <Globe style={{ width: config.icon, height: config.icon }} />\n            </span>\n          )}\n        </span>\n\n        {label ? (\n          <span\n            className={cn(\n              \"font-medium text-foreground\",\n              config.label,\n              labelClassName\n            )}\n          >\n            {label}\n          </span>\n        ) : (\n          <span className=\"sr-only\">{accessibleLabel}</span>\n        )}\n      </span>\n    );\n  }\n);\n\nFaviconBadge.displayName = \"FaviconBadge\";\n\nexport {\n  FaviconBadge,\n  extractDomain,\n  getFaviconCandidates,\n  getFaviconUrl,\n  isLikelyDefaultFavicon,\n};\nexport default FaviconBadge;\n",
      "type": "registry:ui"
    }
  ],
  "title": "Favicon Badge",
  "description": "Circular website favicon badge with optional label text, spring entrance animation, and Google favicon resolution."
}
