1 slot open this monthBy the makers of shadcnregistryWe built shadcnregistry. Now we build your product.Book a callCheck availability
1 slot open this monthAI features?We build AI features into your product.Book a callCheck availability
1 slot open this month🚨 Slop police🚨 Slop police: we clean up vibe-coded apps.Report itReport your app
shadcnregistry
shadcnregistry
RegistryHealthInspect
Terms of ServicePrivacy Policy
  1. Registries
  2. pulld
  3. Tab Scroller

Tab Scroller

ui

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.

pnpm dlx shadcn@latest add "https://pulld.pages.dev/r/tab-scroller.json"
Premium blocks for shadcn/uiShadcnblocks.com · AffiliateExplore
  • lucide-react
  • scroll-shadow
components/ui/tab-scroller.tsx