{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tab-bar",
  "type": "registry:ui",
  "title": "TabBar",
  "description": "Metro-style pivot tabs with a sliding accent underline that doubles as each tab's progress bar.",
  "categories": [
    "navigation"
  ],
  "registryDependencies": [
    "https://whiskeyjack.net/r/direction.json",
    "https://whiskeyjack.net/r/progress-bar.json",
    "https://whiskeyjack.net/r/use-reduced-motion.json",
    "https://whiskeyjack.net/r/use-tab-bar-fade.json",
    "https://whiskeyjack.net/r/utils.json"
  ],
  "files": [
    {
      "path": "components/ui/tab-bar.tsx",
      "type": "registry:ui",
      "target": "components/ui/tab-bar.tsx",
      "content": "import * as React from 'react'\nimport { cn } from '@/lib/utils'\nimport { isRTL } from '@/lib/direction'\nimport { useTabBarFade } from '@/hooks/use-tab-bar-fade'\nimport { useReducedMotion } from '@/hooks/use-reduced-motion'\nimport { ProgressBar } from '@/components/ui/progress-bar'\n\nexport interface TabBarItem {\n  id: string\n  label: string\n  /**\n   * Optional leading icon, shown before the label. Fixed-width and\n   * weight-independent, so it does not affect the bold-ghost width reservation.\n   * Used to differentiate tabs at a glance (e.g. per-platform icons).\n   */\n  icon?: React.ReactNode\n  /**\n   * Optional progress for the active tab's underline:\n   * - a 0..1 number renders a determinate fill bar (progress toward a target);\n   * - 'indeterminate' renders the open-ended indicator: segment accumulation\n   *   when `count` is set, else a dotted line;\n   * - omitted keeps the plain solid underline (no progress concept).\n   */\n  progress?: number | 'indeterminate'\n  /**\n   * The tally behind an 'indeterminate' underline (streak days, check-ins).\n   * Renders segment accumulation: solid dots on a faint dotted track, every 10\n   * collapsing into a pill (see ProgressBar's `count`). Ignored otherwise.\n   */\n  count?: number\n  /**\n   * Optional decorative style for the active tab's underline. Purely visual (no\n   * functional meaning) and takes precedence over `progress`:\n   * - 'wave' renders a wavy accent line;\n   * - 'shimmer' renders a dimmed accent line with a slow full-accent sweep (for\n   *   occasional \"special\" tabs; requires the `.wj-tab-shimmer` utility class,\n   *   and falls back to a plain line under reduced motion).\n   */\n  underline?: 'wave' | 'shimmer'\n}\n\nexport interface TabBarProps {\n  items: TabBarItem[]\n  activeId: string | null\n  onSelect: (id: string) => void\n  className?: string\n  /** Inline styles for the root element. */\n  style?: React.CSSProperties\n}\n\n/**\n * The \"wave\" decorative underline: a tiled wavy accent line, purely visual.\n * Taller than the solid / progress underlines, so it sits at the bottom and\n * extends upward. The pattern id is per-instance (useId) to avoid collisions.\n * Themeable via the path's CSS stroke. Drifts gently sideways (one wavelength,\n * looping seamlessly) via SMIL, gated by prefers-reduced-motion.\n */\nfunction WaveUnderline() {\n  const patternId = React.useId()\n  const reduced = useReducedMotion()\n  return (\n    <svg\n      aria-hidden\n      className=\"pointer-events-none absolute inset-x-0 bottom-0 h-2 w-full overflow-visible\"\n    >\n      <defs>\n        <pattern id={patternId} width=\"16\" height=\"8\" patternUnits=\"userSpaceOnUse\">\n          <path\n            d=\"M0,4 Q4,0 8,4 T16,4\"\n            fill=\"none\"\n            strokeWidth=\"2\"\n            strokeLinecap=\"round\"\n            style={{ stroke: 'var(--color-accent-500)' }}\n          />\n          {/* Drift one full wavelength (16px) and loop = seamless, since the\n              pattern is periodic. Skipped entirely under reduced motion. */}\n          {!reduced && (\n            <animateTransform\n              attributeName=\"patternTransform\"\n              type=\"translate\"\n              from=\"0 0\"\n              to=\"16 0\"\n              dur=\"1.5s\"\n              repeatCount=\"indefinite\"\n            />\n          )}\n        </pattern>\n      </defs>\n      <rect width=\"100%\" height=\"8\" fill={`url(#${patternId})`} />\n    </svg>\n  )\n}\n\n/**\n * Sticky frosted tab bar for switching between primary entities.\n *\n * Combines the sticky outer wrapper (backdrop-blur, --header-bg, border-b) and\n * the inner horizontally-scrolling strip. Integrates useTabBarFade internally\n * for the dynamic edge-fade mask. Auto-scrolls the active tab into view on\n * `activeId` change, and re-centers it when the item set changes around it\n * (e.g. a search filter clearing re-adds tabs and shifts every position).\n *\n * The outer wrapper uses `sticky top-0 z-30 -mx-4 -mt-3` so callers should\n * place it as a direct child of the page's full-height scroll container (not\n * wrapped in a sized div) and apply `px-4 pt-3` to the page container.\n *\n * The tab bar fade CSS classes (`tab-bar-fade-left`, `tab-bar-fade-right`,\n * `tab-bar-fade-both`) and the `scrollbar-hide` class must be available in the\n * app's own CSS. Import `@whiskeyjack/design-system/css/utilities` to get them.\n */\nexport const TabBar = React.forwardRef<HTMLDivElement, TabBarProps>(function TabBar({ items, activeId, onSelect, className, style }, ref) {\n  const tabBarRef = React.useRef<HTMLDivElement>(null)\n  const fadeClass = useTabBarFade(tabBarRef, items.length)\n  const reduced = useReducedMotion()\n\n  const handleKeyDown = React.useCallback(\n    (e: React.KeyboardEvent<HTMLDivElement>) => {\n      if (!items.length) return\n      const currentIndex = activeId ? items.findIndex((t) => t.id === activeId) : -1\n\n      let nextIndex: number | null = null\n      if (e.key === 'ArrowRight') {\n        nextIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0\n      } else if (e.key === 'ArrowLeft') {\n        nextIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1\n      } else if (e.key === 'Home') {\n        nextIndex = 0\n      } else if (e.key === 'End') {\n        nextIndex = items.length - 1\n      }\n\n      if (nextIndex !== null) {\n        e.preventDefault()\n        const next = items[nextIndex]\n        onSelect(next.id)\n        // Move DOM focus to the newly selected tab\n        const btn = tabBarRef.current?.querySelector<HTMLElement>(\n          `[data-tab-id=\"${next.id}\"]`\n        )\n        btn?.focus()\n      }\n    },\n    [activeId, items, onSelect]\n  )\n\n  const scrollTabIntoView = React.useCallback(\n    (center = false) => {\n      if (!tabBarRef.current || !activeId) return\n      const activeBtn = tabBarRef.current.querySelector(\n        `[data-tab-id=\"${activeId}\"]`\n      ) as HTMLElement | null\n      if (!activeBtn) return\n      const container = tabBarRef.current\n\n      // Center the active tab in the strip (scrollTo clamps at the edges).\n      // offsetLeft is physical, giving the LTR scrollLeft target; in modern-RTL\n      // the scroll range is [-(scrollWidth-clientWidth), 0], so shift into it.\n      if (center) {\n        const ltrLeft = activeBtn.offsetLeft - (container.clientWidth - activeBtn.offsetWidth) / 2\n        const left = isRTL(container)\n          ? ltrLeft - (container.scrollWidth - container.clientWidth)\n          : ltrLeft\n        container.scrollTo({ left, behavior: reduced ? 'auto' : 'smooth' })\n        return\n      }\n\n      const containerRect = container.getBoundingClientRect()\n      const btnRect = activeBtn.getBoundingClientRect()\n      const pad = 16\n\n      if (btnRect.left < containerRect.left + pad) {\n        container.scrollTo({\n          left: container.scrollLeft + btnRect.left - containerRect.left - pad,\n          behavior: reduced ? 'auto' : 'smooth',\n        })\n      } else if (btnRect.right > containerRect.right - pad) {\n        container.scrollTo({\n          left: container.scrollLeft + btnRect.right - containerRect.right + pad,\n          behavior: reduced ? 'auto' : 'smooth',\n        })\n      }\n    },\n    [activeId, reduced]\n  )\n\n  // Selecting a neighbouring tab nudges it into view (minimal scroll). When the\n  // ITEM SET changes around a still-selected tab -- e.g. a search filter\n  // clearing re-adds the hidden tabs and shifts every position -- the old\n  // scroll offset is meaningless, so center the active tab instead.\n  const itemsKey = items.map((i) => i.id).join('\\n')\n  const prevItemsKeyRef = React.useRef(itemsKey)\n  React.useEffect(() => {\n    const itemsChanged = prevItemsKeyRef.current !== itemsKey\n    prevItemsKeyRef.current = itemsKey\n    scrollTabIntoView(itemsChanged)\n  }, [itemsKey, scrollTabIntoView])\n\n  // Sliding underline: measure the active tab's position within the scroll\n  // strip so a single accent bar can animate (left + width) from tab to tab.\n  // offsetLeft/Width are content-relative, so the bar scrolls with the strip.\n  const [indicator, setIndicator] = React.useState<{\n    left: number\n    width: number\n  } | null>(null)\n\n  const measureIndicator = React.useCallback(() => {\n    if (!tabBarRef.current || !activeId) {\n      setIndicator(null)\n      return\n    }\n    const activeBtn = tabBarRef.current.querySelector(\n      `[data-tab-id=\"${activeId}\"]`\n    ) as HTMLElement | null\n    // offsetWidth is 0 while the bar is hidden (display:none at another\n    // breakpoint -- e.g. the sidebar layout takes over at xl). Don't collapse\n    // the indicator to zero width then; keep the last good measurement so the\n    // underline reappears intact when the bar is shown again. The ResizeObserver\n    // below re-measures on that 0 -> visible transition.\n    if (!activeBtn || activeBtn.offsetWidth === 0) return\n    setIndicator({ left: activeBtn.offsetLeft, width: activeBtn.offsetWidth })\n  }, [activeId])\n\n  React.useEffect(() => {\n    measureIndicator()\n  }, [measureIndicator, items.length])\n\n  // Re-measure when the strip's size changes -- window resize, and crucially the\n  // 0 -> visible transition when switching from the xl sidebar back to the\n  // inline tab bar (a plain [activeId] effect never re-runs on that, so the\n  // underline would otherwise keep its stale hidden-measured zero width).\n  React.useEffect(() => {\n    const el = tabBarRef.current\n    if (!el || typeof ResizeObserver === 'undefined') return\n    const ro = new ResizeObserver(() => measureIndicator())\n    ro.observe(el)\n    return () => ro.disconnect()\n  }, [measureIndicator])\n\n  // The active tab's underline appearance: a decorative `underline` style wins,\n  // else `progress` (a fill bar or dotted line), else the plain solid bar.\n  const activeItem = activeId ? items.find((i) => i.id === activeId) : undefined\n  const activeProgress = activeItem?.progress\n  const activeUnderline = activeItem?.underline\n\n  return (\n    <div\n      ref={ref}\n      className={cn(\n        'sticky top-0 z-30 -mx-4 -mt-3 backdrop-blur-lg',\n        // Cover the top safe-area inset so the frosted bar reaches the top edge\n        // with the tab labels below the status bar / notch. The strip's own py-5\n        // would stack on top of the full inset, leaving too big a gap below the\n        // island, so trim the inset slightly here (clamps to 0 when env()≈0, so\n        // web/Android keep their padding). env() only reports real insets in the\n        // Tauri iOS webview once the auto content-inset is off (see lib.rs).\n        'pt-[calc(env(safe-area-inset-top)_-_0.25rem)]',\n        'border-b border-[var(--color-border-light)] dark:border-[var(--color-border-dark)]',\n        className\n      )}\n      style={{ background: 'var(--header-bg)', ...style }}\n    >\n      <div\n        ref={tabBarRef}\n        role=\"tablist\"\n        data-tab-bar\n        onWheel={(e) => {\n          if (tabBarRef.current && e.deltaY !== 0) {\n            tabBarRef.current.scrollLeft += e.deltaY\n            e.preventDefault()\n          }\n        }}\n        onKeyDown={handleKeyDown}\n        className={cn('relative flex gap-4 overflow-x-auto px-4 py-5 scrollbar-hide', fadeClass)}\n      >\n        {items.map((item) => {\n          const isActive = item.id === activeId\n          return (\n            <button\n              key={item.id}\n              role=\"tab\"\n              aria-selected={isActive}\n              tabIndex={isActive ? 0 : -1}\n              data-tab-id={item.id}\n              data-label={item.label}\n              onClick={() => onSelect(item.id)}\n              className={cn(\n                // Metro-style pivot: no button chrome. Same size for all tabs;\n                // the active title carries the heavier weight at full strength,\n                // the rest lighter and dimmed. inline-flex lays out an optional\n                // leading icon beside the label.\n                'wj-focus-ring inline-flex items-center justify-center gap-1.5 px-1 text-sm whitespace-nowrap flex-shrink-0 transition-all',\n                'text-[var(--color-text-primary-light)] dark:text-[var(--color-text-primary-dark)]',\n                isActive ? 'font-semibold opacity-100' : 'font-normal opacity-40'\n              )}\n            >\n              {item.icon && (\n                <span aria-hidden className=\"flex shrink-0 items-center\">\n                  {item.icon}\n                </span>\n              )}\n              {/* Reserve each tab's BOLD width via a hidden bold ghost (the label\n                  duplicated through ::after) so toggling the active weight never\n                  reflows the strip. The visible label centers within it; the icon\n                  (fixed width) sits outside so it doesn't affect the reservation. */}\n              <span\n                data-label={item.label}\n                className=\"text-center after:block after:h-0 after:overflow-hidden after:invisible after:font-semibold after:content-[attr(data-label)]\"\n              >\n                {item.label}\n              </span>\n            </button>\n          )\n        })}\n        {indicator && (\n          <div\n            aria-hidden\n            className={cn(\n              'pointer-events-none absolute bottom-0 h-[3px]',\n              reduced ? '' : 'transition-all duration-300 ease-out'\n            )}\n            style={{ left: indicator.left, width: indicator.width }}\n          >\n            {activeUnderline === 'shimmer' ? (\n              // Occasional \"special\" tab (e.g. an aggregate / About tab): a dimmed\n              // accent line with a slow full-accent sweep. Reduced motion keeps\n              // the plain full-strength line.\n              reduced ? (\n                <div className=\"h-full rounded-full bg-[var(--color-accent-500)]\" />\n              ) : (\n                <>\n                  <div className=\"h-full rounded-full bg-[var(--color-accent-500)] opacity-50\" />\n                  <span aria-hidden className=\"wj-tab-shimmer absolute inset-0 rounded-full\" />\n                </>\n              )\n            ) : activeUnderline === 'wave' ? (\n              <WaveUnderline />\n            ) : activeProgress === 'indeterminate' ? (\n              // Free / count streaks (no target): segment accumulation when the\n              // item carries its tally, else the plain dotted line.\n              <ProgressBar\n                indeterminate\n                count={activeItem?.count}\n                size=\"xs\"\n                decorative\n                className=\"h-full\"\n              />\n            ) : activeProgress == null ? (\n              <div className=\"h-full rounded-full bg-[var(--color-accent-500)]\" />\n            ) : (\n              // End goal / completion: the determinate progress bar (track color\n              // from --wj-progress-track, full-accent fill).\n              <ProgressBar value={activeProgress} decorative className=\"h-full\" />\n            )}\n          </div>\n        )}\n      </div>\n    </div>\n  )\n})\n\nTabBar.displayName = 'TabBar'\n"
    }
  ],
  "docs": "Reach for TabBar when an app has parallel surfaces a thumb should flick between. Always pair it with useSwipeNavigation so the content swipes too. Give a tab a `progress` value when it tracks completion and the underline carries it; omit it for tabs with no progress concept. Place it as a direct child of the page scroll container.",
  "meta": {
    "group": "navigation",
    "related": [
      "sidebar-tabs",
      "use-swipe-navigation",
      "progress-bar"
    ],
    "exports": [
      "TabBar"
    ],
    "siteSlug": "tab-bar"
  }
}
