Skip to content

Commit da502ce

Browse files
feat(tabs): scroll fade for overflowing segmented tabs (#504)
1 parent 798c2da commit da502ce

6 files changed

Lines changed: 173 additions & 16 deletions

File tree

.changeset/tabs-scroll-fade.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cloudflare/kumo": minor
3+
---
4+
5+
Add scroll fade to segmented tabs. When tabs overflow, gradient masks appear on the edges based on scroll position via scroll-driven animations (Chrome 115+, degrades gracefully). Scrollbar is hidden; the fade is the scroll affordance.

packages/kumo-docs-astro/src/components/demos/TabsDemo.tsx

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -83,17 +83,21 @@ export function TabsControlledDemo() {
8383

8484
export function TabsManyDemo() {
8585
return (
86-
<Tabs
87-
tabs={[
88-
{ value: "overview", label: "Overview" },
89-
{ value: "analytics", label: "Analytics" },
90-
{ value: "reports", label: "Reports" },
91-
{ value: "notifications", label: "Notifications" },
92-
{ value: "settings", label: "Settings" },
93-
{ value: "billing", label: "Billing" },
94-
]}
95-
selectedValue="overview"
96-
/>
86+
<div className="w-full max-w-md">
87+
<Tabs
88+
tabs={[
89+
{ value: "overview", label: "Overview" },
90+
{ value: "analytics", label: "Analytics" },
91+
{ value: "reports", label: "Reports" },
92+
{ value: "notifications", label: "Notifications" },
93+
{ value: "settings", label: "Settings" },
94+
{ value: "billing", label: "Billing" },
95+
{ value: "security", label: "Security" },
96+
{ value: "integrations", label: "Integrations" },
97+
]}
98+
selectedValue="overview"
99+
/>
100+
</div>
97101
);
98102
}
99103

packages/kumo/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -451,10 +451,10 @@
451451
},
452452
"peerDependencies": {
453453
"@phosphor-icons/react": "^2.1.10",
454+
"echarts": "^6.0.0",
454455
"react": "^18.0.0 || ^19.0.0",
455456
"react-dom": "^18.0.0 || ^19.0.0",
456-
"zod": "^4.0.0",
457-
"echarts": "^6.0.0"
457+
"zod": "^4.0.0"
458458
},
459459
"peerDependenciesMeta": {
460460
"zod": {
@@ -465,6 +465,7 @@
465465
"@base-ui/react": "^1.4.0",
466466
"@shikijs/langs": "^4.0.0",
467467
"@shikijs/themes": "^4.0.0",
468+
"@use-gesture/react": "^10.3.1",
468469
"clsx": "^2.1.1",
469470
"motion": "^12.34.1",
470471
"react-day-picker": "^9.13.2",

packages/kumo/src/components/tabs/tabs.tsx

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import type { ReactNode } from "react";
1+
import { useEffect, useRef, useState, type ReactNode } from "react";
22
import type { TabsTab } from "@base-ui/react/tabs";
33
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
4+
import { useDrag } from "@use-gesture/react";
45
import { 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+
}

packages/kumo/src/styles/kumo-binding.css

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,71 @@
214214
}
215215
}
216216

217+
/* Scroll-fade: gradient masks that appear on the edges of a horizontally
218+
scrollable container based on scroll position. Uses scroll-driven
219+
animations (Chrome 115+, Edge 115+). Degrades gracefully to no fade.
220+
Activated by the `data-overflowing` attribute (set via JS ResizeObserver). */
221+
@keyframes scroll-fade-x-left {
222+
to {
223+
mask-size:
224+
var(--scroll-fade-width, 3rem) 100%,
225+
100% 100%,
226+
var(--scroll-fade-width, 3rem) 100%;
227+
}
228+
}
229+
230+
@keyframes scroll-fade-x-right {
231+
to {
232+
mask-size:
233+
var(--scroll-fade-width, 3rem) 100%,
234+
100% 100%,
235+
0 100%;
236+
}
237+
}
238+
239+
[data-overflowing] {
240+
@supports (animation-timeline: scroll()) {
241+
mask-image:
242+
linear-gradient(to right, white, transparent),
243+
linear-gradient(white, white),
244+
linear-gradient(to right, transparent, white);
245+
mask-size:
246+
0 100%,
247+
100% 100%,
248+
var(--scroll-fade-width, 3rem) 100%;
249+
mask-repeat: no-repeat;
250+
mask-composite: exclude;
251+
mask-position: left, center, right;
252+
253+
animation-name: scroll-fade-x-left, scroll-fade-x-right;
254+
animation-timing-function: linear, linear;
255+
animation-timeline: scroll(self x);
256+
animation-range:
257+
0 var(--scroll-fade-range, 3rem),
258+
calc(100% - var(--scroll-fade-range, 3rem)) 100%;
259+
animation-fill-mode: both;
260+
animation-composition: replace;
261+
}
262+
}
263+
264+
/* Tab list scroll behavior. Scrollbar is only hidden when scroll-driven
265+
animations are supported (the fade replaces it as the affordance).
266+
Unsupported browsers keep the native scrollbar. */
267+
.kumo-tabs-list {
268+
overscroll-behavior-x: contain;
269+
}
270+
271+
@supports (animation-timeline: scroll()) {
272+
.kumo-tabs-list {
273+
scrollbar-width: none;
274+
-ms-overflow-style: none;
275+
}
276+
277+
.kumo-tabs-list::-webkit-scrollbar {
278+
display: none;
279+
}
280+
}
281+
217282
/* Popup outline offset - in dark mode, offset inward to align with inner stroke.
218283
This compensates for the different geometry of inner vs outer stroke paths.
219284
Used by: Tooltip, Popover

pnpm-lock.yaml

Lines changed: 19 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)