{ "$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 \n \n \n \n {/* Drift one full wavelength (16px) and loop = seamless, since the\n pattern is periodic. Skipped entirely under reduced motion. */}\n {!reduced && (\n \n )}\n \n \n \n \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(function TabBar({ items, activeId, onSelect, className, style }, ref) {\n const tabBarRef = React.useRef(null)\n const fadeClass = useTabBarFade(tabBarRef, items.length)\n const reduced = useReducedMotion()\n\n const handleKeyDown = React.useCallback(\n (e: React.KeyboardEvent) => {\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(\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 \n {\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 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 \n {item.icon}\n \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 \n {item.label}\n \n \n )\n })}\n {indicator && (\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
\n ) : (\n <>\n
\n \n \n )\n ) : activeUnderline === 'wave' ? (\n \n ) : activeProgress === 'indeterminate' ? (\n // Free / count streaks (no target): segment accumulation when the\n // item carries its tally, else the plain dotted line.\n \n ) : activeProgress == null ? (\n
\n ) : (\n // End goal / completion: the determinate progress bar (track color\n // from --wj-progress-track, full-accent fill).\n \n )}\n
\n )}\n
\n
\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" } }