Skip to content

Commit d3feec0

Browse files
authored
fix: proportional scroll fade (#569)
1 parent 7401701 commit d3feec0

3 files changed

Lines changed: 76 additions & 29 deletions

File tree

.changeset/fix-tabs-scroll-fade.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@cloudflare/kumo": patch
3+
---
4+
5+
Fix segmented `Tabs` scroll fade, scroll-into-view, and ring styling:
6+
7+
- Rewrite CSS scroll-fade masking to use `@property`-animated custom properties, fixing proportional fade rendering across browsers.
8+
- Scroll the selected tab into view on click so it stays visible in overflowing tab lists.
9+
- Move `ring ring-kumo-hairline/70` from the inner list to the root container so the segmented variant ring wraps the entire component correctly.

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

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { useEffect, useRef, useState, type MouseEvent, type PointerEvent, type ReactNode } from "react";
1+
import {
2+
useEffect,
3+
useRef,
4+
useState,
5+
type MouseEvent,
6+
type PointerEvent,
7+
type ReactNode,
8+
} from "react";
29
import type { TabsTab } from "@base-ui/react/tabs";
310
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
411
import { cn } from "../../utils/cn";
@@ -156,15 +163,25 @@ export function Tabs({
156163
return (
157164
<TabsPrimitive.Root
158165
{...rootProps}
159-
className={cn("relative isolate min-w-0 font-medium", className)}
166+
className={cn(
167+
"relative isolate min-w-0 font-medium",
168+
isSegmented &&
169+
(isSm ? "rounded-md" : "rounded-lg") + " ring ring-kumo-hairline/70",
170+
className,
171+
)}
160172
onValueChange={(nextValue) => {
161173
const stringValue = String(nextValue);
162174
onValueChange?.(stringValue);
163175
}}
164176
>
165177
{/* Background element for segmented variant */}
166178
{isSegmented && (
167-
<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")} />
179+
<div
180+
className={cn(
181+
"absolute inset-x-0 top-1/2 z-0 -translate-y-1/2 rounded-lg bg-kumo-recessed",
182+
isSm ? "h-6.5" : "h-9",
183+
)}
184+
/>
168185
)}
169186
<TabsPrimitive.List
170187
ref={listRef}
@@ -173,7 +190,8 @@ export function Tabs({
173190
{...bindDrag()}
174191
className={cn(
175192
"relative flex min-w-0 shrink items-stretch",
176-
isSegmented && "kumo-tabs-list overflow-x-auto rounded-lg bg-kumo-recessed px-0.5 ring ring-kumo-hairline/70 [--scroll-fade-width:3rem]",
193+
isSegmented &&
194+
"kumo-tabs-list overflow-x-auto rounded-lg bg-kumo-recessed px-0.5 [--scroll-fade-width:3rem] scroll-px-(--scroll-fade-width)",
177195
isSegmented && (isSm ? "h-6.5 rounded-md" : "h-9"),
178196
isOverflowing && "cursor-grab active:cursor-grabbing",
179197
isUnderline && "gap-4 border-b border-kumo-hairline pb-2",
@@ -188,13 +206,22 @@ export function Tabs({
188206
data-kumo-part="tab"
189207
value={tab.value}
190208
render={tab.render}
209+
onClick={(e) => {
210+
e.currentTarget.scrollIntoView({
211+
behavior: "smooth",
212+
block: "nearest",
213+
inline: "nearest",
214+
});
215+
}}
191216
className={cn(
192217
"relative z-2 flex items-center rounded bg-transparent whitespace-nowrap focus:outline-none focus:ring-kumo-focus/50 focus-visible:ring-2 focus-visible:ring-kumo-brand",
193-
isOverflowing ? "cursor-grab active:cursor-grabbing" : "cursor-pointer",
218+
isOverflowing
219+
? "cursor-grab active:cursor-grabbing"
220+
: "cursor-pointer",
194221
isSm ? "text-xs" : "text-base",
195222
isSegmented &&
196-
"my-0.5 rounded-md text-kumo-subtle hover:text-kumo-default aria-selected:text-kumo-default focus-visible:ring-inset",
197-
isSegmented && (isSm ? "px-2" : "px-2.5"),
223+
"my-0.5 text-kumo-subtle hover:text-kumo-default aria-selected:text-kumo-default focus-visible:ring-inset",
224+
isSegmented && (isSm ? "px-2 rounded-sm" : "px-2.5 rounded-md"),
198225
isUnderline &&
199226
"text-kumo-subtle hover:bg-kumo-tint hover:text-kumo-default aria-selected:hover:bg-kumo-tint aria-selected:font-medium aria-selected:text-kumo-default",
200227
isUnderline && (isSm ? "px-1.5 py-2.5" : "px-2 py-3"),
@@ -213,7 +240,10 @@ export function Tabs({
213240
"w-(--active-tab-width) translate-x-(--active-tab-left) transition-all duration-200",
214241
"data-[rendered=false]:scale-90 data-[rendered=false]:opacity-0",
215242
isSegmented &&
216-
cn("top-(--active-tab-top) h-(--active-tab-height) bg-kumo-base shadow-sm ring ring-kumo-line", isSm ? "rounded" : "rounded-md"),
243+
cn(
244+
"top-(--active-tab-top) h-(--active-tab-height) bg-kumo-base shadow-sm ring ring-kumo-line",
245+
isSm ? "rounded" : "rounded-md",
246+
),
217247
isUnderline && "bottom-0 h-0.5 bg-kumo-brand",
218248
indicatorClassName,
219249
)}
@@ -260,7 +290,8 @@ function useHorizontalDragScroll(
260290
onPointerMoveCapture: (event: PointerEvent<HTMLElement>) => {
261291
const el = ref.current;
262292
const state = dragState.current;
263-
if (!el || !enabled || !state || state.pointerId !== event.pointerId) return;
293+
if (!el || !enabled || !state || state.pointerId !== event.pointerId)
294+
return;
264295

265296
const movementX = event.clientX - state.startX;
266297
if (!state.dragging) {

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

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -218,37 +218,45 @@
218218
scrollable container based on scroll position. Uses scroll-driven
219219
animations (Chrome 115+, Edge 115+). Degrades gracefully to no fade.
220220
Activated by the `data-overflowing` attribute (set via JS ResizeObserver). */
221+
@property --fade-left-n {
222+
syntax: "<number>";
223+
inherits: false;
224+
initial-value: 0;
225+
}
226+
227+
@property --fade-right-n {
228+
syntax: "<number>";
229+
inherits: false;
230+
initial-value: 0;
231+
}
232+
221233
@keyframes scroll-fade-x-left {
234+
from {
235+
--fade-left-n: 0;
236+
}
222237
to {
223-
mask-size:
224-
var(--scroll-fade-width, 3rem) 100%,
225-
100% 100%,
226-
var(--scroll-fade-width, 3rem) 100%;
238+
--fade-left-n: 1;
227239
}
228240
}
229241

230242
@keyframes scroll-fade-x-right {
243+
from {
244+
--fade-right-n: 1;
245+
}
231246
to {
232-
mask-size:
233-
var(--scroll-fade-width, 3rem) 100%,
234-
100% 100%,
235-
0 100%;
247+
--fade-right-n: 0;
236248
}
237249
}
238250

239251
[data-overflowing] {
240252
@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;
253+
mask-image: linear-gradient(
254+
to right,
255+
transparent,
256+
white calc(var(--fade-left-n) * var(--scroll-fade-width, 3rem)),
257+
white calc(100% - var(--fade-right-n) * var(--scroll-fade-width, 3rem)),
258+
transparent
259+
);
252260

253261
animation-name: scroll-fade-x-left, scroll-fade-x-right;
254262
animation-timing-function: linear, linear;
@@ -257,7 +265,6 @@
257265
0 var(--scroll-fade-range, 3rem),
258266
calc(100% - var(--scroll-fade-range, 3rem)) 100%;
259267
animation-fill-mode: both;
260-
animation-composition: replace;
261268
}
262269
}
263270

0 commit comments

Comments
 (0)