{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "bottom-nav",
  "title": "Bottom Nav",
  "description": "A mobile tab bar with badges, safe-area padding and an indicator that measures the active item and glides to it.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/ui/bottom-nav.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport interface BottomNavItem {\n  key: string\n  label: string\n  icon: React.ReactNode\n  /** Selected-state glyph (usually the filled twin of `icon`); falls back to `icon`. */\n  activeIcon?: React.ReactNode\n  /** Unread count. 0 / undefined renders nothing; anything above 99 renders \"99+\". */\n  badge?: number\n  /** When set the item renders a real <a> — routing stays with the consumer. */\n  href?: string\n  /** Per-item side effect, fired alongside onValueChange. */\n  onSelect?: () => void\n}\n\nexport interface BottomNavProps extends React.HTMLAttributes<HTMLElement> {\n  items: BottomNavItem[]\n  /** Controlled: key of the active item. */\n  value: string\n  onValueChange?: (key: string) => void\n  /** Text labels for every item, only under the active one, or never (icons keep their aria-label). */\n  showLabels?: \"always\" | \"active\" | \"never\"\n  indicator?: \"bar\" | \"pill\" | \"none\"\n  /** Accessible name of the <nav> landmark. */\n  label?: string\n}\n\ninterface Metrics {\n  left: number\n  width: number\n}\n\n/**\n * Mobile tab bar. The indicator is measured from the active item's own box and\n * moved with a transform, so it tracks any item count / width without the\n * consumer declaring geometry.\n */\nexport const BottomNav = React.forwardRef<HTMLElement, BottomNavProps>(\n  (\n    {\n      items,\n      value,\n      onValueChange,\n      showLabels = \"always\",\n      indicator = \"bar\",\n      label = \"Primary\",\n      className,\n      ...props\n    },\n    ref,\n  ) => {\n    const rowRef = React.useRef<HTMLDivElement | null>(null)\n    const itemRefs = React.useRef(new Map<string, HTMLElement>())\n    const [metrics, setMetrics] = React.useState<Metrics | null>(null)\n    // no transition on the first frame: place the indicator, then allow gliding from the next\n    // one (otherwise it flies in from 0 at mount)\n    const [glide, setGlide] = React.useState(false)\n\n    const setItemRef = (key: string) => (node: HTMLElement | null) => {\n      if (node) itemRefs.current.set(key, node)\n      else itemRefs.current.delete(key)\n    }\n\n    // an inline items literal is a new array every render; joined into a key, unchanged contents\n    // mean the ResizeObserver is not rebuilt.\n    const itemsKey = items.map(item => item.key).join(\"\\n\")\n\n    React.useEffect(() => {\n      if (indicator === \"none\") return\n      const row = rowRef.current\n      const active = itemRefs.current.get(value)\n      if (!row || !active) {\n        setMetrics(null)\n        return\n      }\n      // row is relative, so it is both the indicator's containing block and the offsetParent —\n      // offsetLeft can be used as is.\n      const measure = () => {\n        const next = { left: active.offsetLeft, width: active.offsetWidth }\n        setMetrics(prev =>\n          prev && prev.left === next.left && prev.width === next.width ? prev : next,\n        )\n      }\n      measure()\n      if (typeof ResizeObserver === \"undefined\") return\n      // a wider or narrower window / container changes the flex column widths, so measure again.\n      const observer = new ResizeObserver(measure)\n      observer.observe(row)\n      return () => observer.disconnect()\n    }, [value, itemsKey, indicator])\n\n    React.useEffect(() => {\n      if (!metrics || glide) return\n      const raf = requestAnimationFrame(() => setGlide(true))\n      return () => cancelAnimationFrame(raf)\n    }, [metrics, glide])\n\n    return (\n      <nav\n        aria-label={label}\n        className={cn(\n          \"w-full rounded-2xl border bg-background/95 pb-[env(safe-area-inset-bottom)] backdrop-blur\",\n          className,\n        )}\n        ref={ref}\n        {...props}\n      >\n        <div className=\"relative flex items-stretch rounded-[inherit] px-1\" ref={rowRef}>\n          {/* the clipping layer wraps the indicator only: on the first / last item the bar\n              follows the container's radius instead of hanging past the outline, and the\n              badge sits outside the layer so it is never clipped */}\n          {indicator !== \"none\" && metrics && (\n            <span\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]\"\n            >\n              <span\n                className={cn(\n                  \"absolute left-0\",\n                  indicator === \"bar\"\n                    ? \"top-0 h-0.5 rounded-full bg-primary\"\n                    : \"inset-y-1.5 rounded-xl bg-accent\",\n                  glide &&\n                    \"transition-[transform,width] duration-300 ease-out motion-reduce:transition-none\",\n                )}\n                style={{ transform: `translateX(${metrics.left}px)`, width: metrics.width }}\n              />\n            </span>\n          )}\n\n          {items.map(item => {\n            const active = item.key === value\n            const showLabel = showLabels === \"always\" || (showLabels === \"active\" && active)\n            const count = item.badge && item.badge > 0 ? item.badge : 0\n            const badge = count > 99 ? \"99+\" : String(count)\n            // the label may not be rendered at all (showLabels=\"never\"), so aria-label always\n            // carries the accessible name.\n            const accessibleName = count > 0 ? `${item.label}, ${badge} new` : item.label\n\n            const handleClick = () => {\n              onValueChange?.(item.key)\n              item.onSelect?.()\n            }\n\n            // the item is relative itself: the indicator comes first in the DOM, so with both\n            // positioned the item paints on top.\n            // leading-tight has to be pinned explicitly: an inherited line-height (prose's\n            // 1.75rem, say) grows the label box, pushes the icon to the edge and lets the badge\n            // spill over the top of the container.\n            const itemClass = cn(\n              \"group relative flex h-14 min-w-0 flex-1 cursor-pointer flex-col items-center justify-center gap-1 rounded-xl px-1 text-[11px] leading-tight font-medium transition-colors\",\n              \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset\",\n              active ? \"text-primary\" : \"text-muted-foreground hover:text-foreground\",\n            )\n\n            const content = (\n              <>\n                <span\n                  aria-hidden=\"true\"\n                  className=\"relative transition-transform duration-150 group-active:scale-90 motion-reduce:transition-none [&_svg]:size-5\"\n                >\n                  {active && item.activeIcon ? item.activeIcon : item.icon}\n                  {count > 0 && (\n                    <span className=\"absolute -top-1.5 -right-2 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] leading-none text-primary-foreground\">\n                      {badge}\n                    </span>\n                  )}\n                </span>\n                {/* labels never wrap: long copy truncates on a narrow screen and the bar keeps its height */}\n                {showLabel && <span className=\"max-w-full truncate\">{item.label}</span>}\n              </>\n            )\n\n            if (item.href) {\n              return (\n                <a\n                  aria-current={active ? \"page\" : undefined}\n                  aria-label={accessibleName}\n                  className={itemClass}\n                  href={item.href}\n                  key={item.key}\n                  onClick={handleClick}\n                  ref={setItemRef(item.key)}\n                >\n                  {content}\n                </a>\n              )\n            }\n\n            return (\n              <button\n                aria-current={active ? \"page\" : undefined}\n                aria-label={accessibleName}\n                className={itemClass}\n                key={item.key}\n                onClick={handleClick}\n                ref={setItemRef(item.key)}\n                type=\"button\"\n              >\n                {content}\n              </button>\n            )\n          })}\n        </div>\n      </nav>\n    )\n  },\n)\n\nBottomNav.displayName = \"BottomNav\"\n\nexport default BottomNav\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}