{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "gooey-tabs",
  "title": "Gooey tabs",
  "description": "A floating segmented pill of equal-width tabs whose solid marker slides to the picked tab while a liquid silhouette chases it on a spring, stretching and trailing a droplet tail.",
  "dependencies": [
    "liquid-gooey",
    "motion"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/items/gooey-tabs/gooey-tabs.tsx",
      "content": "\"use client\";\n\nimport { Liquid } from \"liquid-gooey\";\nimport { useReducedMotionConfig } from \"motion/react\";\nimport { useEffect, useRef, useState, useSyncExternalStore, type CSSProperties, type ReactNode } from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/**\n * A segmented pill with one liquid indicator (`liquid-gooey`, `effect=\"move\"`):\n * the marker trails the tab you picked with a droplet tail instead of cutting\n * to it. The indicator is a single element that translates, not a highlight on\n * each tab — that is what gives `move` something to chase. Every tab is the\n * same width so the offset is `index * tabWidth` and nothing is measured.\n *\n * Ported from 180Connect's client-record tabs with the routing lifted out.\n */\n\nexport type GooeyTabItem = {\n  id: string;\n  label: ReactNode;\n  /** With an href the tab renders as a link; without one, as a button. */\n  href?: string;\n  /** Shown beside the label when above zero. */\n  count?: number | null;\n};\n\nexport type GooeyTabLinkProps = {\n  item: GooeyTabItem;\n  href: string;\n  className: string;\n  style: CSSProperties;\n  \"aria-current\"?: \"page\";\n  onClick: () => void;\n  children: ReactNode;\n};\n\nconst PHONE_QUERY = \"(width < 40rem)\";\n\nfunction subscribePhone(onChange: () => void) {\n  const query = window.matchMedia(PHONE_QUERY);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n}\n\nfunction useIsPhone() {\n  return useSyncExternalStore(\n    subscribePhone,\n    () => window.matchMedia(PHONE_QUERY).matches,\n    () => false,\n  );\n}\n\nexport function GooeyTabs({\n  items,\n  value,\n  defaultValue,\n  onValueChange,\n  renderLink,\n  tabWidth = 112,\n  ariaLabel = \"Sections\",\n  gooBlur = 5,\n  gooContrast = 18,\n  className,\n}: {\n  items: GooeyTabItem[];\n  /** The open tab. null, or an id not in `items`, hides the marker. */\n  value?: string | null;\n  defaultValue?: string | null;\n  onValueChange?: (id: string) => void;\n  /** Renders a tab that has an href, e.g. with next/link. Defaults to <a>. */\n  renderLink?: (props: GooeyTabLinkProps) => ReactNode;\n  /** Every tab's width in px; size it for the longest label plus its count. */\n  tabWidth?: number;\n  ariaLabel?: string;\n  gooBlur?: number;\n  gooContrast?: number;\n  className?: string;\n}) {\n  const [uncontrolled, setUncontrolled] = useState(defaultValue ?? items[0]?.id ?? null);\n  const current = value !== undefined ? value : uncontrolled;\n  const activeIndex = items.findIndex((item) => item.id === current);\n  const stripRef = useRef<HTMLDivElement>(null);\n  const reduce = useReducedMotionConfig();\n\n  // On a phone (and under reduced motion) the active tab wears its own solid\n  // pill and the liquid indicator is not mounted at all — not hidden with CSS.\n  // liquid-gooey paints each item's silhouette into its own SVG layer, which a\n  // `display: none` never reaches; a hidden item also stops getting the\n  // transition events that wake it, and its silhouette freezes mid-stretch.\n  const isPhone = useIsPhone();\n\n  // The same goes for a strip too narrow for its tabs: once it scrolls, the\n  // liquid layer (painted in its own SVG) no longer lines up with the tabs and\n  // leaves a stray silhouette beside them. So a strip that overflows wears the\n  // solid pill as well.\n  const [overflowing, setOverflowing] = useState(false);\n  useEffect(() => {\n    const strip = stripRef.current;\n    if (!strip) return;\n    const check = () => setOverflowing(strip.scrollWidth > strip.clientWidth + 1);\n    check();\n    const observer = new ResizeObserver(check);\n    observer.observe(strip);\n    return () => observer.disconnect();\n  }, [items.length, tabWidth]);\n\n  const liquid = !isPhone && !reduce && !overflowing;\n\n  // Keep the selected tab fully visible on narrow screens.\n  useEffect(() => {\n    const strip = stripRef.current;\n    if (!strip || activeIndex < 0) return;\n    strip.scrollLeft = Math.max(0, activeIndex * tabWidth - (strip.clientWidth - tabWidth) / 2);\n  }, [activeIndex, tabWidth]);\n\n  function select(id: string) {\n    if (value === undefined) setUncontrolled(id);\n    onValueChange?.(id);\n  }\n\n  return (\n    <nav aria-label={ariaLabel} className={cn(\"py-2\", className)}>\n      <div className=\"relative inline-flex max-w-full rounded-full\">\n        {/* Frosted glass, sized to the pill. A sibling of the liquid layer, not\n            a descendant: a backdrop-filter is defeated by any `filter` in its\n            own subtree, and Liquid paints the droplet with one. */}\n        <div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-0 z-0 rounded-full border border-rule bg-card/60 shadow-[0_1px_2px_color-mix(in_oklab,var(--ink)_6%,transparent)] backdrop-blur-md\"\n        />\n        <Liquid\n          ref={stripRef}\n          blur={gooBlur}\n          contrast={gooContrast}\n          fill=\"var(--ink)\"\n          shadow=\"0 1px 2px color-mix(in oklab, var(--ink) 6%, transparent)\"\n          className=\"relative z-10 inline-flex max-w-full items-center overflow-x-auto rounded-full bg-transparent p-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\"\n        >\n          {liquid && (\n            <Liquid.Item effect=\"move\" move={{ springiness: 0.6, trail: 0.5, stretch: 0.25 }}>\n              <div\n                aria-hidden=\"true\"\n                className=\"pointer-events-none absolute top-1 bottom-1 left-1 rounded-full bg-ink transition-[transform,opacity] duration-300\"\n                style={{\n                  width: `${tabWidth}px`,\n                  transform: `translateX(${Math.max(activeIndex, 0) * tabWidth}px)`,\n                  opacity: activeIndex === -1 ? 0 : 1,\n                }}\n              />\n            </Liquid.Item>\n          )}\n\n          <div className=\"relative z-10 flex items-center\">\n            {items.map((item, index) => {\n              const active = index === activeIndex;\n              const tabClass = cn(\n                \"flex h-8 shrink-0 items-center justify-center rounded-full text-[13px] whitespace-nowrap transition-colors duration-200 pointer-coarse:min-h-11 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-lead-mid\",\n                active\n                  ? cn(\"font-bold text-on-solid\", liquid ? \"bg-transparent\" : \"bg-ink\")\n                  : \"cursor-pointer font-semibold text-dim hover:text-ink\",\n              );\n              const style = { width: `${tabWidth}px` };\n              const children = (\n                <>\n                  {item.label}\n                  {item.count != null && item.count > 0 && (\n                    <span className=\"ml-1.5 font-mono text-[10.5px] text-faint tabular-nums\">{item.count}</span>\n                  )}\n                </>\n              );\n\n              if (item.href) {\n                const linkProps: GooeyTabLinkProps = {\n                  item,\n                  href: item.href,\n                  className: tabClass,\n                  style,\n                  \"aria-current\": active ? \"page\" : undefined,\n                  onClick: () => select(item.id),\n                  children,\n                };\n                if (renderLink) return <span key={item.id}>{renderLink(linkProps)}</span>;\n                return (\n                  <a\n                    key={item.id}\n                    href={item.href}\n                    className={tabClass}\n                    style={style}\n                    aria-current={linkProps[\"aria-current\"]}\n                    onClick={linkProps.onClick}\n                  >\n                    {children}\n                  </a>\n                );\n              }\n\n              return (\n                <button\n                  key={item.id}\n                  type=\"button\"\n                  aria-current={active ? \"true\" : undefined}\n                  onClick={() => select(item.id)}\n                  className={tabClass}\n                  style={style}\n                >\n                  {children}\n                </button>\n              );\n            })}\n          </div>\n        </Liquid>\n      </div>\n    </nav>\n  );\n}\n",
      "type": "registry:component"
    }
  ],
  "meta": {
    "kind": "widget",
    "whenToUse": "Use as the primary section switcher at the top of a record or detail page with three to six sections, where each tab can be a route (pass href) or local state. Not for long or variable-length labels (every tab is one width) or for a queue switcher that needs counts as the main signal; use count-tabs for that.",
    "origin": {
      "project": "180Connect",
      "path": "src/app/(app)/clients/[id]/record-tabs.tsx"
    },
    "credits": [
      {
        "name": "liquid-gooey by Jakub Antalik (the Liquid move effect, used as a dependency)",
        "url": "https://github.com/Jakubantalik/Libraries.dev",
        "license": "MIT"
      }
    ]
  },
  "type": "registry:component"
}
