1- import type { ReactNode } from "react" ;
1+ import { useEffect , useRef , useState , type ReactNode } from "react" ;
22import type { TabsTab } from "@base-ui/react/tabs" ;
33import { Tabs as TabsPrimitive } from "@base-ui/react/tabs" ;
4+ import { useDrag } from "@use-gesture/react" ;
45import { cn } from "../../utils/cn" ;
56
67/** Tabs variant definitions. */
@@ -150,6 +151,8 @@ export function Tabs({
150151 const isSegmented = variant === "segmented" ;
151152 const isUnderline = variant === "underline" ;
152153 const isSm = size === "sm" ;
154+ const { ref : listRef , isOverflowing } = useOverflowDetect ( isSegmented ) ;
155+ const bindDrag = useHorizontalDragScroll ( listRef , isOverflowing ) ;
153156
154157 return (
155158 < TabsPrimitive . Root
@@ -165,10 +168,13 @@ export function Tabs({
165168 < div className = { cn ( "absolute inset-x-0 top-1/2 z-0 -translate-y-1/2 rounded-lg bg-kumo-recessed" , isSm ? "h-6.5" : "h-9" ) } />
166169 ) }
167170 < TabsPrimitive . List
171+ ref = { listRef }
168172 activateOnFocus = { activateOnFocus }
173+ data-overflowing = { isOverflowing ? "" : undefined }
174+ { ...bindDrag ( ) }
169175 className = { cn (
170- "scrollbar-hide relative flex min-w-0 shrink items-stretch" ,
171- isSegmented && "rounded-lg bg-kumo-recessed px-0.5 ring ring-kumo-hairline/70" ,
176+ "relative flex min-w-0 shrink items-stretch" ,
177+ isSegmented && "kumo-tabs-list overflow-x-auto rounded-lg bg-kumo-recessed px-0.5 touch-pan-y ring ring-kumo-hairline/70 [--scroll-fade-width:3rem] " ,
172178 isSegmented && ( isSm ? "h-6.5 rounded-md" : "h-9" ) ,
173179 isUnderline && "gap-4 border-b border-kumo-hairline pb-2" ,
174180 isUnderline && ( isSm ? "h-6.5" : "h-7.5" ) ,
@@ -215,3 +221,60 @@ export function Tabs({
215221 </ TabsPrimitive . Root >
216222 ) ;
217223}
224+
225+ // ─── Horizontal drag-to-scroll ────────────────────────────────────────
226+
227+ /**
228+ * Enables pointer/touch drag to horizontally scroll the tab list.
229+ * Only active when the list is overflowing. Prevents vertical scroll
230+ * interference and uses `touch-action: pan-y` so native vertical
231+ * scrolling is preserved.
232+ */
233+ function useHorizontalDragScroll (
234+ ref : React . RefObject < HTMLElement | null > ,
235+ enabled : boolean ,
236+ ) {
237+ return useDrag (
238+ ( { delta : [ dx ] , event } ) => {
239+ const el = ref . current ;
240+ if ( ! el || ! enabled ) return ;
241+ // Prevent text selection while dragging
242+ event ?. preventDefault ( ) ;
243+ el . scrollLeft -= dx ;
244+ } ,
245+ {
246+ axis : "x" ,
247+ pointer : { touch : true } ,
248+ filterTaps : true ,
249+ from : [ 0 , 0 ] ,
250+ } ,
251+ ) ;
252+ }
253+
254+ // ─── Overflow detection ───────────────────────────────────────────────
255+
256+ /**
257+ * Detects whether the element's content overflows horizontally.
258+ * Returns a ref to attach and a boolean for conditional rendering.
259+ * The `data-overflowing` attribute drives the scroll-fade CSS.
260+ */
261+ function useOverflowDetect ( enabled : boolean ) {
262+ const ref = useRef < HTMLDivElement > ( null ) ;
263+ const [ isOverflowing , setIsOverflowing ] = useState ( false ) ;
264+
265+ useEffect ( ( ) => {
266+ if ( ! enabled ) return ;
267+ const el = ref . current ;
268+ if ( ! el ) return ;
269+
270+ const check = ( ) => setIsOverflowing ( el . scrollWidth > el . clientWidth ) ;
271+
272+ const ro = new ResizeObserver ( check ) ;
273+ ro . observe ( el ) ;
274+ check ( ) ;
275+
276+ return ( ) => ro . disconnect ( ) ;
277+ } , [ enabled ] ) ;
278+
279+ return { ref, isOverflowing } ;
280+ }
0 commit comments