{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "browser-tabs",
  "type": "registry:component",
  "title": "Browser Tabs",
  "description": "A browser tab strip: drag tabs to reorder, close one and its neighbours slide in under your pointer, pin from the right-click menu, and scroll when the strip overflows.",
  "categories": [
    "component",
    "Workspace"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/context-menu.json"
  ],
  "files": [
    {
      "path": "registry/devl/browser-tabs.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { ArrowLeftIcon, ArrowRightIcon, CopyIcon, GlobeIcon, ListXIcon, LockIcon, PinIcon, PinOffIcon, PlusIcon, RotateCwIcon, XIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, Reorder, useReducedMotion } from \"motion/react\";\nimport { type KeyboardEvent, type ReactNode, useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport {\n  ContextMenu,\n  ContextMenuItem,\n  ContextMenuPopup,\n  ContextMenuSeparator,\n  ContextMenuShortcut,\n  ContextMenuTrigger,\n} from \"@/components/ui/context-menu\";\nimport { cn } from \"@/lib/utils\";\n\nexport type TabTone = \"sky\" | \"violet\" | \"emerald\" | \"amber\" | \"rose\" | \"teal\" | \"neutral\";\n\nexport interface BrowserTab {\n  id: string;\n  title: string;\n  url: string;\n  tone?: TabTone;\n  mark?: string;\n  pinned?: boolean;\n  headline?: string;\n}\n\nexport interface BrowserTabsProps {\n  defaultTabs: BrowserTab[];\n  defaultActiveId?: string;\n  renderPage?: (tab: BrowserTab) => ReactNode;\n  newTab?: () => Omit<BrowserTab, \"id\">;\n  onChange?: (tabs: BrowserTab[], activeId: string | null) => void;\n  className?: string;\n}\n\nconst FAVICON: Record<TabTone, string> = {\n  sky: \"bg-sky-500 text-white\",\n  violet: \"bg-violet-500 text-white\",\n  emerald: \"bg-emerald-500 text-white\",\n  amber: \"bg-amber-400 text-amber-950\",\n  rose: \"bg-rose-500 text-white\",\n  teal: \"bg-teal-500 text-white\",\n  neutral: \"bg-neutral-400 text-white dark:bg-neutral-600\",\n};\n\nconst PAGE_TINT: Record<TabTone, string> = {\n  sky: \"bg-sky-500/10 text-sky-700 dark:text-sky-300\",\n  violet: \"bg-violet-500/10 text-violet-700 dark:text-violet-300\",\n  emerald: \"bg-emerald-500/10 text-emerald-700 dark:text-emerald-300\",\n  amber: \"bg-amber-500/12 text-amber-700 dark:text-amber-300\",\n  rose: \"bg-rose-500/10 text-rose-700 dark:text-rose-300\",\n  teal: \"bg-teal-500/10 text-teal-700 dark:text-teal-300\",\n  neutral: \"bg-muted text-muted-foreground\",\n};\n\nconst PINNED_W = 40;\nconst MIN_W = 104;\nconst MAX_W = 196;\nconst NEW_W = 36;\n\nconst pinnedFirst = (list: BrowserTab[]) => [...list.filter((t) => t.pinned), ...list.filter((t) => !t.pinned)];\n\nlet counter = 0;\n\nexport function BrowserTabs({ defaultTabs, defaultActiveId, renderPage, newTab, onChange, className }: BrowserTabsProps) {\n  const reduce = useReducedMotion();\n  const [tabs, setTabs] = useState(() => pinnedFirst(defaultTabs));\n  const [active, setActive] = useState<string | null>(defaultActiveId ?? defaultTabs[0]?.id ?? null);\n  const [menuTab, setMenuTab] = useState<string | null>(null);\n  const [stripWidth, setStripWidth] = useState(0);\n  // After closing with the mouse, widths hold still so the next close button lands under the pointer.\n  const [frozen, setFrozen] = useState<number | null>(null);\n  const [edges, setEdges] = useState({ left: false, right: false });\n  const stripRef = useRef<HTMLDivElement>(null);\n  const areaRef = useRef<HTMLDivElement>(null);\n  const tabRefs = useRef(new Map<string, HTMLElement>());\n\n  useLayoutEffect(() => {\n    const el = areaRef.current;\n    if (!el) return;\n    const ro = new ResizeObserver(() => setStripWidth(el.clientWidth));\n    ro.observe(el);\n    setStripWidth(el.clientWidth);\n    return () => ro.disconnect();\n  }, []);\n\n  const checkEdges = () => {\n    const el = stripRef.current;\n    if (!el) return;\n    setEdges({ left: el.scrollLeft > 2, right: el.scrollLeft + el.clientWidth < el.scrollWidth - 2 });\n  };\n  useEffect(() => {\n    const t = window.setTimeout(checkEdges, 260);\n    return () => clearTimeout(t);\n  }, [tabs, stripWidth, frozen]);\n\n  const commit = (next: BrowserTab[], nextActive: string | null = active) => {\n    setTabs(next);\n    setActive(nextActive);\n    onChange?.(next, nextActive);\n  };\n\n  const pinnedCount = tabs.filter((t) => t.pinned).length;\n  const looseCount = tabs.length - pinnedCount;\n  const fit = looseCount ? Math.floor((stripWidth - pinnedCount * PINNED_W - NEW_W - 12) / looseCount) : MAX_W;\n  const width = frozen ?? Math.max(MIN_W, Math.min(MAX_W, fit));\n\n  const close = (id: string, byPointer = false) => {\n    const i = tabs.findIndex((t) => t.id === id);\n    if (i < 0) return;\n    if (byPointer && !tabs[i].pinned) setFrozen(width);\n    const next = tabs.filter((t) => t.id !== id);\n    let nextActive = active;\n    if (active === id) nextActive = next[Math.min(i, next.length - 1)]?.id ?? null;\n    commit(next, nextActive);\n    if (!byPointer && nextActive) requestAnimationFrame(() => tabRefs.current.get(nextActive)?.focus());\n  };\n\n  const open = () => {\n    const base = newTab?.() ?? { title: \"New Tab\", url: \"\", tone: \"neutral\" as const, mark: \"\" };\n    const tab = { ...base, id: `tab-${Date.now()}-${counter++}` };\n    commit([...tabs, tab], tab.id);\n    const el = stripRef.current;\n    if (!el) return;\n    const start = performance.now();\n    const follow = () => {\n      const end = el.scrollWidth - el.clientWidth;\n      const done = reduce || performance.now() - start > 450;\n      el.scrollLeft = done ? end : el.scrollLeft + (end - el.scrollLeft) * 0.3;\n      if (!done) requestAnimationFrame(follow);\n    };\n    requestAnimationFrame(follow);\n  };\n\n  const togglePin = (id: string) => commit(pinnedFirst(tabs.map((t) => (t.id === id ? { ...t, pinned: !t.pinned } : t))));\n  const duplicate = (id: string) => {\n    const i = tabs.findIndex((t) => t.id === id);\n    if (i < 0) return;\n    const copy = { ...tabs[i], id: `tab-${Date.now()}-${counter++}`, pinned: false };\n    const next = [...tabs];\n    next.splice(i + 1, 0, copy);\n    commit(pinnedFirst(next), copy.id);\n  };\n  const closeOthers = (id: string) => commit(tabs.filter((t) => t.id === id || t.pinned), id);\n\n  const move = (id: string, dir: -1 | 1) => {\n    const i = tabs.findIndex((t) => t.id === id);\n    const j = i + dir;\n    if (j < 0 || j >= tabs.length || tabs[j].pinned !== tabs[i].pinned) return;\n    const next = [...tabs];\n    [next[i], next[j]] = [next[j], next[i]];\n    commit(next);\n  };\n\n  const onTabKey = (event: KeyboardEvent<HTMLElement>, tab: BrowserTab) => {\n    const i = tabs.findIndex((t) => t.id === tab.id);\n    if (event.altKey && event.shiftKey && (event.key === \"ArrowLeft\" || event.key === \"ArrowRight\")) {\n      event.preventDefault();\n      move(tab.id, event.key === \"ArrowLeft\" ? -1 : 1);\n      requestAnimationFrame(() => tabRefs.current.get(tab.id)?.focus());\n      return;\n    }\n    let next: BrowserTab | undefined;\n    if (event.key === \"ArrowRight\") next = tabs[(i + 1) % tabs.length];\n    else if (event.key === \"ArrowLeft\") next = tabs[(i - 1 + tabs.length) % tabs.length];\n    else if (event.key === \"Home\") next = tabs[0];\n    else if (event.key === \"End\") next = tabs[tabs.length - 1];\n    else if (event.key === \"Delete\" || event.key === \"Backspace\") {\n      event.preventDefault();\n      close(tab.id);\n      return;\n    }\n    if (next) {\n      event.preventDefault();\n      setActive(next.id);\n      tabRefs.current.get(next.id)?.focus();\n      tabRefs.current.get(next.id)?.scrollIntoView({ block: \"nearest\", inline: \"nearest\" });\n    }\n  };\n\n  const current = tabs.find((t) => t.id === active) ?? null;\n  const menuTarget = tabs.find((t) => t.id === menuTab);\n  const spring = reduce ? { duration: 0 } : { type: \"spring\" as const, stiffness: 520, damping: 42 };\n\n  return (\n    <div className={cn(\"flex w-full max-w-[640px] flex-col overflow-hidden rounded-2xl border bg-card shadow-xs/5\", className)}>\n      <div className=\"flex items-end bg-muted pt-2 ps-3 pe-2\">\n        <div aria-hidden className=\"mb-[13px] me-2 flex shrink-0 gap-1.5\">\n          <span className=\"size-2.5 rounded-full bg-red-400\" />\n          <span className=\"size-2.5 rounded-full bg-amber-400\" />\n          <span className=\"size-2.5 rounded-full bg-emerald-400\" />\n        </div>\n        <div className=\"flex min-w-0 flex-1 items-end\" ref={areaRef}>\n          <ContextMenu>\n            <ContextMenuTrigger className=\"relative min-w-0 shrink\">\n              <div\n                className=\"flex min-w-0 items-end overflow-x-auto overflow-y-hidden pe-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\"\n                onPointerLeave={() => setFrozen(null)}\n                onScroll={checkEdges}\n                ref={stripRef}\n                style={{\n                  maskImage: `linear-gradient(to right, ${edges.left ? \"transparent, black 20px\" : \"black, black\"}, ${edges.right ? \"black calc(100% - 20px), transparent\" : \"black, black\"})`,\n                }}\n              >\n                <Reorder.Group\n                  aria-label=\"Tabs\"\n                  as=\"div\"\n                  axis=\"x\"\n                  className=\"flex shrink-0 items-end\"\n                  onReorder={(next: BrowserTab[]) => commit(pinnedFirst(next))}\n                  role=\"tablist\"\n                  values={tabs}\n                >\n                  <AnimatePresence initial={false}>\n                    {tabs.map((tab, i) => {\n                      const isActive = tab.id === active;\n                      const prevActive = tabs[i - 1]?.id === active;\n                      return (\n                        <Reorder.Item\n                          animate={{ width: tab.pinned ? PINNED_W : width, opacity: 1 }}\n                          as=\"div\"\n                          className={cn(\n                            \"group/tab relative flex h-[34px] shrink-0 cursor-default select-none items-center outline-none\",\n                            isActive ? \"z-10\" : \"z-0\",\n                          )}\n                          dragListener={stripWidth > 0}\n                          exit={{ width: 0, opacity: 0, transition: { duration: reduce ? 0 : 0.16, ease: \"easeOut\" } }}\n                          initial={reduce ? false : { width: 0, opacity: 0 }}\n                          key={tab.id}\n                          layout=\"position\"\n                          onAuxClick={(event) => {\n                            if (event.button === 1) close(tab.id, true);\n                          }}\n                          onContextMenu={() => setMenuTab(tab.id)}\n                          onPointerDown={(event) => {\n                            if (event.button === 0) setActive(tab.id);\n                          }}\n                          role=\"presentation\"\n                          // Reorder.Item adds tabIndex 0 unless it is overridden; focus belongs to the inner tab\n                          tabIndex={undefined}\n                          transition={spring}\n                          value={tab}\n                          whileDrag={{ zIndex: 20 }}\n                        >\n                          <span\n                            aria-hidden\n                            className={cn(\n                              \"absolute top-2.5 left-0 h-3.5 w-px bg-border transition-opacity duration-150\",\n                              i === 0 || isActive || prevActive ? \"opacity-0\" : \"opacity-100\",\n                            )}\n                          />\n                          <div\n                            aria-keyshortcuts=\"Delete\"\n                            aria-label={tab.title}\n                            aria-selected={isActive}\n                            className={cn(\n                              \"relative mx-px flex h-full min-w-0 flex-1 items-center gap-1.5 rounded-t-lg ps-2.5 pe-2 text-xs outline-none transition-colors duration-150\",\n                              \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset\",\n                              isActive\n                                ? \"bg-card text-foreground before:absolute before:-bottom-0 before:-left-2.5 before:size-2.5 before:bg-[radial-gradient(circle_at_0_0,transparent_10px,var(--color-card)_10.5px)] after:absolute after:-right-2.5 after:-bottom-0 after:size-2.5 after:bg-[radial-gradient(circle_at_100%_0,transparent_10px,var(--color-card)_10.5px)]\"\n                                : \"text-muted-foreground [@media(hover:hover)]:group-hover/tab:text-foreground\",\n                              tab.pinned && \"justify-center px-0\",\n                            )}\n                            onKeyDown={(event) => onTabKey(event, tab)}\n                            ref={(el: HTMLDivElement | null) => {\n                              if (el) tabRefs.current.set(tab.id, el);\n                              else tabRefs.current.delete(tab.id);\n                            }}\n                            role=\"tab\"\n                            tabIndex={isActive ? 0 : -1}\n                            title={tab.title}\n                          >\n                            {!isActive && (\n                              <span\n                                aria-hidden\n                                className=\"absolute inset-x-0.5 top-0.5 bottom-1 rounded-lg bg-foreground/0 transition-colors duration-150 [@media(hover:hover)]:group-hover/tab:bg-foreground/6\"\n                              />\n                            )}\n                            <Favicon tab={tab} />\n                            {!tab.pinned && (\n                              <>\n                                <span className=\"relative min-w-0 flex-1 truncate\">{tab.title}</span>\n                                <span aria-hidden className=\"-me-1 size-5 shrink-0\" />\n                              </>\n                            )}\n                          </div>\n                          {!tab.pinned && (\n                            <button\n                              aria-hidden\n                              className={cn(\n                                \"absolute end-[5px] top-1/2 flex size-5 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full before:absolute before:-inset-1.5 text-muted-foreground outline-none transition-[opacity,background-color] duration-150 hover:bg-foreground/10 hover:text-foreground focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring\",\n                                isActive ? \"opacity-100\" : \"opacity-0 group-hover/tab:opacity-100\",\n                              )}\n                              onClick={(event) => {\n                                event.stopPropagation();\n                                close(tab.id, event.detail > 0);\n                              }}\n                              onPointerDown={(event) => event.stopPropagation()}\n                              tabIndex={-1}\n                              type=\"button\"\n                            >\n                              <XIcon className=\"size-3\" />\n                            </button>\n                          )}\n                        </Reorder.Item>\n                      );\n                    })}\n                  </AnimatePresence>\n                </Reorder.Group>\n              </div>\n            </ContextMenuTrigger>\n            <ContextMenuPopup className=\"min-w-44 transition-[scale,opacity] duration-150 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0\">\n              {menuTarget && (\n                <>\n                  <ContextMenuItem onClick={() => togglePin(menuTarget.id)}>\n                    {menuTarget.pinned ? <PinOffIcon /> : <PinIcon />}\n                    {menuTarget.pinned ? \"Unpin tab\" : \"Pin tab\"}\n                  </ContextMenuItem>\n                  <ContextMenuItem onClick={() => duplicate(menuTarget.id)}>\n                    <CopyIcon />\n                    Duplicate\n                  </ContextMenuItem>\n                  <ContextMenuSeparator />\n                  <ContextMenuItem onClick={() => close(menuTarget.id)}>\n                    <XIcon />\n                    Close tab\n                    <ContextMenuShortcut>Del</ContextMenuShortcut>\n                  </ContextMenuItem>\n                  <ContextMenuItem disabled={tabs.length - pinnedCount <= (menuTarget.pinned ? 0 : 1)} onClick={() => closeOthers(menuTarget.id)}>\n                    <ListXIcon />\n                    Close other tabs\n                  </ContextMenuItem>\n                </>\n              )}\n            </ContextMenuPopup>\n          </ContextMenu>\n          <motion.button\n            aria-label=\"New tab\"\n            className=\"relative mb-[5px] ms-1 flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground outline-none transition-[background-color,color,scale] duration-150 hover:bg-foreground/8 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.97]\"\n            onClick={open}\n            type=\"button\"\n          >\n            <PlusIcon className=\"size-4\" />\n          </motion.button>\n        </div>\n      </div>\n\n      <div className=\"flex items-center gap-1 border-b px-2 py-1.5\">\n        {[ArrowLeftIcon, ArrowRightIcon, RotateCwIcon].map((Icon, i) => (\n          <span aria-hidden className={cn(\"flex size-7 items-center justify-center rounded-full text-muted-foreground\", i === 1 && \"opacity-40\")} key={Icon.displayName ?? i}>\n            <Icon className=\"size-3.5\" />\n          </span>\n        ))}\n        <div className=\"ms-1 flex h-7 min-w-0 flex-1 items-center gap-2 rounded-full bg-muted px-3 text-xs\">\n          {current?.url ? <LockIcon className=\"size-3 shrink-0 text-muted-foreground\" /> : null}\n          <span className=\"truncate\">\n            {current?.url ? (\n              <>\n                <span className=\"text-foreground\">{current.url.split(\"/\")[0]}</span>\n                <span className=\"text-muted-foreground\">{current.url.slice(current.url.split(\"/\")[0].length)}</span>\n              </>\n            ) : (\n              <span className=\"text-muted-foreground\">Search or type a URL</span>\n            )}\n          </span>\n        </div>\n      </div>\n\n      <div className=\"relative h-52 overflow-hidden\">\n        {current ? (\n          (renderPage ?? DefaultPage)(current)\n        ) : (\n          <div className=\"flex h-full flex-col items-center justify-center gap-2 text-center\">\n            <p className=\"font-medium text-sm\">No tabs open</p>\n            <button\n              className=\"flex h-8 cursor-pointer items-center gap-1.5 rounded-lg border bg-background px-3 font-medium text-sm shadow-xs/5 outline-none transition-[background-color,scale] hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.97]\"\n              onClick={open}\n              type=\"button\"\n            >\n              <PlusIcon className=\"size-4\" />\n              New tab\n            </button>\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n\nfunction Favicon({ tab }: { tab: BrowserTab }) {\n  const mark = tab.mark ?? tab.title.charAt(0);\n  if (!mark) return <GlobeIcon aria-hidden className=\"relative size-4 shrink-0 text-muted-foreground\" strokeWidth={1.75} />;\n  return (\n    <span\n      aria-hidden\n      className={cn(\n        \"relative flex size-4 shrink-0 items-center justify-center rounded-[calc(var(--radius)*0.4)] font-bold text-[9px] leading-none\",\n        FAVICON[tab.tone ?? \"neutral\"],\n      )}\n    >\n      {mark}\n    </span>\n  );\n}\n\nfunction DefaultPage(tab: BrowserTab) {\n  const tint = PAGE_TINT[tab.tone ?? \"neutral\"];\n  if (!tab.url) {\n    return (\n      <div className=\"flex h-full flex-col items-center justify-center gap-3\">\n        <div className=\"flex h-9 w-64 max-w-[80%] items-center rounded-full border bg-background px-4 text-muted-foreground text-xs shadow-xs/5\">Search the web</div>\n        <div className=\"flex gap-3\">\n          {[\"S\", \"M\", \"D\", \"B\"].map((l) => (\n            <span className=\"flex size-9 items-center justify-center rounded-full bg-muted font-medium text-muted-foreground text-xs\" key={l}>\n              {l}\n            </span>\n          ))}\n        </div>\n      </div>\n    );\n  }\n  return (\n    <div className=\"flex h-full flex-col gap-3 p-4\">\n      <div className=\"flex items-center gap-2\">\n        <Favicon tab={tab} />\n        <span className=\"font-semibold text-sm\">{tab.title.split(\" \")[0]}</span>\n        <span className=\"ms-auto flex gap-3 text-muted-foreground text-xs\">\n          <span>Product</span>\n          <span>Pricing</span>\n          <span>Docs</span>\n        </span>\n      </div>\n      <div className={cn(\"flex flex-1 flex-col justify-end gap-1.5 rounded-xl p-4\", tint)}>\n        <p className=\"text-balance font-semibold text-base text-foreground\">{tab.headline ?? tab.title}</p>\n        <div className=\"h-1.5 w-2/3 rounded-full bg-current opacity-25\" />\n        <div className=\"h-1.5 w-1/2 rounded-full bg-current opacity-25\" />\n      </div>\n      <div className=\"grid grid-cols-3 gap-2\">\n        {[0, 1, 2].map((i) => (\n          <div className=\"h-8 rounded-lg bg-muted\" key={i} />\n        ))}\n      </div>\n    </div>\n  );\n}\n"
    }
  ]
}