{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tab-scroller",
  "title": "Tab Scroller",
  "description": "A horizontally scrolling strip for a row of tabs that does not fit, which keeps the selected tab in view and puts an arrow at each end while there is more that way. Reach for it wherever a tab row outgrew the space it was designed for: a settings screen whose sections kept being added to, a project or repository page with Overview / Activity / Insights / Settings and six more, a dashboard switching between data sets, a mobile category strip, an admin console's per-entity tabs, a code viewer with one tab per file, a wizard shown as tabs in a narrow dialog, a sidebar somebody can drag narrower, a tab row that was four items when it was designed and is fourteen now, or any tab list whose labels are translated into a language that makes them 40% longer. Common asks it answers: \"scrollable tabs react\", \"shadcn tabs overflow\", \"tabs too many to fit\", \"horizontal scrolling tabs\", \"tabs with scroll arrows\", \"scroll active tab into view\", \"keep selected tab visible\", \"tabs scroll to selected\", \"MUI Tabs variant scrollable equivalent shadcn\", \"scrollButtons auto\", \"antd tabs overflow\", \"chrome-style tab strip\", \"tab bar scrolls on mobile\", \"tabs wrap to second line\", \"stop tabs from wrapping\", \"tabs squeeze and truncate\", \"radix tabs overflow scroll\", \"TabsList scrollable\", \"scrollIntoView scrolls the whole page\", \"scrollIntoView block nearest still scrolls page\", \"prevent scrollIntoView from scrolling parent\", \"scroll a div without scrolling the page\", \"scrollLeft negative in RTL\", \"rtl horizontal scroll react\", \"hide scrollbar but keep scrolling\", \"scrollbar-width none tailwind\", \"show arrows only when scrollable\", \"detect if a div is scrollable\", \"ResizeObserver loop with overflow buttons\", \"arrow buttons change the width they measured\", \"roving tabindex arrow keys conflict with scroll\", \"tablist keyboard navigation\", \"タブ スクロール react\", \"タブが2行になる\", \"タブが多すぎて入らない\", \"選択中のタブを見える位置へ\", \"横スクロールするタブ\", \"タブに矢印ボタン\", \"scrollIntoView でページごと動く\", \"親要素だけスクロールさせたい\", \"RTL で scrollLeft が負になる\", \"スクロールバーを隠す\", \"矢印は端で消す\", \"tablist 矢印キー 競合\". What official shadcn/ui ships has no overflow story at all, and that is an omission rather than a design decision. Its `tabs` is a 3,772-byte Radix wrapper whose only dependencies are `cn` and `radix-ui`: `TabsList` is `inline-flex w-fit`, so it is sized by its contents and simply becomes wider than its parent, and `TabsTrigger` carries `flex-1` with `whitespace-nowrap`, so the triggers first squeeze each other toward their text and then run past the edge, where whatever ancestor happens to clip decides what you can still see. Nothing in it measures anything. Across the whole official registry today — 65 items listed, 62 fetchable, `code-block`, `questionnaire` and `trace` indexed and 404 on both style tracks, 223,287 bytes of `files[].content` concatenated — `scrollIntoView`, `scrollWidth`, `clientWidth`, `offsetWidth`, `scrollLeft`, `scrollTo`, `ResizeObserver` and `tablist` are every one of them a zero hit, and the single match for `aria-selected` is a Tailwind variant in `calendar`. The two neighbours that do scroll are a different job: `carousel` hands its scrolling to `embla-carousel-react` and moves between slides, with no selected item to follow, and `message-scroller` delegates to `@shadcn/react/message-scroller` and chases the newest message at the bottom. Neither has the thing a tab strip needs, which is to follow a selection somebody else owns. The obvious implementation is `scrollIntoView` on the selected tab, and it is wrong in a way that is invisible while you build it. `scrollIntoView` scrolls every scrollable ancestor to reveal the element, the document included, so on a short page with the strip near the top it looks perfect — and on a real page, with the tabs partway down a long settings screen or inside a dialog, selecting a tab scrolls the page vertically and throws the content the reader was looking at off the screen. `block: \"nearest\"` shortens the trip without stopping it: nearest is still a scroll whenever the element is not already fully visible, and it still applies to each ancestor in turn. Measured in Chrome rather than argued about: on a strip 1,200px down a tall page, `scrollIntoView({ block: \"nearest\", inline: \"nearest\" })` on the fifth tab took the document from `scrollY` 0 to 1,201 and parked the tab hard against the edge at `scrollLeft` 300 with no gutter, while the same tab through this component moved the strip to 332 and left `scrollY` at 0 — predicted offset 332 against an actual 332, with the 32px gutter left past the tab exactly as asked. The only way to move one box and nothing else is to work out the offset and set it on that box, which is what this does — `nextTabScroll` is the arithmetic as a pure function and the strip is moved with `scrollTo` on itself, so nothing above it in the tree is touched. The arrows are painted over the ends of the strip rather than laid out beside it, and that is load-bearing. Arrows that take part in layout make the window narrower exactly when they appear, and the window's width is the input that decided they were needed, so a strip whose tabs fit the full width but not the width-minus-two-buttons has no stable answer — the version everybody writes first either oscillates or settles on whichever state it started in. Overlaid, the window is the same width whether they show or not, so the measurement cannot be disturbed by its own result: measured on this pair, an absolutely positioned button left the scrollport at 300px while the same button as a flex sibling changed it. They are `aria-hidden` and not tab stops, because the scrolling they do is already reachable: moving the selection scrolls the strip, and a screen reader offered \"scroll tabs right\" has no way to aim it or to know when to stop. They stand in for the scrollbar, which is hidden — a horizontal scrollbar under a row of tabs is a second control for the same job that also changes the strip's height on the platforms that reserve room for it. It binds no keys, and that is the one thing that makes a scrolling tab strip different from a scrolling row of anything else: a `tablist` has already spent the arrow keys. Left and Right move the selection, and binding them here as well would collide with the keys that do, resolving the wrong way round — the person pressing Right wants the next tab, not the same tab with the strip slid underneath it. So this wraps a tab strip instead of being one. No role is imposed on the children and no tab state is owned here: Radix's `TabsList` stays the `tablist`, its triggers stay the tabs, and keyboard users move the selection while the selection brings itself into view, which is the same motion driven by the input that was already there. Which is also why it takes no prop saying which tab is current. The selected tab is found with a selector covering both conventions — `aria-selected=\"true\"` from the ARIA practices and a hand-rolled strip, `data-state=\"active\"` from Radix and therefore from official's `TabsTrigger` — and a `MutationObserver` watching just those two attributes re-reads it when either changes anywhere inside. Nothing has to be lifted or wired, and it behaves the same under Radix, under your own strip, and under a router that derives the tab from the URL. It is re-scrolled only when the selected tab actually changes, never on any other mutation, so a badge count ticking over or a label being translated does not drag back a user who scrolled by hand to look at something; a resize is the single exception, because it can carry the selected tab out of the window and the tab being visible is the invariant worth re-asserting. Which end has more is `scroll-shadow`'s `useScrollEdges`, imported rather than written again — that hook's own documentation names arrow buttons on a tab strip as the reason it is exported. It brings the two things a hand-rolled version gets wrong: that an RTL box reports `scrollLeft === 0` while half its tabs are hidden off to the left, because the CSSOM puts 0 at the initial position and runs negative going left — a fresh RTL strip here reported `scrollLeft` 0 with 420px of tabs hidden, and the offset that brings the same tab into view is written as -332; and that a `scroll` listener alone leaves the arrows missing on arrival, which is the moment they are needed, since a strip that arrives from a fetch already too wide — or inside a panel that was `display: none` until its accordion opened — has overflowed without anybody scrolling. Three smaller things are written down in the source because each is a way to be a pixel or two wrong in a visible place: the window is the scrollport rather than the border box, so a strip with a border does not park its tab under an arrow; a gutter of 32 is kept between the tab and the edge, because a tab flush against the edge cannot be told from one that continues past it and is half-covered by the chevron painted over it; and a target within half a pixel of where the strip already is is not asked for, since an animated scroll is itself a stream of scroll events and a smooth scroll to where you already are never finishes. The first positioning is instant and the rest animate, so a strip whose twelfth tab is selected on arrival does not sweep past the first eleven on load, and `prefers-reduced-motion` turns the animation off on both the selection path and the arrows. What you get: `TabScroller`, wrapping whatever tab strip you already have, with `align` (`\"nearest\"` moves as little as possible and leaves a tab that is already comfortably in view exactly where it is; `\"center\"` always parks it in the middle), `gutter`, `arrows`, `scrollStep` — honoured at `0`, which is the value a caller passes to pin the arrows — `activeSelector` if your strip marks the current tab some third way, `viewportRef` for calling `scrollTo` yourself, and `viewportClassName` for the scrolling element's padding, gap or scrollbar. `nextTabScroll`, `readTabVisibility` and `scrollLeftForOffset` are exported separately: the arithmetic, the measurement and the RTL sign rule, each assertable on its own, which is how the clamps, the gutter boundaries, the tab wider than the window and both writing directions are tested as numbers rather than reasoned about. One file, two lucide icons, no `radix-ui`. Honest limits: the server cannot measure, so an SSR'd strip renders scrolled to its start with no arrows and a selected tab that is off-window is correct one frame after hydration rather than in the HTML; and the strip clips, so a popover opened from a tab should be portalled out as usual. Distinct from its neighbours here: `scroll-shadow` fades an edge to say there is more past it and has no selected item to follow, and this composes with it rather than repeating it; `toolbar-overflow` folds what does not fit into a menu at the end, which is the right answer for actions and the wrong one for tabs, since a tab hidden in a menu cannot show that it is the current one; `breadcrumb-overflow` folds the middle of a path; `overflow-tooltip` reveals one string that CSS has already clipped, which is the answer for a long label rather than for a long row; `segmented-control` is a `radiogroup` picking a value among a few short options that are meant to fit; and `swipe-actions` and `pull-to-refresh` are touch gestures rather than a measured row.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "https://pulld.pages.dev/r/scroll-shadow.json"
  ],
  "files": [
    {
      "path": "registry/ui/tab-scroller.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ChevronLeft, ChevronRight } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useScrollEdges } from \"@/registry/ui/scroll-shadow\"\n\n/** Where a tab is parked once it has been brought into view. */\nexport type TabScrollerAlign = \"nearest\" | \"center\"\n\n/**\n * How far the strip is scrolled, and where the tab sits relative to the visible window.\n *\n * Everything here is **logical**: `leadingGap` is the room between the inline start of the window\n * and the inline start of the tab, whichever way the text runs, so the arithmetic below never has\n * to know about writing direction. {@link readTabVisibility} resolves the direction once, on the\n * way in, and {@link scrollLeftForOffset} puts the sign back on the way out.\n */\nexport interface TabVisibility {\n  /** Room between the window's inline start and the tab's. Negative when the tab starts off-window. */\n  leadingGap: number\n  /** Room between the tab's inline end and the window's. Negative when the tab runs past the end. */\n  trailingGap: number\n  /** How far the strip is already scrolled from its inline start. Never negative. */\n  scrolled: number\n  /** `scrollWidth - clientWidth` — the furthest the strip can be scrolled. */\n  maxScroll: number\n}\n\nexport interface TabScrollOptions {\n  align?: TabScrollerAlign\n  /** Pixels kept between the tab and the edge of the window. See {@link DEFAULT_GUTTER}. */\n  gutter?: number\n}\n\n/**\n * How much room is kept between the brought-into-view tab and the edge of the window.\n *\n * Not zero, for two reasons that both show up as \"the selected tab looks wrong\". A tab flush\n * against the edge reads as cut off — there is no way to tell a tab that ends at the window's edge\n * from one that continues past it — and the arrow buttons are painted *over* the ends of the strip,\n * so a tab parked at the edge sits underneath one and is half-covered by a chevron. 32 is the width\n * of those buttons.\n */\nconst DEFAULT_GUTTER = 32\n\n/** Fraction of the window an arrow button travels: a screenful, less a sliver kept for context. */\nconst ARROW_STEP_RATIO = 0.8\n\n/**\n * Sub-pixel difference between where the strip is and where it should be that is not worth a scroll.\n *\n * Layout is fractional and `scrollLeft` is not reliably so, so the target computed from a set of\n * rects is routinely a quarter of a pixel from where the strip already is. Without this the\n * component asks for a scroll on every measurement, and because an animated scroll is itself a\n * stream of scroll events, each one arriving while the last is still running, a smooth scroll to\n * the place you are already in never finishes.\n */\nexport const TAB_SCROLL_EPSILON = 0.5\n\nconst clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max)\n\n/**\n * Where the strip should be scrolled to so that `visibility`'s tab is in view, as a logical offset.\n *\n * Pure, because every interesting case here is a set of numbers rather than a click: a tab that\n * starts before the window, one that runs past it, one wider than the window itself, one already\n * comfortably in view, and the two ends where the answer has to be clamped rather than honoured.\n *\n * `\"nearest\"` moves as little as possible — the tab that is off the leading end comes to the\n * leading gutter, the tab off the trailing end comes to the trailing gutter, and a tab already in\n * view with room on both sides is left exactly where it is. That last case is the one that makes\n * the difference between a strip that follows you and a strip that fidgets: re-centring a tab that\n * is already perfectly readable moves the other tabs out from under the pointer.\n *\n * `\"center\"` always moves, which is the right default for a strip of many short tabs where the\n * selected one should be somewhere predictable rather than wherever it happened to land.\n *\n * A tab wider than the window cannot satisfy both gutters, and the leading edge wins: the start of\n * a label is worth more than its end.\n */\nexport function nextTabScroll(\n  visibility: TabVisibility,\n  { align = \"nearest\", gutter = DEFAULT_GUTTER }: TabScrollOptions = {}\n): number {\n  const { leadingGap, trailingGap, scrolled, maxScroll } = visibility\n  const limit = Math.max(0, maxScroll)\n\n  if (align === \"center\") {\n    // Scrolling forward by d takes d off the leading gap and adds it to the trailing one, so the\n    // two are equal at exactly half their difference.\n    return clamp(scrolled + (leadingGap - trailingGap) / 2, 0, limit)\n  }\n\n  if (leadingGap < gutter) return clamp(scrolled + leadingGap - gutter, 0, limit)\n  if (trailingGap < gutter) return clamp(scrolled + gutter - trailingGap, 0, limit)\n  return scrolled\n}\n\n/**\n * A logical scroll offset as the `scrollLeft` the CSSOM wants.\n *\n * In an LTR box the two are the same number. In an RTL box `scrollLeft` is 0 at the *initial*\n * position — the right-hand end — and runs negative going left, so the logical distance travelled\n * from the inline start is the negation. This is the same rule `scroll-shadow` documents for\n * reading the edges, kept in one function here so the component never writes the sign by hand.\n */\nexport function scrollLeftForOffset(offset: number, rtl: boolean): number {\n  // Zero is the initial position either way round, and negating it produces `-0` — which scrolls\n  // the same but is a different number, so it leaks into anything that compares offsets or writes\n  // one down.\n  if (!rtl || offset === 0) return offset\n  return -offset\n}\n\n/**\n * Where `tab` sits inside `viewport`'s scrolling window, in logical units.\n *\n * The window is the **scrollport** rather than the border box: `clientLeft` is subtracted off and\n * `clientWidth` used for the far edge, so a strip with a border is not reported as a window a\n * border's width too wide, which would park every tab a pixel or two under an arrow.\n */\nexport function readTabVisibility(\n  viewport: HTMLElement,\n  tab: HTMLElement,\n  rtl: boolean\n): TabVisibility {\n  const box = viewport.getBoundingClientRect()\n  const tabBox = tab.getBoundingClientRect()\n  const windowStart = box.left + viewport.clientLeft\n  const windowEnd = windowStart + viewport.clientWidth\n\n  return {\n    leadingGap: rtl ? windowEnd - tabBox.right : tabBox.left - windowStart,\n    trailingGap: rtl ? tabBox.left - windowStart : windowEnd - tabBox.right,\n    scrolled: rtl ? -viewport.scrollLeft : viewport.scrollLeft,\n    maxScroll: viewport.scrollWidth - viewport.clientWidth,\n  }\n}\n\n/**\n * The selected tab, under either of the two conventions a tab strip can be built with.\n *\n * `aria-selected` is what the ARIA practices put on the selected tab, and what a hand-rolled strip\n * will have. `data-state=\"active\"` is what Radix — and therefore official shadcn/ui's `TabsTrigger`\n * — puts there. Matching both is why this component needs no prop telling it which tab is current\n * and no control over the tab state itself: it reads the same attribute a screen reader does.\n */\nexport const DEFAULT_ACTIVE_TAB_SELECTOR =\n  '[role=\"tab\"][aria-selected=\"true\"], [role=\"tab\"][data-state=\"active\"], [data-state=\"active\"]'\n\nexport interface TabScrollerProps extends React.ComponentPropsWithoutRef<\"div\"> {\n  /** How the selected tab is found. Defaults to {@link DEFAULT_ACTIVE_TAB_SELECTOR}. */\n  activeSelector?: string\n  /** Where a tab is parked once brought into view. Defaults to `\"nearest\"`. */\n  align?: TabScrollerAlign\n  /** Pixels kept between the tab and the window's edge. Defaults to 32. */\n  gutter?: number\n  /** Show the overlaid arrow buttons while there is more in that direction. Defaults to true. */\n  arrows?: boolean\n  /** Pixels an arrow travels. Defaults to 80% of the window. */\n  scrollStep?: number\n  /** Ref to the scrolling element — the one to call `scrollTo` on. */\n  viewportRef?: React.Ref<HTMLDivElement>\n  /** Classes for the scrolling element: padding, `gap-2`, `scroll-smooth`. */\n  viewportClassName?: string\n  /** Classes for both arrow buttons. */\n  arrowClassName?: string\n}\n\n/**\n * A horizontally scrolling strip for a row of tabs that does not fit, which keeps the selected tab\n * in view and puts an arrow at each end while there is more that way.\n *\n * ```tsx\n * <Tabs value={tab} onValueChange={setTab}>\n *   <TabScroller>\n *     <TabsList className=\"w-max\">\n *       <TabsTrigger value=\"overview\">Overview</TabsTrigger>\n *       …twenty more…\n *     </TabsList>\n *   </TabScroller>\n *   <TabsContent value=\"overview\">…</TabsContent>\n * </Tabs>\n * ```\n *\n * **It wraps a tab strip rather than being one.** No `role` is imposed on the children and no tab\n * state is owned here, because the strip's own library already owns both: Radix's `TabsList` is the\n * `tablist`, its triggers are the tabs, and its Left/Right keys move the selection. That division\n * is the answer to the one thing that makes a scrolling tab strip different from a scrolling row of\n * anything else — **a `tablist` has already spent the arrow keys.** Binding Left/Right here to\n * scroll the strip would collide with the keys that move the selection, and the collision resolves\n * the wrong way round: the keyboard user who presses Right wants the next tab, and scrolling the\n * strip underneath them while the selection stays put is the one outcome nobody asked for. So this\n * component binds no keys at all. Keyboard users move the selection, and the selection brings\n * itself into view — which is the same motion, driven by the input that was already there.\n *\n * **Which is why it watches the DOM instead of taking a prop.** The selected tab is found with a\n * selector covering `aria-selected=\"true\"` and `data-state=\"active\"`, and a `MutationObserver`\n * re-reads it when either attribute changes anywhere inside. Nothing has to tell this component\n * what the current tab is, nothing has to be lifted, and it works the same under Radix, under a\n * hand-rolled strip, and under a router that decides the tab from the URL.\n *\n * **What it does not do is call `scrollIntoView`.** That is the obvious implementation and it is\n * wrong in a way that is invisible in development: `scrollIntoView` scrolls *every* scrollable\n * ancestor to reveal the element, the document included. On a short page, where the strip is near\n * the top and nothing else scrolls, it looks perfect. On a real page — a strip of tabs partway down\n * a long settings screen, inside a dialog, inside a horizontally scrolling layout — selecting a tab\n * scrolls the page vertically to bring the strip into view and jumps the content the user was\n * reading. `block: \"nearest\"` reduces how far it goes but does not stop it: \"nearest\" is still a\n * scroll when the element is not fully visible, and it still applies to each ancestor in turn.\n *\n * Measured in Chrome rather than argued about: on a strip 1,200px down a tall page,\n * `scrollIntoView({ block: \"nearest\", inline: \"nearest\" })` on the fifth tab took the document from\n * `scrollY` 0 to **1,201**, and parked the tab hard against the edge at `scrollLeft` 300 with no\n * gutter. The same tab through this component's arithmetic moved the strip to `scrollLeft` 332 and\n * left `scrollY` at 0 — the predicted offset was 332 and the actual was 332, with 32px of gutter\n * left past the tab exactly as asked. The only way to move one box and nothing else is to compute\n * the offset and set it on that box, which is what {@link nextTabScroll} and\n * {@link readTabVisibility} are for.\n *\n * **The arrows are painted over the strip, not laid out beside it**, and that is load-bearing\n * rather than cosmetic. Arrows that take part in layout make the window narrower exactly when they\n * appear, and the window's width is the input that decided whether they were needed — so a strip\n * whose tabs fit the full width but not the width-minus-two-buttons has no stable answer, and the\n * version everybody writes first oscillates or settles on whichever state it happened to start in.\n * Overlaying them means the window is the same width whether they are showing or not, so the\n * measurement cannot be disturbed by its own result — measured on this pair, an absolutely\n * positioned button left the scrollport at 300px, and the same button as a flex sibling changed it. They are `aria-hidden` and not tab stops: the\n * scrolling they do is reachable without them, since moving the selection scrolls the strip, and a\n * screen reader offered \"scroll tabs right\" has no way to know how far it went or when to stop.\n * They are a pointer affordance, standing in for the scrollbar this hides.\n *\n * Which end has more is {@link useScrollEdges} from `scroll-shadow`, imported rather than written\n * again — the hook's own documentation names arrow buttons on a tab strip as the reason it is\n * exported. It brings with it the two things a hand-rolled version of this gets wrong: that an RTL\n * box reports `scrollLeft === 0` while half its tabs are hidden off to the left, and that a `scroll`\n * listener alone leaves the arrows missing on arrival, which is the moment they are needed — a\n * strip that arrives from a fetch already too wide, or inside a panel that was `display: none` until\n * its accordion opened, has overflowed without anybody scrolling.\n *\n * The first positioning is instant and the rest are smooth, so a strip whose twelfth tab is\n * selected on arrival does not animate past the first eleven on load, and `prefers-reduced-motion`\n * turns the animation off entirely. The strip is only re-scrolled when the selected tab actually\n * changes, never on any other mutation, so a user who scrolls the strip by hand to look at\n * something is not dragged back. A resize is the one exception: it can carry the selected tab out\n * of the window, and the tab being visible is the invariant worth re-asserting.\n *\n * Honest limits: the server cannot measure, so an SSR'd strip renders scrolled to its start with no\n * arrows until hydration, and a strip whose selected tab is off-window is correct one frame later\n * rather than in the HTML. The scrollbar is hidden — the arrows and the fade of\n * `scroll-shadow` are the affordances in its place — so pass `viewportClassName` to put it back if\n * a design wants it.\n */\nexport const TabScroller = React.forwardRef<HTMLDivElement, TabScrollerProps>(\n  function TabScroller(\n    {\n      activeSelector = DEFAULT_ACTIVE_TAB_SELECTOR,\n      align = \"nearest\",\n      gutter = DEFAULT_GUTTER,\n      arrows = true,\n      scrollStep,\n      viewportRef,\n      viewportClassName,\n      arrowClassName,\n      className,\n      children,\n      ...props\n    },\n    ref\n  ) {\n    const innerRef = React.useRef<HTMLDivElement>(null)\n    React.useImperativeHandle(viewportRef, () => innerRef.current as HTMLDivElement)\n\n    const edges = useScrollEdges(innerRef)\n\n    // The tab the strip was last scrolled for. Comparing against it is what keeps this from\n    // fighting the user: every other mutation inside a tab strip — a badge count changing, a label\n    // being translated, a tab being added — would otherwise re-assert the scroll position and undo\n    // a scroll somebody made by hand a moment ago.\n    const lastActive = React.useRef<Element | null>(null)\n    // Whether anything has been positioned yet, so the first one can be instant.\n    const positioned = React.useRef(false)\n\n    const scrollToTab = React.useCallback(\n      (tab: HTMLElement, instant: boolean) => {\n        const viewport = innerRef.current\n        if (!viewport) return\n\n        const rtl = getComputedStyle(viewport).direction === \"rtl\"\n        const visibility = readTabVisibility(viewport, tab, rtl)\n        const target = nextTabScroll(visibility, { align, gutter })\n        if (Math.abs(target - visibility.scrolled) <= TAB_SCROLL_EPSILON) return\n\n        const reduced =\n          viewport.ownerDocument.defaultView?.matchMedia?.(\"(prefers-reduced-motion: reduce)\")\n            .matches ?? false\n\n        viewport.scrollTo({\n          left: scrollLeftForOffset(target, rtl),\n          behavior: instant || reduced ? \"auto\" : \"smooth\",\n        })\n      },\n      [align, gutter]\n    )\n\n    React.useEffect(() => {\n      const viewport = innerRef.current\n      if (!viewport) return\n\n      let frame: number | null = null\n\n      // `force` is the resize path: the selected tab has not changed, but the window it had to fit\n      // in has, so the comparison against `lastActive` is deliberately skipped.\n      const sync = (force: boolean) => {\n        const active = viewport.querySelector<HTMLElement>(activeSelector)\n        if (!active) return\n        if (!force && active === lastActive.current) return\n        lastActive.current = active\n        const instant = !positioned.current\n        positioned.current = true\n        scrollToTab(active, instant)\n      }\n\n      const schedule = (force: boolean) => {\n        if (frame !== null) cancelAnimationFrame(frame)\n        // One frame later, so a selection change that also re-renders the strip is measured against\n        // the layout it produced rather than the one it replaced.\n        frame = requestAnimationFrame(() => {\n          frame = null\n          sync(force)\n        })\n      }\n\n      sync(false)\n\n      let observer: MutationObserver | null = null\n      if (typeof MutationObserver !== \"undefined\") {\n        observer = new MutationObserver(() => schedule(false))\n        observer.observe(viewport, {\n          subtree: true,\n          attributes: true,\n          // Only the two attributes that can mean \"this is the selected tab now\". Watching every\n          // attribute would wake this on each class change, and Radix changes classes on hover.\n          attributeFilter: [\"aria-selected\", \"data-state\"],\n          // A tab strip whose tabs arrive from a fetch has no selected tab to find on mount.\n          childList: true,\n        })\n      }\n\n      let resizeObserver: ResizeObserver | null = null\n      if (typeof ResizeObserver !== \"undefined\") {\n        resizeObserver = new ResizeObserver(() => schedule(true))\n        resizeObserver.observe(viewport)\n      }\n\n      return () => {\n        if (frame !== null) cancelAnimationFrame(frame)\n        observer?.disconnect()\n        resizeObserver?.disconnect()\n      }\n    }, [activeSelector, scrollToTab])\n\n    const nudge = (direction: 1 | -1) => {\n      const viewport = innerRef.current\n      if (!viewport) return\n      const step = scrollStep ?? Math.max(1, viewport.clientWidth * ARROW_STEP_RATIO)\n      const reduced =\n        viewport.ownerDocument.defaultView?.matchMedia?.(\"(prefers-reduced-motion: reduce)\")\n          .matches ?? false\n      // `scrollBy` is physical in both writing directions — left is left — which is what these\n      // buttons mean, so neither the sign nor the chevrons need mirroring for an RTL strip.\n      viewport.scrollBy({ left: direction * step, behavior: reduced ? \"auto\" : \"smooth\" })\n    }\n\n    const arrowBase =\n      \"absolute inset-y-0 z-10 flex w-8 items-center justify-center bg-background text-muted-foreground transition-opacity hover:text-foreground\"\n\n    return (\n      <div\n        ref={ref}\n        // Not `overflow-hidden`: the arrows are absolutely positioned children and clipping here\n        // would be fine for them, but a popover opened from a tab would be clipped too, and the\n        // scrolling child already clips what it scrolls.\n        className={cn(\"relative min-w-0\", className)}\n        data-more-left={edges.left ? \"\" : undefined}\n        data-more-right={edges.right ? \"\" : undefined}\n        {...props}\n      >\n        <div\n          ref={innerRef}\n          className={cn(\n            \"min-w-0 overflow-x-auto overflow-y-hidden\",\n            // The scrollbar is replaced by the arrows. A horizontal scrollbar under a row of tabs\n            // is a second control for the same job that also changes the strip's height on the\n            // platforms that reserve room for it.\n            \"[scrollbar-width:none] [&::-webkit-scrollbar]:hidden\",\n            viewportClassName\n          )}\n        >\n          {children}\n        </div>\n\n        {arrows && edges.left && (\n          <button\n            type=\"button\"\n            // See {@link TabScroller}: a redundant pointer affordance for a scroll that the tab\n            // selection already performs, so exposing it would add a control a keyboard user\n            // cannot usefully aim.\n            aria-hidden=\"true\"\n            tabIndex={-1}\n            onClick={() => nudge(-1)}\n            // Physical `left-0`, not logical `start-0`. Everything about these buttons is physical\n            // — the edge that decides whether to show one (`edges.left`), the direction `scrollBy`\n            // travels, and the chevron glyph — so a logical inset would put the left-pointing\n            // button on the right of an RTL strip, showing it for content hidden at the other end.\n            className={cn(arrowBase, \"left-0\", arrowClassName)}\n          >\n            <ChevronLeft className=\"size-4\" aria-hidden=\"true\" />\n          </button>\n        )}\n\n        {arrows && edges.right && (\n          <button\n            type=\"button\"\n            aria-hidden=\"true\"\n            tabIndex={-1}\n            onClick={() => nudge(1)}\n            className={cn(arrowBase, \"right-0\", arrowClassName)}\n          >\n            <ChevronRight className=\"size-4\" aria-hidden=\"true\" />\n          </button>\n        )}\n      </div>\n    )\n  }\n)\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui",
  "docs": "Install any pulld component by name: add \"@pulld\": \"https://pulld.pages.dev/r/{name}.json\" to the registries block in components.json, then `npx shadcn add @pulld/<name>`. All 140 components: https://pulld.pages.dev/?utm_source=cli"
}
