From 4ee6ad48beab1ffb862e2b4ca8d12275af4485f4 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 31 Jul 2026 14:14:59 -0600 Subject: [PATCH 01/24] feat(ui): Add Mosaic ScrollArea with scroll-driven fade indicators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A vertically scrolling region that fades its content at whichever edge still has something to reveal. Composed as `ScrollArea.Root` + `ScrollArea.Viewport`. The indicators are pure CSS with no runtime cost: two scroll-driven animations write a progress var per edge, and a single four-stop mask gradient computes its stops from them. Driving a number rather than the mask's own geometry means one gradient covers both edges — no `mask-composite` — and leaves the progress vars readable so a consumer can swap the treatment from a stylesheet alone. The vars are `stylex.types.number`, so StyleX emits an `@property` registration for each. That does two jobs: an unregistered custom property animates discretely and would snap at 50% instead of tracking the scroll, and `initial-value: 0` is what hides both indicators when the viewport isn't scrollable — an inactive timeline leaves the vars at their initial value, so the resting state is already the hidden one. Only `animation-name` sits behind `@supports (animation-timeline: scroll())`. A browser that ignores `animation-timeline` would otherwise run the animations on the document timeline at the default `0s` duration, land on the end frame immediately, and paint both fades permanently; with no name the rest is inert and the mask resolves to fully opaque. Because the fade is a mask rather than a sticky overlay element, it is paint-only and cannot shift the content the way sticky pseudo-elements do. `gutter` defaults to `auto`, matching CSS. `stable` is the opt-in for content that can change height in place — a filterable list, a paginated table — where crossing the overflow threshold would shift the rows sideways. Scrollbar size is a theme token (`--cl-scrollbar-width`, default `thin`) rather than a prop, since Mosaic has no reason to size scrollbars differently between components. It is keyword-only by spec: `scrollbar-width` accepts `auto | thin | none` and not a length. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/mosaic-scroll-area.md | 13 ++ .../mosaic/components/scroll-area/index.ts | 2 + .../scroll-area/scroll-area.styles.ts | 131 ++++++++++++++++++ .../scroll-area/scroll-area.test.tsx | 111 +++++++++++++++ .../components/scroll-area/scroll-area.tsx | 98 +++++++++++++ .../scroll-area/scroll-area.vars.stylex.ts | 34 +++++ packages/ui/src/mosaic/styles/index.ts | 5 + packages/ui/src/mosaic/tokens.stylex.ts | 23 +++ 8 files changed, 417 insertions(+) create mode 100644 .changeset/mosaic-scroll-area.md create mode 100644 packages/ui/src/mosaic/components/scroll-area/index.ts create mode 100644 packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts create mode 100644 packages/ui/src/mosaic/components/scroll-area/scroll-area.test.tsx create mode 100644 packages/ui/src/mosaic/components/scroll-area/scroll-area.tsx create mode 100644 packages/ui/src/mosaic/components/scroll-area/scroll-area.vars.stylex.ts diff --git a/.changeset/mosaic-scroll-area.md b/.changeset/mosaic-scroll-area.md new file mode 100644 index 00000000000..dec946abb56 --- /dev/null +++ b/.changeset/mosaic-scroll-area.md @@ -0,0 +1,13 @@ +--- +'@clerk/ui': minor +--- + +Add `ScrollArea` to Mosaic — a vertically scrolling region that fades its content at whichever edge still has something to reveal. Composed as `ScrollArea.Root` and `ScrollArea.Viewport`. + +The indicators are pure CSS, driven by scroll-driven animations, and cost nothing at runtime. Because the fade is a mask rather than a sticky overlay element, it is paint-only and cannot shift the content. Browsers without scroll-driven animation support get a plain scroll area rather than a broken one. + +`ScrollArea.Viewport` takes a `gutter` prop. The default, `auto`, takes the scrollbar's space only while the content overflows. Pass `stable` for a collection that can change height in place — a filterable list, a paginated table — so that crossing the overflow threshold doesn't shift its rows sideways. + +The treatment is overridable in plain CSS, with no props involved. Set `mask-image: none` on `.cl-scroll-area-viewport` to retire the default fade, and read `--cl-scroll-area-progress-start` / `--cl-scroll-area-progress-end` — each running 0 → 1 as its edge gains something to reveal — to drive a shadow or any other indicator. `--cl-scroll-area-fade-size` and `--cl-scroll-area-fade-range` tune the built-in fade's height and how far you scroll before it reaches full strength. + +Also adds a `--cl-scrollbar-width` theme token, defaulting to `thin`, which sets the scrollbar size for every scrolling surface in Mosaic at once. Per the CSS spec this is keyword-only (`auto`, `thin`, or `none`) — `scrollbar-width` does not accept a length. diff --git a/packages/ui/src/mosaic/components/scroll-area/index.ts b/packages/ui/src/mosaic/components/scroll-area/index.ts new file mode 100644 index 00000000000..1e7a691bba8 --- /dev/null +++ b/packages/ui/src/mosaic/components/scroll-area/index.ts @@ -0,0 +1,2 @@ +export { ScrollArea } from './scroll-area'; +export type { ScrollAreaGutter, ScrollAreaRootProps, ScrollAreaViewportProps } from './scroll-area'; diff --git a/packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts b/packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts new file mode 100644 index 00000000000..4859866439a --- /dev/null +++ b/packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts @@ -0,0 +1,131 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, scrollbarVars, space } from '../../tokens.stylex'; +import { scrollAreaVars } from './scroll-area.vars.stylex'; + +// Same-file locals so the `var()` references read as names rather than as a wall of +// bracket lookups inside the gradient. StyleX inlines them at build; an imported helper +// would fail static evaluation. +const progressStart = scrollAreaVars['--cl-scroll-area-progress-start']; +const progressEnd = scrollAreaVars['--cl-scroll-area-progress-end']; +const fadeSize = scrollAreaVars['--cl-scroll-area-fade-size']; +const fadeRange = scrollAreaVars['--cl-scroll-area-fade-range']; +const scrollbarInset = scrollAreaVars['--cl-scroll-area-scrollbar-inset']; + +// One animation per edge, each writing its own progress var. The end fade counts DOWN +// rather than running `animation-direction: reverse`: with `fill-mode: both` the two are +// equivalent (the backwards fill holds the `from` frame, so the fade reads 1 for the whole +// scroll and only drops across the final `fade-range`), and writing it into the keyframes +// keeps `animation-direction` off the element entirely. +// The suppressions work around a gap in StyleX's own types, not a problem with the CSS: +// `Keyframes` declares each frame as `CSSProperties`, which carries no index signature for +// `--*` keys, so a custom property the compiler accepts and emits correctly still fails to +// typecheck. It has to be suppressed rather than cast — the babel plugin requires a bare +// object literal, and wrapping the argument in an `as` expression fails the build with +// "keyframes() can only accept an object". A computed key is out for the same reason. +const revealStart = stylex.keyframes({ + // @ts-expect-error -- StyleX's `Keyframes` type omits custom properties; see above. + from: { '--cl-scroll-area-progress-start': 0 }, + // @ts-expect-error -- StyleX's `Keyframes` type omits custom properties; see above. + to: { '--cl-scroll-area-progress-start': 1 }, +}); + +const revealEnd = stylex.keyframes({ + // @ts-expect-error -- StyleX's `Keyframes` type omits custom properties; see above. + from: { '--cl-scroll-area-progress-end': 1 }, + // @ts-expect-error -- StyleX's `Keyframes` type omits custom properties; see above. + to: { '--cl-scroll-area-progress-end': 0 }, +}); + +// A single four-stop gradient covers both edges, because the animated quantity is a number +// the stops are computed from rather than the mask's own geometry. At progress 0 the stop +// collapses onto the edge it starts from, leaving a hard boundary that reads as fully +// opaque — so "no scroll yet" and "not scrollable at all" render identically, for free. +// +// The second layer is the scrollbar strip, held opaque so the fade never touches it. At the +// default `0px` inset it is zero-wide and contributes nothing. Layers composite with `add` +// by default, so no `mask-composite` declaration is needed. +const maskImage = `linear-gradient(to bottom, transparent 0, #000 calc(${progressStart} * ${fadeSize}), #000 calc(100% - ${progressEnd} * ${fadeSize}), transparent 100%), linear-gradient(#000, #000)`; + +// Split by concern rather than one object per slot: the sort-keys rule reorders within an +// object, so a large one ends up interleaving unrelated properties and stranding the +// comments that explain them. +export const styles = stylex.create({ + root: { + display: 'flex', + flexDirection: 'column', + // Only load-bearing for a future scrollbar part; the viewport needs no positioning. + position: 'relative', + // A scroll container nested in a column flex parent overflows its track without this. + minHeight: 0, + }, + + /** The scroll container itself. */ + viewport: { + overscrollBehavior: 'contain', + flexBasis: 'auto', + flexGrow: 1, + flexShrink: 1, + scrollbarColor: { + default: `${colorVars['--cl-color-neutral-faded']} transparent`, + // Forced-colors users get the system scrollbar; a themed one loses its contrast + // guarantee against a palette we no longer control. + '@media (forced-colors: active)': 'auto', + }, + scrollbarWidth: scrollbarVars['--cl-scrollbar-width'], + minHeight: 0, + overflowX: 'hidden', + overflowY: 'auto', + }, + + /** Paint-only, so it can never shift the content the way a sticky shadow element does. */ + mask: { + maskImage, + maskPosition: 'left top, right top', + maskRepeat: 'no-repeat', + maskSize: `calc(100% - ${scrollbarInset}) 100%, ${scrollbarInset} 100%`, + }, + + // Only the name is gated on timeline support. A browser that ignores `animation-timeline` + // would otherwise run these on the document timeline at the default `0s` duration, land + // on the end frame immediately, and paint both fades permanently. With no name the + // remaining animation properties are inert, the vars hold at their registered + // `initial-value: 0`, and the mask resolves to fully opaque — so an unsupported browser + // gets a plain scroll area rather than a broken one. + indicators: { + // eslint-disable-next-line @stylexjs/valid-styles -- `animation-range` postdates StyleX's property allowlist; it compiles and emits correctly. + animationRange: `0px ${fadeRange}, calc(100% - ${fadeRange}) 100%`, + animationFillMode: 'both', + animationName: { + default: null, + '@supports (animation-timeline: scroll())': `${revealStart}, ${revealEnd}`, + }, + animationTimeline: 'scroll(self block), scroll(self block)', + animationTimingFunction: 'linear', + }, + + // Not focusable by default — see the `tabIndex` note on the component. Styled anyway so it + // looks right the moment a consumer opts in. + focusRing: { + outline: { default: null, ':focus-visible': `2px solid ${colorVars['--cl-color-primary']}` }, + outlineOffset: { default: null, ':focus-visible': space['0.5'] }, + }, +}); + +// Gutter only — the scrollbar's own size is a theme token (`--cl-scrollbar-width`), since +// Mosaic has no reason to size scrollbars differently between components. What varies per +// instance is whether the space is held open, which is a layout decision about the +// surrounding content rather than an appearance one. +export const gutters = stylex.create({ + // The default, and CSS's own. Nothing is reserved until a scrollbar actually appears, which + // is right whenever the content can't change height while mounted — no shift is possible, + // so holding space open would only cost width. + auto: { + scrollbarGutter: 'auto', + }, + // Opt in where the content CAN change height in place — a filterable or paginated + // collection — so crossing the overflow threshold doesn't shift the rows sideways. + stable: { + scrollbarGutter: 'stable', + }, +}); diff --git a/packages/ui/src/mosaic/components/scroll-area/scroll-area.test.tsx b/packages/ui/src/mosaic/components/scroll-area/scroll-area.test.tsx new file mode 100644 index 00000000000..112bd615283 --- /dev/null +++ b/packages/ui/src/mosaic/components/scroll-area/scroll-area.test.tsx @@ -0,0 +1,111 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it } from 'vitest'; + +import { scrollbarVars } from '../../tokens.stylex'; +import { ScrollArea } from './scroll-area'; +import { scrollAreaVars } from './scroll-area.vars.stylex'; + +describe('Mosaic ScrollArea', () => { + it('renders its children inside the viewport', () => { + render( + + Contents + , + ); + expect(screen.getByText('Contents')).toBeInTheDocument(); + }); + + it('carries the stable slot classes', () => { + render( + + Contents + , + ); + expect(screen.getByTestId('root')).toHaveClass('cl-scroll-area-root'); + expect(screen.getByTestId('viewport')).toHaveClass('cl-scroll-area-viewport'); + }); + + it('defaults to the auto gutter so a non-resizing list keeps the full width', () => { + render(Contents); + expect(screen.getByTestId('viewport')).toHaveAttribute('data-gutter', 'auto'); + }); + + it.each(['stable', 'auto'] as const)('reflects the %s gutter', gutter => { + render( + + Contents + , + ); + expect(screen.getByTestId('viewport')).toHaveAttribute('data-gutter', gutter); + }); + + it('lets the consumer className and style win', () => { + render( + + Contents + , + ); + const viewport = screen.getByTestId('viewport'); + expect(viewport).toHaveClass('cl-scroll-area-viewport', 'my-scroller'); + expect(viewport).toHaveStyle({ maxHeight: '240px' }); + }); + + it('forwards arbitrary div props and the ref on both parts', () => { + const rootRef = React.createRef(); + const viewportRef = React.createRef(); + render( + + + Contents + + , + ); + expect(rootRef.current).toBe(screen.getByTestId('root')); + const viewport = screen.getByTestId('viewport'); + expect(viewportRef.current).toBe(viewport); + expect(viewport).toHaveAttribute('tabindex', '0'); + expect(viewport).toHaveAttribute('aria-label', 'Members'); + }); + + // The `--cl-*` names are the component's public API — a consumer's stylesheet references them + // by hand, and `clerk-js` ships to apps pinned to older SDKs, so renaming one breaks themes + // already in the wild. Assert the exact strings so a rename has to be a deliberate act. + it('emits the documented public custom properties', () => { + // `toMatchObject`, not `toEqual`: StyleX adds an internal `__varGroupHash__` key, and adding + // a new var is not itself a breaking change — removing or renaming one is. + expect(scrollAreaVars).toMatchObject({ + '--cl-scroll-area-progress-start': 'var(--cl-scroll-area-progress-start)', + '--cl-scroll-area-progress-end': 'var(--cl-scroll-area-progress-end)', + '--cl-scroll-area-fade-size': 'var(--cl-scroll-area-fade-size)', + '--cl-scroll-area-fade-range': 'var(--cl-scroll-area-fade-range)', + '--cl-scroll-area-scrollbar-inset': 'var(--cl-scroll-area-scrollbar-inset)', + }); + }); + + // Shared across every scrolling surface in Mosaic rather than owned here, but the viewport + // reads it, so a rename would silently drop the scrollbar sizing. + it('reads the shared scrollbar-width token', () => { + expect(scrollbarVars).toMatchObject({ '--cl-scrollbar-width': 'var(--cl-scrollbar-width)' }); + }); + + it('does not make the viewport focusable on its own', () => { + render(Contents); + expect(screen.getByTestId('viewport')).not.toHaveAttribute('tabindex'); + }); +}); diff --git a/packages/ui/src/mosaic/components/scroll-area/scroll-area.tsx b/packages/ui/src/mosaic/components/scroll-area/scroll-area.tsx new file mode 100644 index 00000000000..aafb2452055 --- /dev/null +++ b/packages/ui/src/mosaic/components/scroll-area/scroll-area.tsx @@ -0,0 +1,98 @@ +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import type { MosaicComponentProps } from '../../props'; +import { mergeStyleProps, themeProps } from '../../props'; +import { gutters, styles } from './scroll-area.styles'; + +export type ScrollAreaGutter = 'stable' | 'auto'; + +export type ScrollAreaRootProps = Omit, 'render'>; + +export interface ScrollAreaViewportProps extends Omit, 'render'> { + /** + * Whether the scrollbar's space is held open. `auto` (the default) takes the space only + * while the content overflows. Pass `stable` when the content can change height **in + * place** — a filterable or paginated collection — so that crossing the overflow threshold + * doesn't shift the rows sideways; the cost is a permanently reserved gutter next to a list + * that may never scroll. + * + * Neither value does anything on platforms that overlay their scrollbars, which reserve no + * space either way. + * + * The scrollbar's *size* is not a prop — it's the `--cl-scrollbar-width` theme token, so + * every scrolling surface in Mosaic changes together. + */ + gutter?: ScrollAreaGutter; +} + +/** + * The wrapper. Positioned, so a future scrollbar part can be placed against it; today it + * only establishes the box the viewport flexes inside. + */ +const Root = React.forwardRef(function ScrollAreaRoot( + { className, style, ...rest }, + ref, +) { + return ( +
+ ); +}); + +/** + * The scroll container. Owns the overflow, the scroll timelines, and the mask. + */ +const Viewport = React.forwardRef(function ScrollAreaViewport( + { gutter = 'auto', className, style, ...rest }, + ref, +) { + return ( +
+ ); +}); + +/** + * Mosaic `ScrollArea` — a vertically scrolling region that fades its content at whichever + * edge has more to reveal. Composed via dot syntax: `ScrollArea.Root`, `ScrollArea.Viewport`. + * + * The fade is a mask driven by two scroll-driven animations, one per edge, which write + * `--cl-scroll-area-progress-start` and `--cl-scroll-area-progress-end`. Nothing about it + * runs in JavaScript, and nothing about it participates in layout — the mask is paint-only, + * so it can't shift the content the way sticky shadow elements do. + * + * The indicators are a progressive enhancement. Without scroll-driven animation support the + * progress vars hold at 0 and the mask resolves to fully opaque, leaving a plain scroll area. + * + * @example + * + * {items} + * + * + * @example + * // Hold the scrollbar's space open, for a collection that can change height in place. + * {items} + * + * @remarks + * Chrome and Firefox make an overflowing scroll container keyboard-focusable on their own; + * Safari does not, so a keyboard-only user can't scroll it there. `tabIndex` is deliberately + * not set here — an always-present tab stop is wrong for a region that often isn't + * scrollable. Pass `tabIndex={0}` (with an `aria-label` or `role='region'`) where the content + * is known to overflow. + */ +export const ScrollArea = { + Root, + Viewport, +}; diff --git a/packages/ui/src/mosaic/components/scroll-area/scroll-area.vars.stylex.ts b/packages/ui/src/mosaic/components/scroll-area/scroll-area.vars.stylex.ts new file mode 100644 index 00000000000..f4ccf412ae1 --- /dev/null +++ b/packages/ui/src/mosaic/components/scroll-area/scroll-area.vars.stylex.ts @@ -0,0 +1,34 @@ +import * as stylex from '@stylexjs/stylex'; + +// ScrollArea's public var contract. The two progress vars are the whole point of the +// component's styling API: a scroll-driven animation writes them, the default mask reads +// them, and a consumer can read them instead to drive any treatment they like. +// +// They are `stylex.types.number` rather than plain strings so StyleX emits an `@property` +// registration for each. That registration is load-bearing twice over: +// +// 1. An unregistered custom property animates DISCRETELY — it would flip at 50% of the +// scroll range instead of tracking it. Registering `syntax: ""` is what makes +// the value interpolate. +// 2. `initial-value: 0` is what hides both indicators when the viewport isn't scrollable. +// A scroll timeline with no scrollable overflow is inactive, so neither animation +// applies and both vars fall back to 0 — which the mask reads as "no fade". The +// `--can-scroll` space-toggle hack the well-known demos use is unnecessary here, +// because our resting state is already the hidden one. +export const scrollAreaVars = stylex.defineVars({ + '--cl-scroll-area-progress-start': stylex.types.number(0), + '--cl-scroll-area-progress-end': stylex.types.number(0), + // Matched on purpose: the fade reaches full strength after you've scrolled its own height, + // so the indicator grows in at the same rate as the content it's covering moves. They stay + // independent knobs — a shorter range makes the fade snap in sooner without changing how + // tall it ends up. + '--cl-scroll-area-fade-size': '1.5rem', + '--cl-scroll-area-fade-range': '1.5rem', + // Width of the strip at the inline end that the fade is held back from, so a classic + // (space-consuming) scrollbar isn't faded along with the content. Defaults to `0px` + // because CSS cannot measure a scrollbar: the value differs per platform, per browser, + // and on macOS it changes when a mouse is connected, so any non-zero default would be + // wrong more often than right. Overlay scrollbars — the common case — need no inset at + // all. Consumers targeting a known classic-scrollbar platform can set it. + '--cl-scroll-area-scrollbar-inset': '0px', +}); diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index b6094e9f27c..d79bd8a95b0 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -28,6 +28,8 @@ export type { MenuSeparatorProps, MenuTriggerProps, } from '../components/menu'; +export { ScrollArea } from '../components/scroll-area'; +export type { ScrollAreaGutter, ScrollAreaRootProps, ScrollAreaViewportProps } from '../components/scroll-area'; export { Text, TextContext } from '../components/text'; export type { TextProps } from '../components/text'; @@ -48,6 +50,7 @@ import { easingVars, fontWeightVars, radiusVars, + scrollbarVars, space, spacingVars, targetVars, @@ -60,6 +63,7 @@ export { easingVars, fontWeightVars, radiusVars, + scrollbarVars, space, spacingVars, targetVars, @@ -74,6 +78,7 @@ export type DurationVarName = keyof typeof durationVars; export type EasingVarName = keyof typeof easingVars; export type FontWeightVarName = keyof typeof fontWeightVars; export type RadiusVarName = keyof typeof radiusVars; +export type ScrollbarVarName = keyof typeof scrollbarVars; export type SpacingVarName = keyof typeof spacingVars; export type TargetVarName = keyof typeof targetVars; export type TypeScaleVarName = keyof typeof typeScaleVars; diff --git a/packages/ui/src/mosaic/tokens.stylex.ts b/packages/ui/src/mosaic/tokens.stylex.ts index 4f5ba031af9..8ebdd9aa274 100644 --- a/packages/ui/src/mosaic/tokens.stylex.ts +++ b/packages/ui/src/mosaic/tokens.stylex.ts @@ -87,6 +87,29 @@ const targetDefaults = { export const targetVars = stylex.defineVars(targetDefaults); +// ============================================================================= +// Scrollbar Tokens +// ============================================================================= +// One opinion for every scrolling surface in Mosaic, set in one place. Mosaic has no +// reason to render differently-sized scrollbars in different components, so this is a +// token rather than a per-component prop — a consumer restyles all of them at once. +// +// `thin` rather than `auto`: these scroll regions are compact panels (member lists in a +// card or a popover), where a platform-default ~17px bar reads heavy, and where +// `scrollbar-gutter: stable` means the width is content space we give up whether or not +// anything is scrolling. The tradeoff is a smaller drag target on the platforms whose +// scrollbars are draggable at all — set `auto` to take it back. +// +// Keyword-only, by the CSS spec: `scrollbar-width` accepts `auto | thin | none` and NOT a +// length. A real pixel width exists only via `::-webkit-scrollbar`, which Firefox ignores +// and which Chrome 121+ discards once `scrollbar-color` is set — so there is no honest way +// to expose this as a length. +const scrollbarDefaults = { + '--cl-scrollbar-width': 'thin', +} as const; + +export const scrollbarVars = stylex.defineVars(scrollbarDefaults); + // ============================================================================= // Spacing Tokens // ============================================================================= From 49ead2a1003c7d89875df382267aa2a6cc22eec7 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 31 Jul 2026 14:15:10 -0600 Subject: [PATCH 02/24] docs(swingset): Document Mosaic ScrollArea MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the story module and MDX page for `ScrollArea`, following the archetype A compound layout: Example, Usage, Parts, Styling. Five examples. `Default` and `NotScrollable` show that absent indicators are the resting state rather than something switched off. `Gutter` toggles the content across the overflow threshold, because `stable` and `auto` are identical while the content permanently overflows — a side-by-side pair alone demonstrates nothing, and the docs say so alongside the platform caveat, since neither value does anything where scrollbars overlay. `CustomIndicators` is the CSS-only override path: `mask-image: none` plus the progress vars driving a pair of gradient overlays. Its scrim mixes from `--cl-color-card-foreground` rather than hardcoding black — a black scrim darkens a dark surface, which is indistinguishable from the mask it replaced, so the example would have taught a bug in dark mode. Co-Authored-By: Claude Opus 5 (1M context) --- .../swingset/src/components/DocsViewer.tsx | 1 + packages/swingset/src/lib/registry.ts | 18 ++ .../src/stories/scroll-area.component.mdx | 228 ++++++++++++++++++ .../stories/scroll-area.component.stories.tsx | 170 +++++++++++++ 4 files changed, 417 insertions(+) create mode 100644 packages/swingset/src/stories/scroll-area.component.mdx create mode 100644 packages/swingset/src/stories/scroll-area.component.stories.tsx diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index d54119bbc35..59134c90c82 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -39,6 +39,7 @@ const docModules: Record> = { icon: dynamic(() => import('../stories/icon.mdx')), menu: dynamic(() => import('../stories/menu.component.mdx')), popover: dynamic(() => import('../stories/popover.component.mdx')), + 'scroll-area': dynamic(() => import('../stories/scroll-area.component.mdx')), tabs: dynamic(() => import('../stories/tabs.component.mdx')), text: dynamic(() => import('../stories/text.mdx')), }, diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 3b1e386414c..734d787b6d9 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -94,6 +94,14 @@ import { Placement as PopoverComponentPlacement, } from '../stories/popover.component.stories'; import { meta as popoverMeta } from '../stories/popover.stories'; +import { + CustomIndicators as ScrollAreaCustomIndicators, + Default as ScrollAreaDefault, + Gutter as ScrollAreaGutter, + meta as scrollAreaMeta, + NotScrollable as ScrollAreaNotScrollable, + Tuning as ScrollAreaTuning, +} from '../stories/scroll-area.component.stories'; import { meta as selectMeta } from '../stories/select.stories'; import { Default as TabsComponentDefault, meta as tabsComponentMeta } from '../stories/tabs.component.stories'; import { meta as tabsMeta } from '../stories/tabs.stories'; @@ -169,6 +177,15 @@ const popoverComponentModule: StoryModule = { Alignment: PopoverComponentAlignment, }; +const scrollAreaModule: StoryModule = { + meta: scrollAreaMeta, + Default: ScrollAreaDefault, + NotScrollable: ScrollAreaNotScrollable, + Gutter: ScrollAreaGutter, + Tuning: ScrollAreaTuning, + CustomIndicators: ScrollAreaCustomIndicators, +}; + const itemModule: StoryModule = { meta: itemMeta, Default: ItemDefault, @@ -239,6 +256,7 @@ export const registry: StoryModule[] = [ iconModule, menuComponentModule, popoverComponentModule, + scrollAreaModule, tabsComponentModule, textModule, // Primitives — alphabetical within the group. diff --git a/packages/swingset/src/stories/scroll-area.component.mdx b/packages/swingset/src/stories/scroll-area.component.mdx new file mode 100644 index 00000000000..2290dd5fdfb --- /dev/null +++ b/packages/swingset/src/stories/scroll-area.component.mdx @@ -0,0 +1,228 @@ +import * as ScrollAreaStories from './scroll-area.component.stories'; + +# ScrollArea + +The Mosaic `ScrollArea` — a vertically scrolling region that fades its content at whichever edge +still has something to reveal, so the boundary of a list reads as "there's more" rather than as a +hard cut. + +The fade is **pure CSS**. Two scroll-driven animations write a progress var per edge, and a mask +reads them. Nothing runs in JavaScript, there is no primitive behind it, and no measurement happens +at runtime. Because the fade is a mask rather than a sticky overlay element, it is paint-only and +**cannot shift the content** — the classic sticky-pseudo-element approach takes space in the scroll +flow, this doesn't. + +## Example + + + +## Usage + +Two parts. `ScrollArea.Root` is the box the region occupies; `ScrollArea.Viewport` is the scroll +container that owns the overflow, the timelines and the mask. Give the root a height — the viewport +fills it. + +```tsx +import { ScrollArea } from '@clerk/ui/mosaic/components/scroll-area'; + + + {members.map(m => )} +; +``` + +### Nothing to scroll + +When the content fits, both scroll timelines are inactive, both progress vars hold at their +registered `initial-value: 0`, and the mask resolves to fully opaque. No indicators appear, and +nothing had to detect that — the resting state is already the hidden one. + + + +### Gutter + +`gutter` on `ScrollArea.Viewport` decides whether the scrollbar's space is held open. + +`auto` (the default, and CSS's own) takes the space only while the content overflows. `stable` +reserves it either way, so a collection that crosses the overflow threshold doesn't shift its rows +sideways. + + + +Two conditions have to hold before the values differ at all, which is why the difference is easy to +miss: + +1. **The scrollbars have to be space-consuming.** Windows and Linux always are; macOS only is with a + mouse connected, or with **System Settings → Appearance → Show scroll bars → Always**. Overlay + scrollbars are painted over the content and reserve nothing, so there is no gutter for either + value to hold open. +2. **The content has to be able to stop overflowing.** `auto` reserves space whenever a scrollbar is + actually present, so with permanently-overflowing content the two are identical. The divergence + only appears when the content fits: `stable` keeps the gutter, `auto` gives it back. + +Both conditions matter, and the second is the one that usually explains an apparently broken demo — +hence the toggle above. On a space-consuming platform, crossing the threshold makes the `auto` +column's rows jump sideways while `stable` holds still. + +**Reach for `stable` only when the content can change height in place** — a filterable list, a +paginated table, anything that gains or loses rows without navigating away. There the shift fires +mid-interaction, while the user is typing in a search box, and reads as a bug. Everywhere else it is +pure cost: a permanently reserved gutter beside a list that may never scroll. + +Neither value helps with the macOS mode switch itself. `scrollbar-gutter` is a no-op while +scrollbars overlay, so connecting a mouse narrows the content the first time a real scrollbar +appears no matter which you pick. + +The scrollbar's **size** is not a prop — see `--cl-scrollbar-width` below. + +### Tuning the fade + + + +## Parts + +| Part | Slot | Description | +| --------------------- | ---------------------- | ---------------------------------------------------------------- | +| `ScrollArea.Root` | `scroll-area-root` | The box the region occupies. Positioned, so overlays can anchor. | +| `ScrollArea.Viewport` | `scroll-area-viewport` | The scroll container; owns overflow, the timelines and the mask. | + +### Props + +Only `ScrollArea.Viewport` takes a prop of its own; `ScrollArea.Root` is a plain `div`. Both accept +the usual `className` / `style` escape hatches and forward every other native `div` prop. + +| Prop | Type | Default | Description | +| -------- | -------------------- | -------- | ------------------------------------------- | +| `gutter` | `'stable' \| 'auto'` | `'auto'` | Whether the scrollbar's space is held open. | + +## Styling + +Themed with **StyleX**. Each part carries a stable `.cl-` class alongside the StyleX atoms; +consumers never target the hashed atomic classes. + +### Variables + +| Variable | Default | Description | +| ---------------------------------- | -------- | --------------------------------------------------------------- | +| `--cl-scroll-area-progress-start` | `0` | 0 → 1 as the top edge gains something to reveal. **Read-only.** | +| `--cl-scroll-area-progress-end` | `0` | 1 → 0 as the bottom edge runs out to reveal. **Read-only.** | +| `--cl-scroll-area-fade-size` | `1.5rem` | Height of the fade band. | +| `--cl-scroll-area-fade-range` | `1.5rem` | How far you scroll before the fade reaches full strength. | +| `--cl-scroll-area-scrollbar-inset` | `0px` | Width at the inline end the fade is held back from. | + +The two progress vars are registered with `@property` so they interpolate; the animations write +them, so setting them yourself has no effect. They live on the **viewport** and inherit downward, so +anything reading them must be the viewport or a descendant of it — not the root. + +Plus one token that is **not** scoped to this component, because Mosaic has no reason to size +scrollbars differently between components — set it once and every scrolling surface follows: + +| Token | Default | Description | +| ---------------------- | ------- | ------------------------------------- | +| `--cl-scrollbar-width` | `thin` | `auto`, `thin`, or `none`. See below. | + +`thin` rather than the platform default, because these regions are compact panels where a ~17px bar +reads heavy, and because `gutter: stable` means that width is content space given up whether or not +anything is scrolling. The tradeoff is a smaller drag target on the platforms whose scrollbars are +draggable at all — take it back with `auto`: + +```css +:root { + --cl-scrollbar-width: auto; +} +``` + +**Keyword-only, by spec.** `scrollbar-width` accepts `auto | thin | none` and _not_ a length, so +there is no `--cl-scrollbar-width: 8px`. A real pixel width exists only through +`::-webkit-scrollbar`, which Firefox ignores outright and which Chrome 121+ discards as soon as +`scrollbar-color` is set — so exposing this as a length would be a promise the platform can't keep. + +### Replacing the indicators + +The treatment is a theme decision, so swapping it needs no prop and no JavaScript. Set +`mask-image: none` to retire the default fade and read the progress vars to drive whatever replaces +it. + + + +```css +@import '@clerk/ui/styles.css' layer(components); + +.cl-scroll-area-viewport { + mask-image: none; +} +.cl-scroll-area-viewport::before { + content: ''; + position: absolute; + inset: 0 0 auto; + height: 2rem; + pointer-events: none; + background: linear-gradient(to bottom, color-mix(in oklab, var(--cl-color-card-foreground) 28%, transparent), transparent); + opacity: var(--cl-scroll-area-progress-start); +} +``` + +Position such overlays absolutely rather than with `position: sticky` — a sticky pseudo-element +participates in the scroll flow and takes space from the content, which is the layout shift the mask +approach avoids in the first place. + +Mix the scrim from a theme color rather than hardcoding black. A black scrim darkens a dark surface, +which looks identical to the mask it was meant to replace, so the indicator silently stops reading +as one in dark mode. `--cl-color-card-foreground` is `light-dark()`-backed and inverts to near-white +on a dark surface, so the same rule gives a shadow in light mode and a glow in dark. + +### The scrollbar inset + +`--cl-scroll-area-scrollbar-inset` holds the fade back from a strip at the inline end, so a +space-consuming scrollbar isn't faded along with the content. + +It defaults to `0px` because **CSS cannot measure a scrollbar**. The width differs per platform and +per browser, `scrollbar-width: thin` changes it again, and on macOS it changes at runtime when a +mouse is connected. Any non-zero default would be wrong more often than right — and where the +scrollbar overlays, which is the common case, there is nothing to hold back from. Set it only when +you know the platform you're targeting. + +```css +.cl-scroll-area-viewport { + --cl-scroll-area-scrollbar-inset: 15px; +} +``` + +### Browser support + +The indicators are a progressive enhancement. Without support for scroll-driven animations the +progress vars hold at 0, the mask resolves to fully opaque, and the result is a plain scroll area — +not a broken one. The `animation-name` is gated behind `@supports (animation-timeline: scroll())` +for exactly this reason: an ungated animation would run on the document timeline at the default `0s` +duration and paint both fades permanently. + +### Keyboard access + +Chrome and Firefox make an overflowing scroll container keyboard-focusable on their own; Safari does +not, so a keyboard-only user cannot scroll it there. `ScrollArea.Viewport` deliberately does **not** +set `tabIndex` — an always-present tab stop is wrong for a region that is often not scrollable. Pass +it yourself, with an accessible name, where the content is known to overflow: + +```tsx + + {members} + +``` diff --git a/packages/swingset/src/stories/scroll-area.component.stories.tsx b/packages/swingset/src/stories/scroll-area.component.stories.tsx new file mode 100644 index 00000000000..37fac465814 --- /dev/null +++ b/packages/swingset/src/stories/scroll-area.component.stories.tsx @@ -0,0 +1,170 @@ +/** @jsxImportSource @emotion/react */ +import { Button } from '@clerk/ui/mosaic/components/button'; +import { ScrollArea } from '@clerk/ui/mosaic/components/scroll-area'; +import { Text } from '@clerk/ui/mosaic/components/text'; +import React from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +// Exposes this file's own source (via the `?raw` webpack rule) so each `` example +// renders a code footer with its function's source. See `StoryModule.__source`. +export { default as __source } from './scroll-area.component.stories?raw'; + +export const meta: StoryMeta = { + group: 'Components', + title: 'ScrollArea', + source: 'packages/ui/src/mosaic/components/scroll-area/scroll-area.tsx', + styleEngine: 'stylex', +}; + +const members = [ + 'Ada Lovelace', + 'Grace Hopper', + 'Katherine Johnson', + 'Margaret Hamilton', + 'Radia Perlman', + 'Barbara Liskov', + 'Frances Allen', + 'Jean Bartik', + 'Karen Spärck Jones', + 'Shafi Goldwasser', +]; + +const rows = (names: string[] = members) => + names.map(name => ( +
+ {name} +
+ )); + +export function Default() { + return ( + + {rows()} + + ); +} + +// Nothing is scrollable here, so both scroll timelines are inactive, both progress vars stay +// at their registered `initial-value: 0`, and the mask resolves to fully opaque. The absent +// indicators are the resting state rather than something switched off. +export function NotScrollable() { + return ( + + {rows(members.slice(0, 3))} + + ); +} + +// The two values only diverge when the content DOESN'T overflow: `scrollbar-gutter: auto` +// reserves space whenever a scrollbar is actually present, so with overflowing content both +// look the same. Toggling across the threshold is the whole demo — watch the `auto` column's +// rows jump sideways as its scrollbar comes and goes while `stable` holds still. +// +// Requires space-consuming scrollbars to show anything at all: Windows and Linux always, macOS +// only with a mouse connected or "Show scroll bars: Always" set. Overlay scrollbars reserve no +// space, so there is no gutter for either value to hold open. +export function Gutter() { + const [overflowing, setOverflowing] = React.useState(true); + const content = overflowing ? rows() : rows(members.slice(0, 3)); + + return ( +
+ +
+
+ + {content} + + gutter="stable" — rows never move +
+
+ + {content} + + gutter="auto" — rows widen when the scrollbar goes +
+
+
+ ); +} + +// Both knobs are plain custom properties, so they can be set anywhere in the cascade — on +// the element, on a wrapper, or once at `:root` to retune every scroll area in a theme. +export function Tuning() { + return ( + <> + + + {rows()} + + + ); +} + +// The indicators are a theme decision, so swapping the mask for something else needs no prop +// and no JavaScript — just CSS. +// +// `mask-image: none` retires the default treatment, and the two progress vars stay readable +// for whatever replaces it. Here they drive the opacity of a pair of gradient overlays. +// +// Three things worth copying. The overlays hang off the VIEWPORT, because that is the element +// the scroll-driven animations write the vars onto (they inherit downward, not up to the +// root). They are absolutely positioned rather than sticky, so they overlay the content +// instead of taking space in the scroll flow the way a sticky pseudo-element would. And the +// scrim is mixed from a theme token rather than hardcoded black — a black scrim darkens a +// dark surface, which is indistinguishable from the mask it replaced, so the indicator has to +// flip with the theme the way `--cl-color-card-foreground` does. +export function CustomIndicators() { + return ( + <> + + + {rows()} + + + ); +} From 29d28dbf49205a2cf4c32289ee8b453ee6518393 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 31 Jul 2026 14:26:50 -0600 Subject: [PATCH 03/24] refactor(ui): Promote the scroll fade knobs to theme tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--cl-scroll-area-fade-size`, `-fade-range` and `-scrollbar-inset` become `--cl-scroll-fade-size`, `-range` and `-inset` in `tokens.stylex.ts`. Every other token in Mosaic is global and category-named — `--cl-color-*`, `--cl-radius-*`, `--cl-duration-*`, `--cl-scrollbar-width`. These three were about to be the first `--cl--` family, and if each component followed, theming Clerk would mean enumerating N components x M knobs rather than learning one vocabulary. How soft the edge of a scrolling region is belongs to the design language, not to one component; a component that needs different values still sets the token on itself. The progress vars stay component-named on purpose. They are per-element runtime output that the animations overwrite on every scrolling element, so a `:root` value would be meaningless — promoting them would imply they are settable when setting them does nothing. Set versus read is the line. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/mosaic-scroll-area.md | 4 +- .../src/stories/scroll-area.component.mdx | 77 +++++++++++-------- .../stories/scroll-area.component.stories.tsx | 10 ++- .../scroll-area/scroll-area.styles.ts | 10 +-- .../scroll-area/scroll-area.test.tsx | 16 ++-- .../scroll-area/scroll-area.vars.stylex.ts | 20 +---- packages/ui/src/mosaic/styles/index.ts | 3 + packages/ui/src/mosaic/tokens.stylex.ts | 21 +++++ 8 files changed, 93 insertions(+), 68 deletions(-) diff --git a/.changeset/mosaic-scroll-area.md b/.changeset/mosaic-scroll-area.md index dec946abb56..02d8293b15c 100644 --- a/.changeset/mosaic-scroll-area.md +++ b/.changeset/mosaic-scroll-area.md @@ -8,6 +8,6 @@ The indicators are pure CSS, driven by scroll-driven animations, and cost nothin `ScrollArea.Viewport` takes a `gutter` prop. The default, `auto`, takes the scrollbar's space only while the content overflows. Pass `stable` for a collection that can change height in place — a filterable list, a paginated table — so that crossing the overflow threshold doesn't shift its rows sideways. -The treatment is overridable in plain CSS, with no props involved. Set `mask-image: none` on `.cl-scroll-area-viewport` to retire the default fade, and read `--cl-scroll-area-progress-start` / `--cl-scroll-area-progress-end` — each running 0 → 1 as its edge gains something to reveal — to drive a shadow or any other indicator. `--cl-scroll-area-fade-size` and `--cl-scroll-area-fade-range` tune the built-in fade's height and how far you scroll before it reaches full strength. +The treatment is overridable in plain CSS, with no props involved. Set `mask-image: none` on `.cl-scroll-area-viewport` to retire the default fade, and read `--cl-scroll-area-progress-start` / `--cl-scroll-area-progress-end` — per-element values the animations write, describing how much each edge still has to reveal — to drive a shadow or any other indicator. -Also adds a `--cl-scrollbar-width` theme token, defaulting to `thin`, which sets the scrollbar size for every scrolling surface in Mosaic at once. Per the CSS spec this is keyword-only (`auto`, `thin`, or `none`) — `scrollbar-width` does not accept a length. +Also adds four theme tokens that apply to every scrolling surface in Mosaic rather than to this component alone: `--cl-scroll-fade-size` and `--cl-scroll-fade-range` (both `1.5rem`) tune the fade's height and how far you scroll before it reaches full strength, `--cl-scroll-fade-inset` (`0px`) holds the fade back from a space-consuming scrollbar, and `--cl-scrollbar-width` (`thin`) sets the scrollbar size. Per the CSS spec that last one is keyword-only (`auto`, `thin`, or `none`) — `scrollbar-width` does not accept a length. diff --git a/packages/swingset/src/stories/scroll-area.component.mdx b/packages/swingset/src/stories/scroll-area.component.mdx index 2290dd5fdfb..cadc23bce35 100644 --- a/packages/swingset/src/stories/scroll-area.component.mdx +++ b/packages/swingset/src/stories/scroll-area.component.mdx @@ -113,40 +113,49 @@ consumers never target the hashed atomic classes. ### Variables -| Variable | Default | Description | -| ---------------------------------- | -------- | --------------------------------------------------------------- | -| `--cl-scroll-area-progress-start` | `0` | 0 → 1 as the top edge gains something to reveal. **Read-only.** | -| `--cl-scroll-area-progress-end` | `0` | 1 → 0 as the bottom edge runs out to reveal. **Read-only.** | -| `--cl-scroll-area-fade-size` | `1.5rem` | Height of the fade band. | -| `--cl-scroll-area-fade-range` | `1.5rem` | How far you scroll before the fade reaches full strength. | -| `--cl-scroll-area-scrollbar-inset` | `0px` | Width at the inline end the fade is held back from. | - -The two progress vars are registered with `@property` so they interpolate; the animations write -them, so setting them yourself has no effect. They live on the **viewport** and inherit downward, so -anything reading them must be the viewport or a descendant of it — not the root. - -Plus one token that is **not** scoped to this component, because Mosaic has no reason to size -scrollbars differently between components — set it once and every scrolling surface follows: - -| Token | Default | Description | -| ---------------------- | ------- | ------------------------------------- | -| `--cl-scrollbar-width` | `thin` | `auto`, `thin`, or `none`. See below. | - -`thin` rather than the platform default, because these regions are compact panels where a ~17px bar -reads heavy, and because `gutter: stable` means that width is content space given up whether or not -anything is scrolling. The tradeoff is a smaller drag target on the platforms whose scrollbars are -draggable at all — take it back with `auto`: +Two of them are **read-only per-element state**, written by the scroll-driven animations. They live +on the viewport and inherit downward, so anything reading them has to be the viewport or a +descendant — not the root. Setting them yourself does nothing; the animations overwrite them. + +| Variable | Range | Description | +| --------------------------------- | ----- | --------------------------------------------- | +| `--cl-scroll-area-progress-start` | 0 → 1 | How much the top edge has to reveal. | +| `--cl-scroll-area-progress-end` | 1 → 0 | How much the bottom edge still has to reveal. | + +They are registered with `@property` so they interpolate — an unregistered custom property animates +discretely and would snap halfway through the scroll instead of tracking it. + +The knobs you actually set are **theme tokens, not component variables**, because how soft the edge +of a scrolling region is belongs to the design language rather than to one component. Set them once +and every scrolling surface follows; a component that needs different values sets the token on +itself. + +| Token | Default | Description | +| ------------------------ | -------- | --------------------------------------------------------- | +| `--cl-scroll-fade-size` | `1.5rem` | Height of the fade band. | +| `--cl-scroll-fade-range` | `1.5rem` | How far you scroll before the fade reaches full strength. | +| `--cl-scroll-fade-inset` | `0px` | Width at the inline end the fade is held back from. | +| `--cl-scrollbar-width` | `thin` | `auto`, `thin`, or `none`. Keyword-only — see below. | ```css :root { - --cl-scrollbar-width: auto; + --cl-scroll-fade-size: 2.5rem; + --cl-scroll-fade-range: 2.5rem; } ``` -**Keyword-only, by spec.** `scrollbar-width` accepts `auto | thin | none` and _not_ a length, so -there is no `--cl-scrollbar-width: 8px`. A real pixel width exists only through +`size` and `range` default to the same value on purpose: the fade reaches full strength after you +have scrolled its own height, so it grows in at the rate the content moves. They stay independent — +a shorter range makes the fade snap in sooner without changing how tall it ends up. + +`--cl-scrollbar-width` is **keyword-only, by spec**. `scrollbar-width` accepts `auto | thin | none` +and _not_ a length, so there is no `8px` value for it. A real pixel width exists only through `::-webkit-scrollbar`, which Firefox ignores outright and which Chrome 121+ discards as soon as -`scrollbar-color` is set — so exposing this as a length would be a promise the platform can't keep. +`scrollbar-color` is set — exposing it as a length would be a promise the platform can't keep. + +`thin` rather than the platform default because these regions are compact panels where a ~17px bar +reads heavy. The tradeoff is a smaller drag target on the platforms whose scrollbars are draggable +at all; take it back with `--cl-scrollbar-width: auto`. ### Replacing the indicators @@ -185,20 +194,20 @@ which looks identical to the mask it was meant to replace, so the indicator sile as one in dark mode. `--cl-color-card-foreground` is `light-dark()`-backed and inverts to near-white on a dark surface, so the same rule gives a shadow in light mode and a glow in dark. -### The scrollbar inset +### The fade inset -`--cl-scroll-area-scrollbar-inset` holds the fade back from a strip at the inline end, so a -space-consuming scrollbar isn't faded along with the content. +`--cl-scroll-fade-inset` holds the fade back from a strip at the inline end, so a space-consuming +scrollbar isn't faded along with the content. It defaults to `0px` because **CSS cannot measure a scrollbar**. The width differs per platform and -per browser, `scrollbar-width: thin` changes it again, and on macOS it changes at runtime when a -mouse is connected. Any non-zero default would be wrong more often than right — and where the +per browser, `--cl-scrollbar-width: thin` changes it again, and on macOS it changes at runtime when +a mouse is connected. Any non-zero default would be wrong more often than right — and where the scrollbar overlays, which is the common case, there is nothing to hold back from. Set it only when you know the platform you're targeting. ```css -.cl-scroll-area-viewport { - --cl-scroll-area-scrollbar-inset: 15px; +:root { + --cl-scroll-fade-inset: 15px; } ``` diff --git a/packages/swingset/src/stories/scroll-area.component.stories.tsx b/packages/swingset/src/stories/scroll-area.component.stories.tsx index 37fac465814..25c489e3bee 100644 --- a/packages/swingset/src/stories/scroll-area.component.stories.tsx +++ b/packages/swingset/src/stories/scroll-area.component.stories.tsx @@ -98,15 +98,17 @@ export function Gutter() { ); } -// Both knobs are plain custom properties, so they can be set anywhere in the cascade — on -// the element, on a wrapper, or once at `:root` to retune every scroll area in a theme. +// Theme tokens rather than component variables, so they can be set anywhere in the cascade — +// on the element, on a wrapper, or once at `:root` to retune every scrolling surface in +// Mosaic at the same time. Scoped to a wrapper class here so the demo doesn't retheme the +// rest of the page. export function Tuning() { return ( <> { // The `--cl-*` names are the component's public API — a consumer's stylesheet references them // by hand, and `clerk-js` ships to apps pinned to older SDKs, so renaming one breaks themes // already in the wild. Assert the exact strings so a rename has to be a deliberate act. - it('emits the documented public custom properties', () => { + it('emits the documented per-element progress properties', () => { // `toMatchObject`, not `toEqual`: StyleX adds an internal `__varGroupHash__` key, and adding // a new var is not itself a breaking change — removing or renaming one is. expect(scrollAreaVars).toMatchObject({ '--cl-scroll-area-progress-start': 'var(--cl-scroll-area-progress-start)', '--cl-scroll-area-progress-end': 'var(--cl-scroll-area-progress-end)', - '--cl-scroll-area-fade-size': 'var(--cl-scroll-area-fade-size)', - '--cl-scroll-area-fade-range': 'var(--cl-scroll-area-fade-range)', - '--cl-scroll-area-scrollbar-inset': 'var(--cl-scroll-area-scrollbar-inset)', }); }); // Shared across every scrolling surface in Mosaic rather than owned here, but the viewport - // reads it, so a rename would silently drop the scrollbar sizing. - it('reads the shared scrollbar-width token', () => { + // reads them, so a rename would silently drop the scrollbar sizing or the fade's knobs. + it('reads the shared scroll tokens', () => { expect(scrollbarVars).toMatchObject({ '--cl-scrollbar-width': 'var(--cl-scrollbar-width)' }); + expect(scrollFadeVars).toMatchObject({ + '--cl-scroll-fade-size': 'var(--cl-scroll-fade-size)', + '--cl-scroll-fade-range': 'var(--cl-scroll-fade-range)', + '--cl-scroll-fade-inset': 'var(--cl-scroll-fade-inset)', + }); }); it('does not make the viewport focusable on its own', () => { diff --git a/packages/ui/src/mosaic/components/scroll-area/scroll-area.vars.stylex.ts b/packages/ui/src/mosaic/components/scroll-area/scroll-area.vars.stylex.ts index f4ccf412ae1..5260b6d7171 100644 --- a/packages/ui/src/mosaic/components/scroll-area/scroll-area.vars.stylex.ts +++ b/packages/ui/src/mosaic/components/scroll-area/scroll-area.vars.stylex.ts @@ -1,8 +1,9 @@ import * as stylex from '@stylexjs/stylex'; -// ScrollArea's public var contract. The two progress vars are the whole point of the -// component's styling API: a scroll-driven animation writes them, the default mask reads -// them, and a consumer can read them instead to drive any treatment they like. +// ScrollArea's per-element runtime output: vars a consumer READS, never sets. The scroll-driven +// animations write them on every scrolling element, so a `:root` value would simply be +// overwritten. That is why these stay component-named while the fade's actual knobs live in +// `tokens.stylex.ts` as the global `--cl-scroll-fade-*` family — those are set, these are read. // // They are `stylex.types.number` rather than plain strings so StyleX emits an `@property` // registration for each. That registration is load-bearing twice over: @@ -18,17 +19,4 @@ import * as stylex from '@stylexjs/stylex'; export const scrollAreaVars = stylex.defineVars({ '--cl-scroll-area-progress-start': stylex.types.number(0), '--cl-scroll-area-progress-end': stylex.types.number(0), - // Matched on purpose: the fade reaches full strength after you've scrolled its own height, - // so the indicator grows in at the same rate as the content it's covering moves. They stay - // independent knobs — a shorter range makes the fade snap in sooner without changing how - // tall it ends up. - '--cl-scroll-area-fade-size': '1.5rem', - '--cl-scroll-area-fade-range': '1.5rem', - // Width of the strip at the inline end that the fade is held back from, so a classic - // (space-consuming) scrollbar isn't faded along with the content. Defaults to `0px` - // because CSS cannot measure a scrollbar: the value differs per platform, per browser, - // and on macOS it changes when a mouse is connected, so any non-zero default would be - // wrong more often than right. Overlay scrollbars — the common case — need no inset at - // all. Consumers targeting a known classic-scrollbar platform can set it. - '--cl-scroll-area-scrollbar-inset': '0px', }); diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index d79bd8a95b0..40145ee1e8d 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -51,6 +51,7 @@ import { fontWeightVars, radiusVars, scrollbarVars, + scrollFadeVars, space, spacingVars, targetVars, @@ -64,6 +65,7 @@ export { fontWeightVars, radiusVars, scrollbarVars, + scrollFadeVars, space, spacingVars, targetVars, @@ -79,6 +81,7 @@ export type EasingVarName = keyof typeof easingVars; export type FontWeightVarName = keyof typeof fontWeightVars; export type RadiusVarName = keyof typeof radiusVars; export type ScrollbarVarName = keyof typeof scrollbarVars; +export type ScrollFadeVarName = keyof typeof scrollFadeVars; export type SpacingVarName = keyof typeof spacingVars; export type TargetVarName = keyof typeof targetVars; export type TypeScaleVarName = keyof typeof typeScaleVars; diff --git a/packages/ui/src/mosaic/tokens.stylex.ts b/packages/ui/src/mosaic/tokens.stylex.ts index 8ebdd9aa274..c2b726ea5a3 100644 --- a/packages/ui/src/mosaic/tokens.stylex.ts +++ b/packages/ui/src/mosaic/tokens.stylex.ts @@ -110,6 +110,27 @@ const scrollbarDefaults = { export const scrollbarVars = stylex.defineVars(scrollbarDefaults); +// The edge-fade indicator on a scrolling region. Global rather than owned by `ScrollArea` +// because "how soft is the edge of a scrolling region" is a design-language decision, on a +// par with a radius step — any component that grows an edge fade should read these rather +// than mint its own family. A component that genuinely needs a different value sets the var +// on itself; the global default still applies everywhere else. +// +// `size` and `range` default to the same value on purpose: the fade reaches full strength +// after you've scrolled its own height, so it grows in at the rate the content moves. +// +// `inset` holds the fade back from a strip at the inline end so a space-consuming scrollbar +// isn't faded along with the content. It defaults to `0px` because CSS cannot measure a +// scrollbar — the width differs per platform and browser, and on macOS it changes at runtime +// when a mouse is connected — so any non-zero default would be wrong more often than right. +const scrollFadeDefaults = { + '--cl-scroll-fade-size': '1.5rem', + '--cl-scroll-fade-range': '1.5rem', + '--cl-scroll-fade-inset': '0px', +} as const; + +export const scrollFadeVars = stylex.defineVars(scrollFadeDefaults); + // ============================================================================= // Spacing Tokens // ============================================================================= From 1a669453b5952618c15c107b814a47385f98b5fd Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 31 Jul 2026 14:40:54 -0600 Subject: [PATCH 04/24] fix(ui): Manage ScrollArea's tab stop so consumers don't have to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chrome and Firefox make an overflowing scroll container keyboard-focusable on their own; Safari does not, so a keyboard-only user there cannot scroll the region at all (WCAG 2.1.1). Leaving that to the caller meant every caller had to know about a browser gap to get a baseline accessibility guarantee, which is the component's responsibility rather than theirs. The viewport now takes a tab stop exactly when the browsers themselves would: when it overflows AND its content contains nothing focusable. The second half carries as much weight as the first — a list whose rows are buttons or links is already reachable, since tabbing into the content scrolls it, so a stop on the container would be redundant. Reproducing the browsers' rule rather than sniffing for Safari means this is a no-op wherever the browser already handles it. Both halves are observed rather than sampled once. A ResizeObserver watches the viewport and each element child, because content growing past the threshold leaves the viewport's own box unchanged and a MutationObserver wouldn't catch a purely visual change like an image loading; a MutationObserver covers rows gaining or losing interactivity. An explicit `tabIndex` always wins, and `-1` opts out. This is the component's only JavaScript. The JSDoc claim that nothing ran at runtime is corrected accordingly — the indicators still cost nothing, with no scroll listener and no measurement behind them. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/mosaic-scroll-area.md | 4 +- .../src/stories/scroll-area.component.mdx | 42 +++++---- .../scroll-area/scroll-area.test.tsx | 60 ++++++++++++- .../components/scroll-area/scroll-area.tsx | 52 ++++++++--- .../scroll-area/use-scroller-focusable.ts | 89 +++++++++++++++++++ 5 files changed, 218 insertions(+), 29 deletions(-) create mode 100644 packages/ui/src/mosaic/components/scroll-area/use-scroller-focusable.ts diff --git a/.changeset/mosaic-scroll-area.md b/.changeset/mosaic-scroll-area.md index 02d8293b15c..c5c8099d162 100644 --- a/.changeset/mosaic-scroll-area.md +++ b/.changeset/mosaic-scroll-area.md @@ -4,7 +4,9 @@ Add `ScrollArea` to Mosaic — a vertically scrolling region that fades its content at whichever edge still has something to reveal. Composed as `ScrollArea.Root` and `ScrollArea.Viewport`. -The indicators are pure CSS, driven by scroll-driven animations, and cost nothing at runtime. Because the fade is a mask rather than a sticky overlay element, it is paint-only and cannot shift the content. Browsers without scroll-driven animation support get a plain scroll area rather than a broken one. +The indicators are pure CSS, driven by scroll-driven animations — no scroll listener and no measurement. Because the fade is a mask rather than a sticky overlay element, it is paint-only and cannot shift the content. Browsers without scroll-driven animation support get a plain scroll area rather than a broken one. + +`ScrollArea.Viewport` manages its own `tabIndex` so consumers don't have to. Chrome and Firefox make an overflowing scroll container keyboard-focusable automatically and Safari does not, leaving a keyboard-only user there unable to scroll the region (WCAG 2.1.1); the viewport takes a tab stop exactly when those browsers would — when it overflows and its content holds nothing focusable — so a list of buttons or links, which is already reachable, doesn't gain a redundant stop. Pass an explicit `tabIndex` to override, or `-1` to opt out. `ScrollArea.Viewport` takes a `gutter` prop. The default, `auto`, takes the scrollbar's space only while the content overflows. Pass `stable` for a collection that can change height in place — a filterable list, a paginated table — so that crossing the overflow threshold doesn't shift its rows sideways. diff --git a/packages/swingset/src/stories/scroll-area.component.mdx b/packages/swingset/src/stories/scroll-area.component.mdx index cadc23bce35..2b7ee100b0d 100644 --- a/packages/swingset/src/stories/scroll-area.component.mdx +++ b/packages/swingset/src/stories/scroll-area.component.mdx @@ -7,10 +7,9 @@ still has something to reveal, so the boundary of a list reads as "there's more" hard cut. The fade is **pure CSS**. Two scroll-driven animations write a progress var per edge, and a mask -reads them. Nothing runs in JavaScript, there is no primitive behind it, and no measurement happens -at runtime. Because the fade is a mask rather than a sticky overlay element, it is paint-only and -**cannot shift the content** — the classic sticky-pseudo-element approach takes space in the scroll -flow, this doesn't. +reads them — no scroll listener, no measurement, no primitive behind it. Because the fade is a mask +rather than a sticky overlay element, it is paint-only and **cannot shift the content**; the classic +sticky-pseudo-element approach takes space in the scroll flow, this doesn't. ## Example @@ -221,17 +220,30 @@ duration and paint both fades permanently. ### Keyboard access -Chrome and Firefox make an overflowing scroll container keyboard-focusable on their own; Safari does -not, so a keyboard-only user cannot scroll it there. `ScrollArea.Viewport` deliberately does **not** -set `tabIndex` — an always-present tab stop is wrong for a region that is often not scrollable. Pass -it yourself, with an accessible name, where the content is known to overflow: +The viewport manages its own `tabIndex`, so this is handled for you. + +Chrome and Firefox make an overflowing scroll container keyboard-focusable automatically. Safari +does not, which leaves a keyboard-only user there unable to scroll the region at all (WCAG 2.1.1). +The viewport closes that gap by taking a tab stop exactly when those browsers would: when it +**overflows** _and_ its content contains **nothing focusable**. + +The second half matters as much as the first. A list whose rows are buttons or links is already +reachable — tabbing into the content scrolls it — so a stop on the container would be a redundant +one. Chrome and Firefox make the same exclusion, which is why applying the rule everywhere rather +than sniffing for Safari changes nothing in the browsers that already handle it. + +Both halves are watched rather than sampled once, so a list that grows past the threshold or rows +that gain interactivity are picked up after mount. + +Pass an explicit `tabIndex` to take the decision back; `-1` opts out entirely. ```tsx - - {members} - +// Managed: takes a stop only if it needs one. +{plainTextRows} + +// Opted out. +{rows} ``` + +No `role` is added along with the stop, matching what the browsers do natively. Add +`role='region'` with an `aria-label` if you want the region announced. diff --git a/packages/ui/src/mosaic/components/scroll-area/scroll-area.test.tsx b/packages/ui/src/mosaic/components/scroll-area/scroll-area.test.tsx index 51df9805315..603a33e36e2 100644 --- a/packages/ui/src/mosaic/components/scroll-area/scroll-area.test.tsx +++ b/packages/ui/src/mosaic/components/scroll-area/scroll-area.test.tsx @@ -106,8 +106,62 @@ describe('Mosaic ScrollArea', () => { }); }); - it('does not make the viewport focusable on its own', () => { - render(Contents); - expect(screen.getByTestId('viewport')).not.toHaveAttribute('tabindex'); + describe('keyboard reachability', () => { + // jsdom reports every box as zero-sized, so overflow has to be faked. Both values are + // stubbed together because the check is a comparison, not a threshold. + const setOverflow = (element: HTMLElement, overflowing: boolean) => { + Object.defineProperty(element, 'scrollHeight', { configurable: true, value: overflowing ? 400 : 100 }); + Object.defineProperty(element, 'clientHeight', { configurable: true, value: 100 }); + }; + + // The element has to overflow before the effect's first sync runs, so the stubs are + // installed from the callback ref rather than after render. + const renderViewport = (overflowing: boolean, children: React.ReactNode) => + render( + { + if (element) { + setOverflow(element, overflowing); + } + }} + > + {children} + , + ); + + it('takes a tab stop when it overflows and holds nothing focusable', () => { + renderViewport(true,

Contents

); + expect(screen.getByTestId('viewport')).toHaveAttribute('tabindex', '0'); + }); + + it('takes no tab stop when there is nothing to scroll', () => { + renderViewport(false,

Contents

); + expect(screen.getByTestId('viewport')).not.toHaveAttribute('tabindex'); + }); + + // Tabbing into the content already scrolls the region, so a stop on the container would be + // a redundant one. Chrome and Firefox make the same exclusion. + it('takes no tab stop when its content is already reachable', () => { + renderViewport(true, ); + expect(screen.getByTestId('viewport')).not.toHaveAttribute('tabindex'); + }); + + it('lets an explicit tabIndex win over the managed one', () => { + render( + { + if (element) { + setOverflow(element, true); + } + }} + > +

Contents

+
, + ); + expect(screen.getByTestId('viewport')).toHaveAttribute('tabindex', '-1'); + }); }); }); diff --git a/packages/ui/src/mosaic/components/scroll-area/scroll-area.tsx b/packages/ui/src/mosaic/components/scroll-area/scroll-area.tsx index aafb2452055..6b3fff92d48 100644 --- a/packages/ui/src/mosaic/components/scroll-area/scroll-area.tsx +++ b/packages/ui/src/mosaic/components/scroll-area/scroll-area.tsx @@ -4,6 +4,7 @@ import React from 'react'; import type { MosaicComponentProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; import { gutters, styles } from './scroll-area.styles'; +import { useScrollerFocusable } from './use-scroller-focusable'; export type ScrollAreaGutter = 'stable' | 'auto'; @@ -47,12 +48,40 @@ const Root = React.forwardRef(function Scro * The scroll container. Owns the overflow, the scroll timelines, and the mask. */ const Viewport = React.forwardRef(function ScrollAreaViewport( - { gutter = 'auto', className, style, ...rest }, + { gutter = 'auto', tabIndex, className, style, ...rest }, ref, ) { + const [node, setNode] = React.useState(null); + + // A callback ref so the component can observe the element while still honouring whatever + // ref the caller passed. There is no `mergeRefs` helper in the repo to reach for. + const setRefs = React.useCallback( + (element: HTMLDivElement | null) => { + setNode(element); + if (typeof ref === 'function') { + ref(element); + } else if (ref) { + ref.current = element; + } + }, + [ref], + ); + + // An explicit `tabIndex` always wins — a caller who has an opinion about the tab order + // shouldn't have it silently overwritten, and passing `-1` is how you opt out entirely. + const managed = tabIndex === undefined; + const needsTabStop = useScrollerFocusable(node, managed); + return (
(funct * edge has more to reveal. Composed via dot syntax: `ScrollArea.Root`, `ScrollArea.Viewport`. * * The fade is a mask driven by two scroll-driven animations, one per edge, which write - * `--cl-scroll-area-progress-start` and `--cl-scroll-area-progress-end`. Nothing about it - * runs in JavaScript, and nothing about it participates in layout — the mask is paint-only, - * so it can't shift the content the way sticky shadow elements do. + * `--cl-scroll-area-progress-start` and `--cl-scroll-area-progress-end`. It costs nothing at + * runtime — no measurement, no scroll listener — and participates in no layout, since the + * mask is paint-only and so can't shift the content the way sticky shadow elements do. The + * only JavaScript here is the tab-stop management described below. * * The indicators are a progressive enhancement. Without scroll-driven animation support the * progress vars hold at 0 and the mask resolves to fully opaque, leaving a plain scroll area. @@ -86,11 +116,13 @@ const Viewport = React.forwardRef(funct * {items} * * @remarks - * Chrome and Firefox make an overflowing scroll container keyboard-focusable on their own; - * Safari does not, so a keyboard-only user can't scroll it there. `tabIndex` is deliberately - * not set here — an always-present tab stop is wrong for a region that often isn't - * scrollable. Pass `tabIndex={0}` (with an `aria-label` or `role='region'`) where the content - * is known to overflow. + * The viewport manages its own `tabIndex`. Chrome and Firefox make an overflowing scroller + * keyboard-focusable automatically; Safari does not, so a keyboard-only user there can't + * scroll the region at all. The viewport closes that gap by taking a tab stop exactly when + * the browsers themselves would: when it overflows **and** its content contains nothing + * focusable. A list of buttons or links is already reachable, so a stop on the container + * would only add noise. Pass an explicit `tabIndex` to take the decision back — `-1` opts + * out completely. */ export const ScrollArea = { Root, diff --git a/packages/ui/src/mosaic/components/scroll-area/use-scroller-focusable.ts b/packages/ui/src/mosaic/components/scroll-area/use-scroller-focusable.ts new file mode 100644 index 00000000000..517e928f604 --- /dev/null +++ b/packages/ui/src/mosaic/components/scroll-area/use-scroller-focusable.ts @@ -0,0 +1,89 @@ +import React from 'react'; + +// What the browsers themselves count as keyboard-reachable. Deliberately close to the +// canonical focusable-elements list rather than exhaustive — it decides whether the scroller +// needs a tab stop of its own, and a near-miss costs at most one redundant stop. +const FOCUSABLE_SELECTOR = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + 'audio[controls]', + 'video[controls]', + 'details > summary', + '[contenteditable]:not([contenteditable="false"])', + '[tabindex]:not([tabindex="-1"])', +].join(','); + +/** + * Whether a scroll container needs a tab stop of its own. + * + * Chrome and Firefox make an overflowing scroller keyboard-focusable automatically; Safari + * does not, so a keyboard-only user there cannot scroll the region at all (WCAG 2.1.1). This + * reproduces the browsers' rule so the gap closes without the caller having to know about it. + * + * The rule is deliberately two-part: **overflowing AND containing nothing focusable.** A + * scroller whose rows are buttons or links is already reachable — tabbing into the content + * scrolls it — so a stop on the container would be pure noise. Chrome and Firefox make the + * same exclusion, which means applying this everywhere (rather than sniffing for Safari) + * matches what those browsers would have done on their own. + * + * Both halves are observed, not sampled once: content can grow past the threshold, and rows + * can gain or lose interactivity, long after mount. + */ +export function useScrollerFocusable(node: HTMLElement | null, enabled: boolean): boolean { + const [focusable, setFocusable] = React.useState(false); + + React.useEffect(() => { + if (!enabled || !node) { + setFocusable(false); + return; + } + + let observedChildren: Element[] = []; + + const sync = () => { + const overflows = node.scrollHeight > node.clientHeight; + const contentIsReachable = node.querySelector(FOCUSABLE_SELECTOR) !== null; + setFocusable(overflows && !contentIsReachable); + }; + + const resizeObserver = new ResizeObserver(sync); + resizeObserver.observe(node); + + // The scroller's own box resizing is only half of it — content growing past the threshold + // leaves the scroller's box untouched, and a `MutationObserver` won't catch a purely + // visual change like an image loading. Observing the children covers that. + const observeChildren = () => { + for (const child of observedChildren) { + resizeObserver.unobserve(child); + } + observedChildren = Array.from(node.children); + for (const child of observedChildren) { + resizeObserver.observe(child); + } + }; + + const mutationObserver = new MutationObserver(() => { + observeChildren(); + sync(); + }); + mutationObserver.observe(node, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['contenteditable', 'controls', 'disabled', 'href', 'tabindex'], + }); + + observeChildren(); + sync(); + + return () => { + resizeObserver.disconnect(); + mutationObserver.disconnect(); + }; + }, [node, enabled]); + + return focusable; +} From 3d9408e7494734d96361ee63755019d92c1a56c3 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 31 Jul 2026 14:54:10 -0600 Subject: [PATCH 05/24] feat(ui): Add a render prop to both ScrollArea parts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings `ScrollArea` in line with the rest of Mosaic, where every part is polymorphic through `render`. A scrolling region often wants semantics of its own — a list of members is a `
    `, a labelled region is a `
    ` — and without this the caller had to choose between the component's styling and the right element. Both parts take it rather than just the viewport: a compound component where only one half is polymorphic is a trap. On the viewport the rendered element has to be able to establish a scroll box, since the overflow, mask and scroll timelines all apply to whatever lands there. `useRender` merges an array of refs, which also replaces the hand-rolled ref composition the viewport needed to observe its own element while still honouring the caller's ref. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/mosaic-scroll-area.md | 2 + .../scroll-area/scroll-area.test.tsx | 23 ++++++ .../components/scroll-area/scroll-area.tsx | 76 ++++++++----------- 3 files changed, 58 insertions(+), 43 deletions(-) diff --git a/.changeset/mosaic-scroll-area.md b/.changeset/mosaic-scroll-area.md index c5c8099d162..28753ab4e30 100644 --- a/.changeset/mosaic-scroll-area.md +++ b/.changeset/mosaic-scroll-area.md @@ -8,6 +8,8 @@ The indicators are pure CSS, driven by scroll-driven animations — no scroll li `ScrollArea.Viewport` manages its own `tabIndex` so consumers don't have to. Chrome and Firefox make an overflowing scroll container keyboard-focusable automatically and Safari does not, leaving a keyboard-only user there unable to scroll the region (WCAG 2.1.1); the viewport takes a tab stop exactly when those browsers would — when it overflows and its content holds nothing focusable — so a list of buttons or links, which is already reachable, doesn't gain a redundant stop. Pass an explicit `tabIndex` to override, or `-1` to opt out. +Both parts accept a `render` prop for polymorphism, so the region can carry its own semantics — `}>` for a list, for example. On the viewport the rendered element has to be able to establish a scroll box, since the overflow, mask and scroll timelines all apply to it. + `ScrollArea.Viewport` takes a `gutter` prop. The default, `auto`, takes the scrollbar's space only while the content overflows. Pass `stable` for a collection that can change height in place — a filterable list, a paginated table — so that crossing the overflow threshold doesn't shift its rows sideways. The treatment is overridable in plain CSS, with no props involved. Set `mask-image: none` on `.cl-scroll-area-viewport` to retire the default fade, and read `--cl-scroll-area-progress-start` / `--cl-scroll-area-progress-end` — per-element values the animations write, describing how much each edge still has to reveal — to drive a shadow or any other indicator. diff --git a/packages/ui/src/mosaic/components/scroll-area/scroll-area.test.tsx b/packages/ui/src/mosaic/components/scroll-area/scroll-area.test.tsx index 603a33e36e2..d43c6585a4f 100644 --- a/packages/ui/src/mosaic/components/scroll-area/scroll-area.test.tsx +++ b/packages/ui/src/mosaic/components/scroll-area/scroll-area.test.tsx @@ -106,6 +106,29 @@ describe('Mosaic ScrollArea', () => { }); }); + it('renders custom elements via render, keeping the styling contract', () => { + render( + } + > + } + > +
  • Ada Lovelace
  • +
    +
    , + ); + const root = screen.getByTestId('root'); + const viewport = screen.getByTestId('viewport'); + expect(root.tagName).toBe('SECTION'); + expect(root).toHaveClass('cl-scroll-area-root'); + expect(viewport.tagName).toBe('UL'); + expect(viewport).toHaveClass('cl-scroll-area-viewport'); + expect(viewport).toHaveAttribute('data-gutter', 'auto'); + }); + describe('keyboard reachability', () => { // jsdom reports every box as zero-sized, so overflow has to be faked. Both values are // stubbed together because the check is a comparison, not a threshold. diff --git a/packages/ui/src/mosaic/components/scroll-area/scroll-area.tsx b/packages/ui/src/mosaic/components/scroll-area/scroll-area.tsx index 6b3fff92d48..5692db55b66 100644 --- a/packages/ui/src/mosaic/components/scroll-area/scroll-area.tsx +++ b/packages/ui/src/mosaic/components/scroll-area/scroll-area.tsx @@ -1,3 +1,4 @@ +import { useRender } from '@clerk/headless/utils'; import * as stylex from '@stylexjs/stylex'; import React from 'react'; @@ -8,9 +9,9 @@ import { useScrollerFocusable } from './use-scroller-focusable'; export type ScrollAreaGutter = 'stable' | 'auto'; -export type ScrollAreaRootProps = Omit, 'render'>; +export type ScrollAreaRootProps = MosaicComponentProps<'div'>; -export interface ScrollAreaViewportProps extends Omit, 'render'> { +export interface ScrollAreaViewportProps extends MosaicComponentProps<'div'> { /** * Whether the scrollbar's space is held open. `auto` (the default) takes the space only * while the content overflows. Pass `stable` when the content can change height **in @@ -29,68 +30,57 @@ export interface ScrollAreaViewportProps extends Omit(function ScrollAreaRoot( - { className, style, ...rest }, + { render, className, style, ...rest }, ref, ) { - return ( -
    - ); + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps(themeProps('scroll-area-root'), stylex.props(styles.root), className, style), + ...rest, + }, + }); }); /** - * The scroll container. Owns the overflow, the scroll timelines, and the mask. + * The scroll container. Owns the overflow, the scroll timelines, and the mask. Renders a + * `div`; `render` swaps in another element, which must be able to establish a scroll box — + * the overflow, mask and timelines all apply to whatever is rendered here. */ const Viewport = React.forwardRef(function ScrollAreaViewport( - { gutter = 'auto', tabIndex, className, style, ...rest }, + { gutter = 'auto', tabIndex, render, className, style, ...rest }, ref, ) { - const [node, setNode] = React.useState(null); - - // A callback ref so the component can observe the element while still honouring whatever - // ref the caller passed. There is no `mergeRefs` helper in the repo to reach for. - const setRefs = React.useCallback( - (element: HTMLDivElement | null) => { - setNode(element); - if (typeof ref === 'function') { - ref(element); - } else if (ref) { - ref.current = element; - } - }, - [ref], - ); + const [node, setNode] = React.useState(null); // An explicit `tabIndex` always wins — a caller who has an opinion about the tab order // shouldn't have it silently overwritten, and passing `-1` is how you opt out entirely. const managed = tabIndex === undefined; const needsTabStop = useScrollerFocusable(node, managed); - return ( -
    - ); + ), + ...rest, + }, + }); }); /** From 9b2c996df8fa2e22cee0166a18127f1e9d81bb57 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 31 Jul 2026 15:29:47 -0600 Subject: [PATCH 06/24] refactor(ui): Ship the scroll area as StyleX atoms instead of a component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything the scroll area does is CSS, so wrapping it in a component only added a DOM node to every scrolling surface and an API to version. `scrollAreaViewport` returns the atoms for the element that scrolls and `scrollAreaRoot` styles a positioned ancestor, so the styles ride on an element that already exists — an `Item.Group`, a list, a panel body — rather than introducing one. That also drops the `render` prop, since applying styles to your own element is polymorphism by construction, and it removes the `.cl-scroll-area-*` classes: the host element keeps its own slot class, and that stays the hook a theme targets. The swingset examples put the atoms on an `Item.Group`, so the override story is written against `.cl-item-group`. The managed tab stop goes with it. It was the only JavaScript here, and `tabindex` cannot be expressed as a style, so the Safari keyboard gap is now the caller's to close — documented, with the rule the browsers themselves use. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/mosaic-scroll-area.md | 16 +- .../src/stories/scroll-area.component.mdx | 113 +++++------ .../stories/scroll-area.component.stories.tsx | 132 ++++++------ .../mosaic/components/scroll-area/index.ts | 5 +- .../scroll-area/scroll-area.styles.ts | 41 +++- .../scroll-area/scroll-area.test.ts | 44 ++++ .../scroll-area/scroll-area.test.tsx | 190 ------------------ .../components/scroll-area/scroll-area.tsx | 120 ----------- .../scroll-area/use-scroller-focusable.ts | 89 -------- packages/ui/src/mosaic/styles/index.ts | 4 +- 10 files changed, 226 insertions(+), 528 deletions(-) create mode 100644 packages/ui/src/mosaic/components/scroll-area/scroll-area.test.ts delete mode 100644 packages/ui/src/mosaic/components/scroll-area/scroll-area.test.tsx delete mode 100644 packages/ui/src/mosaic/components/scroll-area/scroll-area.tsx delete mode 100644 packages/ui/src/mosaic/components/scroll-area/use-scroller-focusable.ts diff --git a/.changeset/mosaic-scroll-area.md b/.changeset/mosaic-scroll-area.md index 28753ab4e30..39c15cce878 100644 --- a/.changeset/mosaic-scroll-area.md +++ b/.changeset/mosaic-scroll-area.md @@ -2,16 +2,18 @@ '@clerk/ui': minor --- -Add `ScrollArea` to Mosaic — a vertically scrolling region that fades its content at whichever edge still has something to reveal. Composed as `ScrollArea.Root` and `ScrollArea.Viewport`. +Add a Mosaic scroll area: a scrolling region that fades its content at whichever edge still has something to reveal. -The indicators are pure CSS, driven by scroll-driven animations — no scroll listener and no measurement. Because the fade is a mask rather than a sticky overlay element, it is paint-only and cannot shift the content. Browsers without scroll-driven animation support get a plain scroll area rather than a broken one. +It ships as StyleX atoms rather than a component, because everything it does is CSS — a component would only add a DOM node and an API to version. `scrollAreaViewport(gutter?)` returns the atoms for the element that scrolls, and `scrollAreaRoot` styles a positioned ancestor for cases where an overlay has to anchor against the scroll box. The atoms bring no class of their own, so the element you apply them to keeps its existing `.cl-` class, and that stays the hook a theme targets. -`ScrollArea.Viewport` manages its own `tabIndex` so consumers don't have to. Chrome and Firefox make an overflowing scroll container keyboard-focusable automatically and Safari does not, leaving a keyboard-only user there unable to scroll the region (WCAG 2.1.1); the viewport takes a tab stop exactly when those browsers would — when it overflows and its content holds nothing focusable — so a list of buttons or links, which is already reachable, doesn't gain a redundant stop. Pass an explicit `tabIndex` to override, or `-1` to opt out. +```tsx +{rows} +``` -Both parts accept a `render` prop for polymorphism, so the region can carry its own semantics — `}>` for a list, for example. On the viewport the rendered element has to be able to establish a scroll box, since the overflow, mask and scroll timelines all apply to it. +The indicators are driven by scroll-driven animations — no scroll listener and no measurement. Because the fade is a mask rather than a sticky overlay element, it is paint-only and cannot shift the content. Browsers without scroll-driven animation support get a plain scroll area rather than a broken one, and a region with nothing to scroll shows no indicators at all. -`ScrollArea.Viewport` takes a `gutter` prop. The default, `auto`, takes the scrollbar's space only while the content overflows. Pass `stable` for a collection that can change height in place — a filterable list, a paginated table — so that crossing the overflow threshold doesn't shift its rows sideways. +`gutter` defaults to `auto`, matching CSS. Pass `stable` for a collection that can change height in place — a filterable list, a paginated table — so that crossing the overflow threshold doesn't shift its rows sideways. -The treatment is overridable in plain CSS, with no props involved. Set `mask-image: none` on `.cl-scroll-area-viewport` to retire the default fade, and read `--cl-scroll-area-progress-start` / `--cl-scroll-area-progress-end` — per-element values the animations write, describing how much each edge still has to reveal — to drive a shadow or any other indicator. +The treatment is replaceable in plain CSS. Set `mask-image: none` on the element carrying the atoms to retire the default fade, and read `--cl-scroll-area-progress-start` / `--cl-scroll-area-progress-end` — per-element values the animations write, describing how much each edge still has to reveal — to drive a shadow or any other indicator. -Also adds four theme tokens that apply to every scrolling surface in Mosaic rather than to this component alone: `--cl-scroll-fade-size` and `--cl-scroll-fade-range` (both `1.5rem`) tune the fade's height and how far you scroll before it reaches full strength, `--cl-scroll-fade-inset` (`0px`) holds the fade back from a space-consuming scrollbar, and `--cl-scrollbar-width` (`thin`) sets the scrollbar size. Per the CSS spec that last one is keyword-only (`auto`, `thin`, or `none`) — `scrollbar-width` does not accept a length. +Also adds four theme tokens that apply to every scrolling surface in Mosaic rather than to one component: `--cl-scroll-fade-size` and `--cl-scroll-fade-range` (both `1.5rem`) tune the fade's height and how far you scroll before it reaches full strength, `--cl-scroll-fade-inset` (`0px`) holds the fade back from a space-consuming scrollbar, and `--cl-scrollbar-width` (`thin`) sets the scrollbar size. Per the CSS spec that last one is keyword-only (`auto`, `thin`, or `none`) — `scrollbar-width` does not accept a length. diff --git a/packages/swingset/src/stories/scroll-area.component.mdx b/packages/swingset/src/stories/scroll-area.component.mdx index 2b7ee100b0d..842aa7d83de 100644 --- a/packages/swingset/src/stories/scroll-area.component.mdx +++ b/packages/swingset/src/stories/scroll-area.component.mdx @@ -2,14 +2,18 @@ import * as ScrollAreaStories from './scroll-area.component.stories'; # ScrollArea -The Mosaic `ScrollArea` — a vertically scrolling region that fades its content at whichever edge -still has something to reveal, so the boundary of a list reads as "there's more" rather than as a -hard cut. +A scrolling region that fades its content at whichever edge still has something to reveal, so the +boundary of a list reads as "there's more" rather than as a hard cut. -The fade is **pure CSS**. Two scroll-driven animations write a progress var per edge, and a mask -reads them — no scroll listener, no measurement, no primitive behind it. Because the fade is a mask -rather than a sticky overlay element, it is paint-only and **cannot shift the content**; the classic -sticky-pseudo-element approach takes space in the scroll flow, this doesn't. +It ships as **StyleX atoms, not a component**. Everything it does is CSS, so a component would only +add a DOM node and an API to version. Spread the atoms onto an element you already render — an +`Item.Group`, a list, a panel body — and that element keeps its own slot class, which stays the hook +a theme targets. + +Two scroll-driven animations write a progress var per edge, and a mask reads them: no scroll +listener, no measurement, nothing at runtime. Because the fade is a mask rather than a sticky +overlay element it is paint-only and **cannot shift the content**, unlike the classic +sticky-pseudo-element approach which takes space in the scroll flow. ## Example @@ -20,18 +24,34 @@ sticky-pseudo-element approach takes space in the scroll flow, this doesn't. ## Usage -Two parts. `ScrollArea.Root` is the box the region occupies; `ScrollArea.Viewport` is the scroll -container that owns the overflow, the timelines and the mask. Give the root a height — the viewport -fills it. +`scrollAreaViewport()` returns the atoms for the element that scrolls; spread them. +`scrollAreaRoot` goes on a positioned ancestor, and is only needed when something has to anchor +against the scroll box — an overlay replacing the mask, for instance. A surface whose parent is +already positioned can skip it. ```tsx -import { ScrollArea } from '@clerk/ui/mosaic/components/scroll-area'; - - - {members.map(m => )} -; +import { Item } from '@clerk/ui/mosaic/components/item'; +import { scrollAreaRoot, scrollAreaViewport } from '@clerk/ui/mosaic/components/scroll-area'; +import * as stylex from '@stylexjs/stylex'; + +
    + {rows} +
    ; ``` +Note the spread: `scrollAreaViewport()` returns an array, so it goes into `stylex.props` with `...`. +Consumer styles still win by being passed after it. + +### API + +| Export | Type | Description | +| ----------------------------- | ------------------------------------------ | ------------------------------------------------ | +| `scrollAreaViewport(gutter?)` | `(gutter?: 'auto' \| 'stable') => atoms[]` | The scroll surface. `gutter` defaults to `auto`. | +| `scrollAreaRoot` | `atom` | Positioned ancestor, for anchoring overlays. | + ### Nothing to scroll When the content fits, both scroll timelines are inactive, both progress vars hold at their @@ -45,7 +65,7 @@ nothing had to detect that — the resting state is already the hidden one. ### Gutter -`gutter` on `ScrollArea.Viewport` decides whether the scrollbar's space is held open. +The `gutter` argument to `scrollAreaViewport()` decides whether the scrollbar's space is held open. `auto` (the default, and CSS's own) takes the space only while the content overflows. `stable` reserves it either way, so a collection that crosses the overflow threshold doesn't shift its rows @@ -89,26 +109,11 @@ The scrollbar's **size** is not a prop — see `--cl-scrollbar-width` below. storyModule={ScrollAreaStories} /> -## Parts - -| Part | Slot | Description | -| --------------------- | ---------------------- | ---------------------------------------------------------------- | -| `ScrollArea.Root` | `scroll-area-root` | The box the region occupies. Positioned, so overlays can anchor. | -| `ScrollArea.Viewport` | `scroll-area-viewport` | The scroll container; owns overflow, the timelines and the mask. | - -### Props - -Only `ScrollArea.Viewport` takes a prop of its own; `ScrollArea.Root` is a plain `div`. Both accept -the usual `className` / `style` escape hatches and forward every other native `div` prop. - -| Prop | Type | Default | Description | -| -------- | -------------------- | -------- | ------------------------------------------- | -| `gutter` | `'stable' \| 'auto'` | `'auto'` | Whether the scrollbar's space is held open. | - ## Styling -Themed with **StyleX**. Each part carries a stable `.cl-` class alongside the StyleX atoms; -consumers never target the hashed atomic classes. +The atoms bring no class of their own — the element you applied them to keeps its existing +`.cl-` class, and that is what a theme targets. In the examples here the styles ride on an +`Item.Group`, so every override below is written against `.cl-item-group`. ### Variables @@ -170,10 +175,10 @@ it. ```css @import '@clerk/ui/styles.css' layer(components); -.cl-scroll-area-viewport { +.cl-item-group { mask-image: none; } -.cl-scroll-area-viewport::before { +.cl-item-group::before { content: ''; position: absolute; inset: 0 0 auto; @@ -220,30 +225,22 @@ duration and paint both fades permanently. ### Keyboard access -The viewport manages its own `tabIndex`, so this is handled for you. - -Chrome and Firefox make an overflowing scroll container keyboard-focusable automatically. Safari -does not, which leaves a keyboard-only user there unable to scroll the region at all (WCAG 2.1.1). -The viewport closes that gap by taking a tab stop exactly when those browsers would: when it -**overflows** _and_ its content contains **nothing focusable**. +Chrome and Firefox make an overflowing scroll container keyboard-focusable on their own. **Safari +does not**, so a keyboard-only user there can't scroll the region at all (WCAG 2.1.1). -The second half matters as much as the first. A list whose rows are buttons or links is already -reachable — tabbing into the content scrolls it — so a stop on the container would be a redundant -one. Chrome and Firefox make the same exclusion, which is why applying the rule everywhere rather -than sniffing for Safari changes nothing in the browsers that already handle it. - -Both halves are watched rather than sampled once, so a list that grows past the threshold or rows -that gain interactivity are picked up after mount. - -Pass an explicit `tabIndex` to take the decision back; `-1` opts out entirely. +CSS can't express this — `tabindex` isn't a style — so the atoms can't close it for you. Where a +scroll surface holds nothing focusable, set `tabIndex={0}` on it yourself: ```tsx -// Managed: takes a stop only if it needs one. -{plainTextRows} - -// Opted out. -{rows} +
    + {prose} +
    ``` -No `role` is added along with the stop, matching what the browsers do natively. Add -`role='region'` with an `aria-label` if you want the region announced. +A surface whose rows are buttons or links needs nothing: tabbing into the content already scrolls +it, which is why Chrome and Firefox skip those too. diff --git a/packages/swingset/src/stories/scroll-area.component.stories.tsx b/packages/swingset/src/stories/scroll-area.component.stories.tsx index 25c489e3bee..8bcb78a5675 100644 --- a/packages/swingset/src/stories/scroll-area.component.stories.tsx +++ b/packages/swingset/src/stories/scroll-area.component.stories.tsx @@ -1,7 +1,9 @@ /** @jsxImportSource @emotion/react */ -import { Button } from '@clerk/ui/mosaic/components/button'; -import { ScrollArea } from '@clerk/ui/mosaic/components/scroll-area'; +import { Avatar } from '@clerk/ui/mosaic/components/avatar'; +import { Item } from '@clerk/ui/mosaic/components/item'; +import { scrollAreaRoot, scrollAreaViewport } from '@clerk/ui/mosaic/components/scroll-area'; import { Text } from '@clerk/ui/mosaic/components/text'; +import * as stylex from '@stylexjs/stylex'; import React from 'react'; import type { StoryMeta } from '@/lib/types'; @@ -13,7 +15,7 @@ export { default as __source } from './scroll-area.component.stories?raw'; export const meta: StoryMeta = { group: 'Components', title: 'ScrollArea', - source: 'packages/ui/src/mosaic/components/scroll-area/scroll-area.tsx', + source: 'packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts', styleEngine: 'stylex', }; @@ -26,25 +28,37 @@ const members = [ 'Barbara Liskov', 'Frances Allen', 'Jean Bartik', - 'Karen Spärck Jones', - 'Shafi Goldwasser', ]; const rows = (names: string[] = members) => names.map(name => ( -
    - {name} -
    + + + + {name[0]} + + + + {name} + + )); +// The scroll surface goes straight onto the `Item.Group` that already scrolls — no wrapper +// element, and the group keeps its own `.cl-item-group` slot, which stays the hook a theme +// targets. `scrollAreaRoot` is on the outer box only so overlays have something to anchor to; +// a group whose parent is already positioned doesn't need it. export function Default() { return ( - - {rows()} - +
    + {rows()} +
    ); } @@ -53,9 +67,12 @@ export function Default() { // indicators are the resting state rather than something switched off. export function NotScrollable() { return ( - - {rows(members.slice(0, 3))} - +
    + {rows(members.slice(0, 2))} +
    ); } @@ -69,29 +86,34 @@ export function NotScrollable() { // space, so there is no gutter for either value to hold open. export function Gutter() { const [overflowing, setOverflowing] = React.useState(true); - const content = overflowing ? rows() : rows(members.slice(0, 3)); + const content = overflowing ? rows() : rows(members.slice(0, 2)); return (
    - +
    - - {content} - - gutter="stable" — rows never move +
    + {content} +
    + stable — rows never move
    - - {content} - - gutter="auto" — rows widen when the scrollbar goes +
    + {content} +
    + auto — rows widen when the scrollbar goes
    @@ -99,9 +121,8 @@ export function Gutter() { } // Theme tokens rather than component variables, so they can be set anywhere in the cascade — -// on the element, on a wrapper, or once at `:root` to retune every scrolling surface in -// Mosaic at the same time. Scoped to a wrapper class here so the demo doesn't retheme the -// rest of the page. +// on the element, on a wrapper, or once at `:root` to retune every scrolling surface in Mosaic +// at the same time. Scoped to a wrapper class here so the demo doesn't retheme the page. export function Tuning() { return ( <> @@ -111,38 +132,36 @@ export function Tuning() { --cl-scroll-fade-range: 3rem; /* how far you scroll before it's at full strength */ } `} - - {rows()} - + {rows()} +
    ); } -// The indicators are a theme decision, so swapping the mask for something else needs no prop -// and no JavaScript — just CSS. -// -// `mask-image: none` retires the default treatment, and the two progress vars stay readable -// for whatever replaces it. Here they drive the opacity of a pair of gradient overlays. +// The indicators are a theme decision, so swapping the mask for something else needs no +// JavaScript — just CSS. `mask-image: none` retires the default treatment and the two progress +// vars stay readable for whatever replaces it. // -// Three things worth copying. The overlays hang off the VIEWPORT, because that is the element -// the scroll-driven animations write the vars onto (they inherit downward, not up to the -// root). They are absolutely positioned rather than sticky, so they overlay the content -// instead of taking space in the scroll flow the way a sticky pseudo-element would. And the -// scrim is mixed from a theme token rather than hardcoded black — a black scrim darkens a -// dark surface, which is indistinguishable from the mask it replaced, so the indicator has to -// flip with the theme the way `--cl-color-card-foreground` does. +// Three things worth copying. The overlays hang off the element the atoms were applied to, +// because that is what the animations write the vars onto — here `.cl-item-group`, since the +// styles ride on a slot that already exists rather than a wrapper of their own. They are +// absolutely positioned rather than sticky, so they overlay the content instead of taking space +// in the scroll flow. And the scrim is mixed from a theme token rather than hardcoded black, +// which would darken a dark surface and be indistinguishable from the mask it replaced. export function CustomIndicators() { return ( <> - - {rows()} - + {rows()} +
    ); } diff --git a/packages/ui/src/mosaic/components/scroll-area/index.ts b/packages/ui/src/mosaic/components/scroll-area/index.ts index 1e7a691bba8..5c72aeb1986 100644 --- a/packages/ui/src/mosaic/components/scroll-area/index.ts +++ b/packages/ui/src/mosaic/components/scroll-area/index.ts @@ -1,2 +1,3 @@ -export { ScrollArea } from './scroll-area'; -export type { ScrollAreaGutter, ScrollAreaRootProps, ScrollAreaViewportProps } from './scroll-area'; +export { scrollAreaRoot, scrollAreaViewport } from './scroll-area.styles'; +export type { ScrollAreaGutter } from './scroll-area.styles'; +export { scrollAreaVars } from './scroll-area.vars.stylex'; diff --git a/packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts b/packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts index 666ce2d73d5..2a05e704a7b 100644 --- a/packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts +++ b/packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts @@ -48,9 +48,9 @@ const revealEnd = stylex.keyframes({ const maskImage = `linear-gradient(to bottom, transparent 0, #000 calc(${progressStart} * ${fadeSize}), #000 calc(100% - ${progressEnd} * ${fadeSize}), transparent 100%), linear-gradient(#000, #000)`; // Split by concern rather than one object per slot: the sort-keys rule reorders within an -// object, so a large one ends up interleaving unrelated properties and stranding the -// comments that explain them. -export const styles = stylex.create({ +// object, so a large one ends up interleaving unrelated properties and stranding the comments +// that explain them. `scrollAreaViewport()` recomposes them, so callers spread one thing. +const styles = stylex.create({ root: { display: 'flex', flexDirection: 'column', @@ -116,7 +116,7 @@ export const styles = stylex.create({ // Mosaic has no reason to size scrollbars differently between components. What varies per // instance is whether the space is held open, which is a layout decision about the // surrounding content rather than an appearance one. -export const gutters = stylex.create({ +const gutters = stylex.create({ // The default, and CSS's own. Nothing is reserved until a scrollbar actually appears, which // is right whenever the content can't change height while mounted — no shift is possible, // so holding space open would only cost width. @@ -129,3 +129,36 @@ export const gutters = stylex.create({ scrollbarGutter: 'stable', }, }); + +export type ScrollAreaGutter = keyof typeof gutters; + +/** + * The scroll surface, as StyleX atoms to spread onto an element you already render. + * + * There is no `` component: everything here is CSS, so a component would only add + * a DOM node and an API to version. Put these on whatever already scrolls — an `Item.Group`, + * a list, a panel body — and it keeps its own slot class, which stays the hook a theme + * targets. + * + * ```tsx + *
    + * {rows} + *
    + * ``` + * + * @param gutter - Whether the scrollbar's space is held open. `auto` (the default, and CSS's + * own) takes it only while the content overflows. `stable` reserves it either way, which is + * worth it when the content can change height **in place** — a filterable or paginated + * collection — so crossing the overflow threshold doesn't shift the rows sideways. Neither + * does anything on platforms that overlay their scrollbars. + */ +export function scrollAreaViewport(gutter: ScrollAreaGutter = 'auto') { + return [styles.viewport, styles.mask, styles.indicators, styles.focusRing, gutters[gutter]] as const; +} + +/** + * The positioned ancestor. Only needed when something has to anchor against the scroll box — + * an overlay replacing the default mask, or a future scrollbar. A scroll surface whose parent + * is already positioned doesn't need it. + */ +export const scrollAreaRoot = styles.root; diff --git a/packages/ui/src/mosaic/components/scroll-area/scroll-area.test.ts b/packages/ui/src/mosaic/components/scroll-area/scroll-area.test.ts new file mode 100644 index 00000000000..a2c2c5eec26 --- /dev/null +++ b/packages/ui/src/mosaic/components/scroll-area/scroll-area.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; + +import { scrollbarVars, scrollFadeVars } from '../../tokens.stylex'; +import { scrollAreaRoot, scrollAreaViewport } from './scroll-area.styles'; +import { scrollAreaVars } from './scroll-area.vars.stylex'; + +describe('Mosaic scroll area styles', () => { + it('composes the viewport atoms into one spreadable set', () => { + expect(scrollAreaViewport()).toHaveLength(5); + expect(scrollAreaRoot).toBeDefined(); + }); + + // The atoms carry the gutter, so the two have to be distinguishable — an accidental + // collapse would silently give every scroll surface the same overflow behaviour. + it('varies the gutter atom by argument', () => { + expect(scrollAreaViewport('stable')).not.toEqual(scrollAreaViewport('auto')); + }); + + it('defaults the gutter to auto', () => { + expect(scrollAreaViewport()).toEqual(scrollAreaViewport('auto')); + }); + + // The `--cl-*` names are the public API — a consumer's stylesheet references them by hand, + // and `clerk-js` ships to apps pinned to older SDKs, so renaming one breaks themes already + // in the wild. Assert the exact strings so a rename has to be a deliberate act. + // + // `toMatchObject`, not `toEqual`: StyleX adds an internal `__varGroupHash__` key, and adding + // a var is not itself breaking — removing or renaming one is. + it('emits the documented per-element progress properties', () => { + expect(scrollAreaVars).toMatchObject({ + '--cl-scroll-area-progress-start': 'var(--cl-scroll-area-progress-start)', + '--cl-scroll-area-progress-end': 'var(--cl-scroll-area-progress-end)', + }); + }); + + it('reads the shared scroll tokens', () => { + expect(scrollbarVars).toMatchObject({ '--cl-scrollbar-width': 'var(--cl-scrollbar-width)' }); + expect(scrollFadeVars).toMatchObject({ + '--cl-scroll-fade-size': 'var(--cl-scroll-fade-size)', + '--cl-scroll-fade-range': 'var(--cl-scroll-fade-range)', + '--cl-scroll-fade-inset': 'var(--cl-scroll-fade-inset)', + }); + }); +}); diff --git a/packages/ui/src/mosaic/components/scroll-area/scroll-area.test.tsx b/packages/ui/src/mosaic/components/scroll-area/scroll-area.test.tsx deleted file mode 100644 index d43c6585a4f..00000000000 --- a/packages/ui/src/mosaic/components/scroll-area/scroll-area.test.tsx +++ /dev/null @@ -1,190 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import React from 'react'; -import { describe, expect, it } from 'vitest'; - -import { scrollbarVars, scrollFadeVars } from '../../tokens.stylex'; -import { ScrollArea } from './scroll-area'; -import { scrollAreaVars } from './scroll-area.vars.stylex'; - -describe('Mosaic ScrollArea', () => { - it('renders its children inside the viewport', () => { - render( - - Contents - , - ); - expect(screen.getByText('Contents')).toBeInTheDocument(); - }); - - it('carries the stable slot classes', () => { - render( - - Contents - , - ); - expect(screen.getByTestId('root')).toHaveClass('cl-scroll-area-root'); - expect(screen.getByTestId('viewport')).toHaveClass('cl-scroll-area-viewport'); - }); - - it('defaults to the auto gutter so a non-resizing list keeps the full width', () => { - render(Contents); - expect(screen.getByTestId('viewport')).toHaveAttribute('data-gutter', 'auto'); - }); - - it.each(['stable', 'auto'] as const)('reflects the %s gutter', gutter => { - render( - - Contents - , - ); - expect(screen.getByTestId('viewport')).toHaveAttribute('data-gutter', gutter); - }); - - it('lets the consumer className and style win', () => { - render( - - Contents - , - ); - const viewport = screen.getByTestId('viewport'); - expect(viewport).toHaveClass('cl-scroll-area-viewport', 'my-scroller'); - expect(viewport).toHaveStyle({ maxHeight: '240px' }); - }); - - it('forwards arbitrary div props and the ref on both parts', () => { - const rootRef = React.createRef(); - const viewportRef = React.createRef(); - render( - - - Contents - - , - ); - expect(rootRef.current).toBe(screen.getByTestId('root')); - const viewport = screen.getByTestId('viewport'); - expect(viewportRef.current).toBe(viewport); - expect(viewport).toHaveAttribute('tabindex', '0'); - expect(viewport).toHaveAttribute('aria-label', 'Members'); - }); - - // The `--cl-*` names are the component's public API — a consumer's stylesheet references them - // by hand, and `clerk-js` ships to apps pinned to older SDKs, so renaming one breaks themes - // already in the wild. Assert the exact strings so a rename has to be a deliberate act. - it('emits the documented per-element progress properties', () => { - // `toMatchObject`, not `toEqual`: StyleX adds an internal `__varGroupHash__` key, and adding - // a new var is not itself a breaking change — removing or renaming one is. - expect(scrollAreaVars).toMatchObject({ - '--cl-scroll-area-progress-start': 'var(--cl-scroll-area-progress-start)', - '--cl-scroll-area-progress-end': 'var(--cl-scroll-area-progress-end)', - }); - }); - - // Shared across every scrolling surface in Mosaic rather than owned here, but the viewport - // reads them, so a rename would silently drop the scrollbar sizing or the fade's knobs. - it('reads the shared scroll tokens', () => { - expect(scrollbarVars).toMatchObject({ '--cl-scrollbar-width': 'var(--cl-scrollbar-width)' }); - expect(scrollFadeVars).toMatchObject({ - '--cl-scroll-fade-size': 'var(--cl-scroll-fade-size)', - '--cl-scroll-fade-range': 'var(--cl-scroll-fade-range)', - '--cl-scroll-fade-inset': 'var(--cl-scroll-fade-inset)', - }); - }); - - it('renders custom elements via render, keeping the styling contract', () => { - render( - } - > - } - > -
  • Ada Lovelace
  • -
    -
    , - ); - const root = screen.getByTestId('root'); - const viewport = screen.getByTestId('viewport'); - expect(root.tagName).toBe('SECTION'); - expect(root).toHaveClass('cl-scroll-area-root'); - expect(viewport.tagName).toBe('UL'); - expect(viewport).toHaveClass('cl-scroll-area-viewport'); - expect(viewport).toHaveAttribute('data-gutter', 'auto'); - }); - - describe('keyboard reachability', () => { - // jsdom reports every box as zero-sized, so overflow has to be faked. Both values are - // stubbed together because the check is a comparison, not a threshold. - const setOverflow = (element: HTMLElement, overflowing: boolean) => { - Object.defineProperty(element, 'scrollHeight', { configurable: true, value: overflowing ? 400 : 100 }); - Object.defineProperty(element, 'clientHeight', { configurable: true, value: 100 }); - }; - - // The element has to overflow before the effect's first sync runs, so the stubs are - // installed from the callback ref rather than after render. - const renderViewport = (overflowing: boolean, children: React.ReactNode) => - render( - { - if (element) { - setOverflow(element, overflowing); - } - }} - > - {children} - , - ); - - it('takes a tab stop when it overflows and holds nothing focusable', () => { - renderViewport(true,

    Contents

    ); - expect(screen.getByTestId('viewport')).toHaveAttribute('tabindex', '0'); - }); - - it('takes no tab stop when there is nothing to scroll', () => { - renderViewport(false,

    Contents

    ); - expect(screen.getByTestId('viewport')).not.toHaveAttribute('tabindex'); - }); - - // Tabbing into the content already scrolls the region, so a stop on the container would be - // a redundant one. Chrome and Firefox make the same exclusion. - it('takes no tab stop when its content is already reachable', () => { - renderViewport(true, ); - expect(screen.getByTestId('viewport')).not.toHaveAttribute('tabindex'); - }); - - it('lets an explicit tabIndex win over the managed one', () => { - render( - { - if (element) { - setOverflow(element, true); - } - }} - > -

    Contents

    -
    , - ); - expect(screen.getByTestId('viewport')).toHaveAttribute('tabindex', '-1'); - }); - }); -}); diff --git a/packages/ui/src/mosaic/components/scroll-area/scroll-area.tsx b/packages/ui/src/mosaic/components/scroll-area/scroll-area.tsx deleted file mode 100644 index 5692db55b66..00000000000 --- a/packages/ui/src/mosaic/components/scroll-area/scroll-area.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { useRender } from '@clerk/headless/utils'; -import * as stylex from '@stylexjs/stylex'; -import React from 'react'; - -import type { MosaicComponentProps } from '../../props'; -import { mergeStyleProps, themeProps } from '../../props'; -import { gutters, styles } from './scroll-area.styles'; -import { useScrollerFocusable } from './use-scroller-focusable'; - -export type ScrollAreaGutter = 'stable' | 'auto'; - -export type ScrollAreaRootProps = MosaicComponentProps<'div'>; - -export interface ScrollAreaViewportProps extends MosaicComponentProps<'div'> { - /** - * Whether the scrollbar's space is held open. `auto` (the default) takes the space only - * while the content overflows. Pass `stable` when the content can change height **in - * place** — a filterable or paginated collection — so that crossing the overflow threshold - * doesn't shift the rows sideways; the cost is a permanently reserved gutter next to a list - * that may never scroll. - * - * Neither value does anything on platforms that overlay their scrollbars, which reserve no - * space either way. - * - * The scrollbar's *size* is not a prop — it's the `--cl-scrollbar-width` theme token, so - * every scrolling surface in Mosaic changes together. - */ - gutter?: ScrollAreaGutter; -} - -/** - * The wrapper. Positioned, so a future scrollbar part can be placed against it; today it - * only establishes the box the viewport flexes inside. Renders a `div`; `render` swaps in - * another element. - */ -const Root = React.forwardRef(function ScrollAreaRoot( - { render, className, style, ...rest }, - ref, -) { - return useRender({ - defaultTagName: 'div', - render, - ref, - props: { - ...mergeStyleProps(themeProps('scroll-area-root'), stylex.props(styles.root), className, style), - ...rest, - }, - }); -}); - -/** - * The scroll container. Owns the overflow, the scroll timelines, and the mask. Renders a - * `div`; `render` swaps in another element, which must be able to establish a scroll box — - * the overflow, mask and timelines all apply to whatever is rendered here. - */ -const Viewport = React.forwardRef(function ScrollAreaViewport( - { gutter = 'auto', tabIndex, render, className, style, ...rest }, - ref, -) { - const [node, setNode] = React.useState(null); - - // An explicit `tabIndex` always wins — a caller who has an opinion about the tab order - // shouldn't have it silently overwritten, and passing `-1` is how you opt out entirely. - const managed = tabIndex === undefined; - const needsTabStop = useScrollerFocusable(node, managed); - - return useRender({ - defaultTagName: 'div', - render, - // `useRender` merges an array of refs, so the component can observe the element without - // taking the caller's ref away from them. - ref: [ref, setNode], - props: { - tabIndex: managed ? (needsTabStop ? 0 : undefined) : tabIndex, - ...mergeStyleProps( - themeProps('scroll-area-viewport', { gutter }), - stylex.props(styles.viewport, styles.mask, styles.indicators, styles.focusRing, gutters[gutter]), - className, - style, - ), - ...rest, - }, - }); -}); - -/** - * Mosaic `ScrollArea` — a vertically scrolling region that fades its content at whichever - * edge has more to reveal. Composed via dot syntax: `ScrollArea.Root`, `ScrollArea.Viewport`. - * - * The fade is a mask driven by two scroll-driven animations, one per edge, which write - * `--cl-scroll-area-progress-start` and `--cl-scroll-area-progress-end`. It costs nothing at - * runtime — no measurement, no scroll listener — and participates in no layout, since the - * mask is paint-only and so can't shift the content the way sticky shadow elements do. The - * only JavaScript here is the tab-stop management described below. - * - * The indicators are a progressive enhancement. Without scroll-driven animation support the - * progress vars hold at 0 and the mask resolves to fully opaque, leaving a plain scroll area. - * - * @example - * - * {items} - * - * - * @example - * // Hold the scrollbar's space open, for a collection that can change height in place. - * {items} - * - * @remarks - * The viewport manages its own `tabIndex`. Chrome and Firefox make an overflowing scroller - * keyboard-focusable automatically; Safari does not, so a keyboard-only user there can't - * scroll the region at all. The viewport closes that gap by taking a tab stop exactly when - * the browsers themselves would: when it overflows **and** its content contains nothing - * focusable. A list of buttons or links is already reachable, so a stop on the container - * would only add noise. Pass an explicit `tabIndex` to take the decision back — `-1` opts - * out completely. - */ -export const ScrollArea = { - Root, - Viewport, -}; diff --git a/packages/ui/src/mosaic/components/scroll-area/use-scroller-focusable.ts b/packages/ui/src/mosaic/components/scroll-area/use-scroller-focusable.ts deleted file mode 100644 index 517e928f604..00000000000 --- a/packages/ui/src/mosaic/components/scroll-area/use-scroller-focusable.ts +++ /dev/null @@ -1,89 +0,0 @@ -import React from 'react'; - -// What the browsers themselves count as keyboard-reachable. Deliberately close to the -// canonical focusable-elements list rather than exhaustive — it decides whether the scroller -// needs a tab stop of its own, and a near-miss costs at most one redundant stop. -const FOCUSABLE_SELECTOR = [ - 'a[href]', - 'button:not([disabled])', - 'input:not([disabled])', - 'select:not([disabled])', - 'textarea:not([disabled])', - 'audio[controls]', - 'video[controls]', - 'details > summary', - '[contenteditable]:not([contenteditable="false"])', - '[tabindex]:not([tabindex="-1"])', -].join(','); - -/** - * Whether a scroll container needs a tab stop of its own. - * - * Chrome and Firefox make an overflowing scroller keyboard-focusable automatically; Safari - * does not, so a keyboard-only user there cannot scroll the region at all (WCAG 2.1.1). This - * reproduces the browsers' rule so the gap closes without the caller having to know about it. - * - * The rule is deliberately two-part: **overflowing AND containing nothing focusable.** A - * scroller whose rows are buttons or links is already reachable — tabbing into the content - * scrolls it — so a stop on the container would be pure noise. Chrome and Firefox make the - * same exclusion, which means applying this everywhere (rather than sniffing for Safari) - * matches what those browsers would have done on their own. - * - * Both halves are observed, not sampled once: content can grow past the threshold, and rows - * can gain or lose interactivity, long after mount. - */ -export function useScrollerFocusable(node: HTMLElement | null, enabled: boolean): boolean { - const [focusable, setFocusable] = React.useState(false); - - React.useEffect(() => { - if (!enabled || !node) { - setFocusable(false); - return; - } - - let observedChildren: Element[] = []; - - const sync = () => { - const overflows = node.scrollHeight > node.clientHeight; - const contentIsReachable = node.querySelector(FOCUSABLE_SELECTOR) !== null; - setFocusable(overflows && !contentIsReachable); - }; - - const resizeObserver = new ResizeObserver(sync); - resizeObserver.observe(node); - - // The scroller's own box resizing is only half of it — content growing past the threshold - // leaves the scroller's box untouched, and a `MutationObserver` won't catch a purely - // visual change like an image loading. Observing the children covers that. - const observeChildren = () => { - for (const child of observedChildren) { - resizeObserver.unobserve(child); - } - observedChildren = Array.from(node.children); - for (const child of observedChildren) { - resizeObserver.observe(child); - } - }; - - const mutationObserver = new MutationObserver(() => { - observeChildren(); - sync(); - }); - mutationObserver.observe(node, { - childList: true, - subtree: true, - attributes: true, - attributeFilter: ['contenteditable', 'controls', 'disabled', 'href', 'tabindex'], - }); - - observeChildren(); - sync(); - - return () => { - resizeObserver.disconnect(); - mutationObserver.disconnect(); - }; - }, [node, enabled]); - - return focusable; -} diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index 40145ee1e8d..826944639b8 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -28,8 +28,8 @@ export type { MenuSeparatorProps, MenuTriggerProps, } from '../components/menu'; -export { ScrollArea } from '../components/scroll-area'; -export type { ScrollAreaGutter, ScrollAreaRootProps, ScrollAreaViewportProps } from '../components/scroll-area'; +export { scrollAreaRoot, scrollAreaVars, scrollAreaViewport } from '../components/scroll-area'; +export type { ScrollAreaGutter } from '../components/scroll-area'; export { Text, TextContext } from '../components/text'; export type { TextProps } from '../components/text'; From 74b579346c194cbfd4d373f71932941a1433022d Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 31 Jul 2026 15:41:39 -0600 Subject: [PATCH 07/24] docs(swingset): Document the scroll surface on Item instead of its own page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dedicated ScrollArea page made sense when there was a ScrollArea component. Now that it ships as atoms, a component page for a thing that isn't a component reads as a mistake, and the atoms are only meaningful applied to something. `Item` gains a `Scrolling` example: a capped-height group of organizations with the scroll surface spread onto the `Item.Group` itself, which is the shape a real call site takes. The page absorbs what the ScrollArea docs carried that has no other home — the theme tokens, the two progress vars, the override recipe, and the two caveats worth knowing (the mask covers the scrollbar until a custom scrollbar exists, and Safari's keyboard gap is the caller's to close). Co-Authored-By: Claude Opus 5 (1M context) --- .../swingset/src/components/DocsViewer.tsx | 1 - packages/swingset/src/lib/registry.ts | 20 +- packages/swingset/src/stories/item.mdx | 84 ++++++ .../swingset/src/stories/item.stories.tsx | 59 +++++ .../src/stories/scroll-area.component.mdx | 246 ------------------ .../stories/scroll-area.component.stories.tsx | 192 -------------- 6 files changed, 145 insertions(+), 457 deletions(-) delete mode 100644 packages/swingset/src/stories/scroll-area.component.mdx delete mode 100644 packages/swingset/src/stories/scroll-area.component.stories.tsx diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 59134c90c82..d54119bbc35 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -39,7 +39,6 @@ const docModules: Record> = { icon: dynamic(() => import('../stories/icon.mdx')), menu: dynamic(() => import('../stories/menu.component.mdx')), popover: dynamic(() => import('../stories/popover.component.mdx')), - 'scroll-area': dynamic(() => import('../stories/scroll-area.component.mdx')), tabs: dynamic(() => import('../stories/tabs.component.mdx')), text: dynamic(() => import('../stories/text.mdx')), }, diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 734d787b6d9..045d609edb4 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -51,6 +51,7 @@ import { Group as ItemGroup, Interactive as ItemInteractive, meta as itemMeta, + Scrolling as ItemScrolling, } from '../stories/item.stories'; import { Default as MenuComponentDefault, meta as menuComponentMeta } from '../stories/menu.component.stories'; import { meta as menuMeta } from '../stories/menu.stories'; @@ -94,14 +95,6 @@ import { Placement as PopoverComponentPlacement, } from '../stories/popover.component.stories'; import { meta as popoverMeta } from '../stories/popover.stories'; -import { - CustomIndicators as ScrollAreaCustomIndicators, - Default as ScrollAreaDefault, - Gutter as ScrollAreaGutter, - meta as scrollAreaMeta, - NotScrollable as ScrollAreaNotScrollable, - Tuning as ScrollAreaTuning, -} from '../stories/scroll-area.component.stories'; import { meta as selectMeta } from '../stories/select.stories'; import { Default as TabsComponentDefault, meta as tabsComponentMeta } from '../stories/tabs.component.stories'; import { meta as tabsMeta } from '../stories/tabs.stories'; @@ -177,20 +170,12 @@ const popoverComponentModule: StoryModule = { Alignment: PopoverComponentAlignment, }; -const scrollAreaModule: StoryModule = { - meta: scrollAreaMeta, - Default: ScrollAreaDefault, - NotScrollable: ScrollAreaNotScrollable, - Gutter: ScrollAreaGutter, - Tuning: ScrollAreaTuning, - CustomIndicators: ScrollAreaCustomIndicators, -}; - const itemModule: StoryModule = { meta: itemMeta, Default: ItemDefault, Interactive: ItemInteractive, Group: ItemGroup, + Scrolling: ItemScrolling, }; const headingModule: StoryModule = { @@ -256,7 +241,6 @@ export const registry: StoryModule[] = [ iconModule, menuComponentModule, popoverComponentModule, - scrollAreaModule, tabsComponentModule, textModule, // Primitives — alphabetical within the group. diff --git a/packages/swingset/src/stories/item.mdx b/packages/swingset/src/stories/item.mdx index 2aea12d0cac..346dbdfdf69 100644 --- a/packages/swingset/src/stories/item.mdx +++ b/packages/swingset/src/stories/item.mdx @@ -34,6 +34,90 @@ Set `size` once on `Item.Root` and the row scales as a unit: it fixes the row's storyModule={ItemStories} /> +### Scrolling + +A capped-height group that scrolls, fading its content at whichever edge still has something to +reveal. + + + +The scroll surface is **StyleX atoms, not a component** — everything it does is CSS, so a component +would only add a DOM node and an API to version. Spread `scrollAreaViewport()` onto the group +itself; it keeps its own `.cl-item-group` slot, which stays the hook a theme targets. `scrollAreaRoot` +goes on a positioned ancestor and is only needed when something has to anchor against the scroll +box — an overlay replacing the fade, for instance. + +```tsx +import { scrollAreaRoot, scrollAreaViewport } from '@clerk/ui/mosaic/components/scroll-area'; +import * as stylex from '@stylexjs/stylex'; + +
    + {organizations} +
    ; +``` + +`scrollAreaViewport()` returns an array, hence the `...` spread. Its one argument is the scrollbar +gutter: `auto` (the default, and CSS's own) takes the space only while the content overflows, while +`stable` reserves it either way — worth it when the content can change height **in place**, such as +a filterable list, so crossing the overflow threshold doesn't shift the rows sideways. + +The fade is driven by two scroll-driven animations: no scroll listener, no measurement, nothing at +runtime. It is a mask rather than a sticky overlay element, so it is paint-only and cannot shift the +content. A group with nothing to scroll shows no indicators, and browsers without scroll-driven +animation support get a plain scrolling group rather than a broken one. + +#### Theming the fade + +Four tokens apply to every scrolling surface in Mosaic, so setting them once retunes all of them: + +| Token | Default | Description | +| ------------------------ | -------- | ----------------------------------------------------------- | +| `--cl-scroll-fade-size` | `1.5rem` | Height of the fade band. | +| `--cl-scroll-fade-range` | `1.5rem` | How far you scroll before the fade reaches full strength. | +| `--cl-scroll-fade-inset` | `0px` | Width at the inline end the fade is held back from. | +| `--cl-scrollbar-width` | `thin` | `auto`, `thin`, or `none` — keyword-only, per the CSS spec. | + +`--cl-scroll-fade-inset` defaults to `0px` because **CSS cannot measure a scrollbar**: the width +differs per platform and browser, and on macOS it changes at runtime when a mouse is connected. Set +it only when you know the platform you're targeting. Until then the mask covers the scrollbar, which +is the one thing this approach can't solve without a custom scrollbar. + +To replace the fade entirely, retire it with `mask-image: none` and read the two per-element vars +the animations write — `--cl-scroll-area-progress-start` and `--cl-scroll-area-progress-end`, each +describing how much that edge still has to reveal. They live on the element carrying the atoms and +inherit downward. + +```css +.cl-item-group { + mask-image: none; +} +.cl-item-group::before { + content: ''; + position: absolute; + inset: 0 0 auto; + height: 2rem; + pointer-events: none; + background: linear-gradient(to bottom, color-mix(in oklab, var(--cl-color-card-foreground) 28%, transparent), transparent); + opacity: var(--cl-scroll-area-progress-start); +} +``` + +Position such overlays absolutely rather than with `position: sticky` — a sticky pseudo-element +takes space in the scroll flow, which is the layout shift the mask approach avoids. And mix the +scrim from a theme color rather than hardcoding black, which would darken a dark surface and look +identical to the mask it replaced. + +Chrome and Firefox make an overflowing scroll container keyboard-focusable on their own; **Safari +does not** (WCAG 2.1.1). `tabindex` isn't a style, so the atoms can't close that gap — set +`tabIndex={0}` yourself on a scroll surface that holds nothing focusable. A group of interactive +rows, like the one above, needs nothing: tabbing into the content already scrolls it. + ## Usage ```tsx diff --git a/packages/swingset/src/stories/item.stories.tsx b/packages/swingset/src/stories/item.stories.tsx index 4c63b08424d..cc8074a94a5 100644 --- a/packages/swingset/src/stories/item.stories.tsx +++ b/packages/swingset/src/stories/item.stories.tsx @@ -2,6 +2,8 @@ import { Avatar } from '@clerk/ui/mosaic/components/avatar'; import { Button } from '@clerk/ui/mosaic/components/button'; import { Item } from '@clerk/ui/mosaic/components/item'; +import { scrollAreaRoot, scrollAreaViewport } from '@clerk/ui/mosaic/components/scroll-area'; +import * as stylex from '@stylexjs/stylex'; import * as React from 'react'; import type { StoryMeta } from '@/lib/types'; @@ -332,3 +334,60 @@ export function Group() {
); } + +const organizations = [ + 'Acme Corporation', + 'Globex', + 'Initech', + 'Umbrella Health', + 'Stark Industries', + 'Wayne Enterprises', + 'Cyberdyne Systems', + 'Soylent Industries', + 'Tyrell Corporation', + 'Weyland-Yutani', +]; + +// A capped-height group that scrolls, with fade indicators at whichever edge still has +// something to reveal. The scroll surface is StyleX atoms rather than a component, so it goes +// straight onto the `Item.Group` — no wrapper element, and the group keeps its `.cl-item-group` +// slot, which stays the hook a theme targets. The outer box only exists to cap the height and +// to give overlays something to anchor to. +export function Scrolling() { + return ( +
+ + {organizations.map(name => ( + ( + + )} + > + + + {name[0]} + + + + {name} + {organizations.indexOf(name) + 3} members + + + ))} + +
+ ); +} diff --git a/packages/swingset/src/stories/scroll-area.component.mdx b/packages/swingset/src/stories/scroll-area.component.mdx deleted file mode 100644 index 842aa7d83de..00000000000 --- a/packages/swingset/src/stories/scroll-area.component.mdx +++ /dev/null @@ -1,246 +0,0 @@ -import * as ScrollAreaStories from './scroll-area.component.stories'; - -# ScrollArea - -A scrolling region that fades its content at whichever edge still has something to reveal, so the -boundary of a list reads as "there's more" rather than as a hard cut. - -It ships as **StyleX atoms, not a component**. Everything it does is CSS, so a component would only -add a DOM node and an API to version. Spread the atoms onto an element you already render — an -`Item.Group`, a list, a panel body — and that element keeps its own slot class, which stays the hook -a theme targets. - -Two scroll-driven animations write a progress var per edge, and a mask reads them: no scroll -listener, no measurement, nothing at runtime. Because the fade is a mask rather than a sticky -overlay element it is paint-only and **cannot shift the content**, unlike the classic -sticky-pseudo-element approach which takes space in the scroll flow. - -## Example - - - -## Usage - -`scrollAreaViewport()` returns the atoms for the element that scrolls; spread them. -`scrollAreaRoot` goes on a positioned ancestor, and is only needed when something has to anchor -against the scroll box — an overlay replacing the mask, for instance. A surface whose parent is -already positioned can skip it. - -```tsx -import { Item } from '@clerk/ui/mosaic/components/item'; -import { scrollAreaRoot, scrollAreaViewport } from '@clerk/ui/mosaic/components/scroll-area'; -import * as stylex from '@stylexjs/stylex'; - -
- {rows} -
; -``` - -Note the spread: `scrollAreaViewport()` returns an array, so it goes into `stylex.props` with `...`. -Consumer styles still win by being passed after it. - -### API - -| Export | Type | Description | -| ----------------------------- | ------------------------------------------ | ------------------------------------------------ | -| `scrollAreaViewport(gutter?)` | `(gutter?: 'auto' \| 'stable') => atoms[]` | The scroll surface. `gutter` defaults to `auto`. | -| `scrollAreaRoot` | `atom` | Positioned ancestor, for anchoring overlays. | - -### Nothing to scroll - -When the content fits, both scroll timelines are inactive, both progress vars hold at their -registered `initial-value: 0`, and the mask resolves to fully opaque. No indicators appear, and -nothing had to detect that — the resting state is already the hidden one. - - - -### Gutter - -The `gutter` argument to `scrollAreaViewport()` decides whether the scrollbar's space is held open. - -`auto` (the default, and CSS's own) takes the space only while the content overflows. `stable` -reserves it either way, so a collection that crosses the overflow threshold doesn't shift its rows -sideways. - - - -Two conditions have to hold before the values differ at all, which is why the difference is easy to -miss: - -1. **The scrollbars have to be space-consuming.** Windows and Linux always are; macOS only is with a - mouse connected, or with **System Settings → Appearance → Show scroll bars → Always**. Overlay - scrollbars are painted over the content and reserve nothing, so there is no gutter for either - value to hold open. -2. **The content has to be able to stop overflowing.** `auto` reserves space whenever a scrollbar is - actually present, so with permanently-overflowing content the two are identical. The divergence - only appears when the content fits: `stable` keeps the gutter, `auto` gives it back. - -Both conditions matter, and the second is the one that usually explains an apparently broken demo — -hence the toggle above. On a space-consuming platform, crossing the threshold makes the `auto` -column's rows jump sideways while `stable` holds still. - -**Reach for `stable` only when the content can change height in place** — a filterable list, a -paginated table, anything that gains or loses rows without navigating away. There the shift fires -mid-interaction, while the user is typing in a search box, and reads as a bug. Everywhere else it is -pure cost: a permanently reserved gutter beside a list that may never scroll. - -Neither value helps with the macOS mode switch itself. `scrollbar-gutter` is a no-op while -scrollbars overlay, so connecting a mouse narrows the content the first time a real scrollbar -appears no matter which you pick. - -The scrollbar's **size** is not a prop — see `--cl-scrollbar-width` below. - -### Tuning the fade - - - -## Styling - -The atoms bring no class of their own — the element you applied them to keeps its existing -`.cl-` class, and that is what a theme targets. In the examples here the styles ride on an -`Item.Group`, so every override below is written against `.cl-item-group`. - -### Variables - -Two of them are **read-only per-element state**, written by the scroll-driven animations. They live -on the viewport and inherit downward, so anything reading them has to be the viewport or a -descendant — not the root. Setting them yourself does nothing; the animations overwrite them. - -| Variable | Range | Description | -| --------------------------------- | ----- | --------------------------------------------- | -| `--cl-scroll-area-progress-start` | 0 → 1 | How much the top edge has to reveal. | -| `--cl-scroll-area-progress-end` | 1 → 0 | How much the bottom edge still has to reveal. | - -They are registered with `@property` so they interpolate — an unregistered custom property animates -discretely and would snap halfway through the scroll instead of tracking it. - -The knobs you actually set are **theme tokens, not component variables**, because how soft the edge -of a scrolling region is belongs to the design language rather than to one component. Set them once -and every scrolling surface follows; a component that needs different values sets the token on -itself. - -| Token | Default | Description | -| ------------------------ | -------- | --------------------------------------------------------- | -| `--cl-scroll-fade-size` | `1.5rem` | Height of the fade band. | -| `--cl-scroll-fade-range` | `1.5rem` | How far you scroll before the fade reaches full strength. | -| `--cl-scroll-fade-inset` | `0px` | Width at the inline end the fade is held back from. | -| `--cl-scrollbar-width` | `thin` | `auto`, `thin`, or `none`. Keyword-only — see below. | - -```css -:root { - --cl-scroll-fade-size: 2.5rem; - --cl-scroll-fade-range: 2.5rem; -} -``` - -`size` and `range` default to the same value on purpose: the fade reaches full strength after you -have scrolled its own height, so it grows in at the rate the content moves. They stay independent — -a shorter range makes the fade snap in sooner without changing how tall it ends up. - -`--cl-scrollbar-width` is **keyword-only, by spec**. `scrollbar-width` accepts `auto | thin | none` -and _not_ a length, so there is no `8px` value for it. A real pixel width exists only through -`::-webkit-scrollbar`, which Firefox ignores outright and which Chrome 121+ discards as soon as -`scrollbar-color` is set — exposing it as a length would be a promise the platform can't keep. - -`thin` rather than the platform default because these regions are compact panels where a ~17px bar -reads heavy. The tradeoff is a smaller drag target on the platforms whose scrollbars are draggable -at all; take it back with `--cl-scrollbar-width: auto`. - -### Replacing the indicators - -The treatment is a theme decision, so swapping it needs no prop and no JavaScript. Set -`mask-image: none` to retire the default fade and read the progress vars to drive whatever replaces -it. - - - -```css -@import '@clerk/ui/styles.css' layer(components); - -.cl-item-group { - mask-image: none; -} -.cl-item-group::before { - content: ''; - position: absolute; - inset: 0 0 auto; - height: 2rem; - pointer-events: none; - background: linear-gradient(to bottom, color-mix(in oklab, var(--cl-color-card-foreground) 28%, transparent), transparent); - opacity: var(--cl-scroll-area-progress-start); -} -``` - -Position such overlays absolutely rather than with `position: sticky` — a sticky pseudo-element -participates in the scroll flow and takes space from the content, which is the layout shift the mask -approach avoids in the first place. - -Mix the scrim from a theme color rather than hardcoding black. A black scrim darkens a dark surface, -which looks identical to the mask it was meant to replace, so the indicator silently stops reading -as one in dark mode. `--cl-color-card-foreground` is `light-dark()`-backed and inverts to near-white -on a dark surface, so the same rule gives a shadow in light mode and a glow in dark. - -### The fade inset - -`--cl-scroll-fade-inset` holds the fade back from a strip at the inline end, so a space-consuming -scrollbar isn't faded along with the content. - -It defaults to `0px` because **CSS cannot measure a scrollbar**. The width differs per platform and -per browser, `--cl-scrollbar-width: thin` changes it again, and on macOS it changes at runtime when -a mouse is connected. Any non-zero default would be wrong more often than right — and where the -scrollbar overlays, which is the common case, there is nothing to hold back from. Set it only when -you know the platform you're targeting. - -```css -:root { - --cl-scroll-fade-inset: 15px; -} -``` - -### Browser support - -The indicators are a progressive enhancement. Without support for scroll-driven animations the -progress vars hold at 0, the mask resolves to fully opaque, and the result is a plain scroll area — -not a broken one. The `animation-name` is gated behind `@supports (animation-timeline: scroll())` -for exactly this reason: an ungated animation would run on the document timeline at the default `0s` -duration and paint both fades permanently. - -### Keyboard access - -Chrome and Firefox make an overflowing scroll container keyboard-focusable on their own. **Safari -does not**, so a keyboard-only user there can't scroll the region at all (WCAG 2.1.1). - -CSS can't express this — `tabindex` isn't a style — so the atoms can't close it for you. Where a -scroll surface holds nothing focusable, set `tabIndex={0}` on it yourself: - -```tsx -
- {prose} -
-``` - -A surface whose rows are buttons or links needs nothing: tabbing into the content already scrolls -it, which is why Chrome and Firefox skip those too. diff --git a/packages/swingset/src/stories/scroll-area.component.stories.tsx b/packages/swingset/src/stories/scroll-area.component.stories.tsx deleted file mode 100644 index 8bcb78a5675..00000000000 --- a/packages/swingset/src/stories/scroll-area.component.stories.tsx +++ /dev/null @@ -1,192 +0,0 @@ -/** @jsxImportSource @emotion/react */ -import { Avatar } from '@clerk/ui/mosaic/components/avatar'; -import { Item } from '@clerk/ui/mosaic/components/item'; -import { scrollAreaRoot, scrollAreaViewport } from '@clerk/ui/mosaic/components/scroll-area'; -import { Text } from '@clerk/ui/mosaic/components/text'; -import * as stylex from '@stylexjs/stylex'; -import React from 'react'; - -import type { StoryMeta } from '@/lib/types'; - -// Exposes this file's own source (via the `?raw` webpack rule) so each `` example -// renders a code footer with its function's source. See `StoryModule.__source`. -export { default as __source } from './scroll-area.component.stories?raw'; - -export const meta: StoryMeta = { - group: 'Components', - title: 'ScrollArea', - source: 'packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts', - styleEngine: 'stylex', -}; - -const members = [ - 'Ada Lovelace', - 'Grace Hopper', - 'Katherine Johnson', - 'Margaret Hamilton', - 'Radia Perlman', - 'Barbara Liskov', - 'Frances Allen', - 'Jean Bartik', -]; - -const rows = (names: string[] = members) => - names.map(name => ( - - - - {name[0]} - - - - {name} - - - )); - -// The scroll surface goes straight onto the `Item.Group` that already scrolls — no wrapper -// element, and the group keeps its own `.cl-item-group` slot, which stays the hook a theme -// targets. `scrollAreaRoot` is on the outer box only so overlays have something to anchor to; -// a group whose parent is already positioned doesn't need it. -export function Default() { - return ( -
- {rows()} -
- ); -} - -// Nothing is scrollable here, so both scroll timelines are inactive, both progress vars stay -// at their registered `initial-value: 0`, and the mask resolves to fully opaque. The absent -// indicators are the resting state rather than something switched off. -export function NotScrollable() { - return ( -
- {rows(members.slice(0, 2))} -
- ); -} - -// The two values only diverge when the content DOESN'T overflow: `scrollbar-gutter: auto` -// reserves space whenever a scrollbar is actually present, so with overflowing content both -// look the same. Toggling across the threshold is the whole demo — watch the `auto` column's -// rows jump sideways as its scrollbar comes and goes while `stable` holds still. -// -// Requires space-consuming scrollbars to show anything at all: Windows and Linux always, macOS -// only with a mouse connected or "Show scroll bars: Always" set. Overlay scrollbars reserve no -// space, so there is no gutter for either value to hold open. -export function Gutter() { - const [overflowing, setOverflowing] = React.useState(true); - const content = overflowing ? rows() : rows(members.slice(0, 2)); - - return ( -
- -
-
-
- {content} -
- stable — rows never move -
-
-
- {content} -
- auto — rows widen when the scrollbar goes -
-
-
- ); -} - -// Theme tokens rather than component variables, so they can be set anywhere in the cascade — -// on the element, on a wrapper, or once at `:root` to retune every scrolling surface in Mosaic -// at the same time. Scoped to a wrapper class here so the demo doesn't retheme the page. -export function Tuning() { - return ( - <> - -
- {rows()} -
- - ); -} - -// The indicators are a theme decision, so swapping the mask for something else needs no -// JavaScript — just CSS. `mask-image: none` retires the default treatment and the two progress -// vars stay readable for whatever replaces it. -// -// Three things worth copying. The overlays hang off the element the atoms were applied to, -// because that is what the animations write the vars onto — here `.cl-item-group`, since the -// styles ride on a slot that already exists rather than a wrapper of their own. They are -// absolutely positioned rather than sticky, so they overlay the content instead of taking space -// in the scroll flow. And the scrim is mixed from a theme token rather than hardcoded black, -// which would darken a dark surface and be indistinguishable from the mask it replaced. -export function CustomIndicators() { - return ( - <> - -
- {rows()} -
- - ); -} From 0c7798704db35c84149976e54d10c889ef8759eb Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 31 Jul 2026 15:49:47 -0600 Subject: [PATCH 08/24] fix(ui): Only style the scrollbar under a fine pointer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A touch platform draws an overlay scrollbar there is no width or colour to apply to, and thinning a target that is already hard to hit would be actively worse. Gating appearance on `@media (pointer: fine)` leaves those platforms with the bar they already draw. Shopify's `s-scroll-box` gates the same way. `scrollbar-gutter` stays ungated: reserving space is a layout decision about the surrounding content, not an appearance one, and the value that is right for a list that can change height doesn't change with the pointer. Forced-colors still wins inside the gate — StyleX nests the two at-rules and triples the class, so the system scrollbar comes back where it has to. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/mosaic-scroll-area.md | 2 +- packages/swingset/src/stories/item.mdx | 4 ++++ .../scroll-area/scroll-area.styles.ts | 17 ++++++++++++----- packages/ui/src/mosaic/tokens.stylex.ts | 4 ++++ 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/.changeset/mosaic-scroll-area.md b/.changeset/mosaic-scroll-area.md index 39c15cce878..5e416c4ffe3 100644 --- a/.changeset/mosaic-scroll-area.md +++ b/.changeset/mosaic-scroll-area.md @@ -16,4 +16,4 @@ The indicators are driven by scroll-driven animations — no scroll listener and The treatment is replaceable in plain CSS. Set `mask-image: none` on the element carrying the atoms to retire the default fade, and read `--cl-scroll-area-progress-start` / `--cl-scroll-area-progress-end` — per-element values the animations write, describing how much each edge still has to reveal — to drive a shadow or any other indicator. -Also adds four theme tokens that apply to every scrolling surface in Mosaic rather than to one component: `--cl-scroll-fade-size` and `--cl-scroll-fade-range` (both `1.5rem`) tune the fade's height and how far you scroll before it reaches full strength, `--cl-scroll-fade-inset` (`0px`) holds the fade back from a space-consuming scrollbar, and `--cl-scrollbar-width` (`thin`) sets the scrollbar size. Per the CSS spec that last one is keyword-only (`auto`, `thin`, or `none`) — `scrollbar-width` does not accept a length. +Also adds four theme tokens that apply to every scrolling surface in Mosaic rather than to one component: `--cl-scroll-fade-size` and `--cl-scroll-fade-range` (both `1.5rem`) tune the fade's height and how far you scroll before it reaches full strength, `--cl-scroll-fade-inset` (`0px`) holds the fade back from a space-consuming scrollbar, and `--cl-scrollbar-width` (`thin`) sets the scrollbar size, applied only under `@media (pointer: fine)` so touch platforms keep the native overlay bar. Per the CSS spec that last one is keyword-only (`auto`, `thin`, or `none`) — `scrollbar-width` does not accept a length. diff --git a/packages/swingset/src/stories/item.mdx b/packages/swingset/src/stories/item.mdx index 346dbdfdf69..0ae71db3855 100644 --- a/packages/swingset/src/stories/item.mdx +++ b/packages/swingset/src/stories/item.mdx @@ -83,6 +83,10 @@ Four tokens apply to every scrolling surface in Mosaic, so setting them once ret | `--cl-scroll-fade-inset` | `0px` | Width at the inline end the fade is held back from. | | `--cl-scrollbar-width` | `thin` | `auto`, `thin`, or `none` — keyword-only, per the CSS spec. | +Scrollbar appearance — colour and width — only applies under `@media (pointer: fine)`, so touch +platforms keep the native overlay bar they already draw. The gutter is not gated: it is a layout +decision rather than an appearance one. + `--cl-scroll-fade-inset` defaults to `0px` because **CSS cannot measure a scrollbar**: the width differs per platform and browser, and on macOS it changes at runtime when a mouse is connected. Set it only when you know the platform you're targeting. Until then the mask covers the scrollbar, which diff --git a/packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts b/packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts index 2a05e704a7b..f506d5263e7 100644 --- a/packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts +++ b/packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts @@ -66,13 +66,20 @@ const styles = stylex.create({ flexBasis: 'auto', flexGrow: 1, flexShrink: 1, + // Appearance is gated on a fine pointer so touch platforms keep the native overlay bar + // they already draw — there is no scrollbar there for a colour or a width to apply to, and + // a thin one would only shrink a target that is already hard to hit. `scrollbar-gutter` + // stays ungated below: it is a layout decision, not an appearance one. scrollbarColor: { - default: `${colorVars['--cl-color-neutral-faded']} transparent`, - // Forced-colors users get the system scrollbar; a themed one loses its contrast - // guarantee against a palette we no longer control. - '@media (forced-colors: active)': 'auto', + default: null, + '@media (pointer: fine)': { + default: `${colorVars['--cl-color-neutral-faded']} transparent`, + // Forced-colors users get the system scrollbar; a themed one loses its contrast + // guarantee against a palette we no longer control. + '@media (forced-colors: active)': 'auto', + }, }, - scrollbarWidth: scrollbarVars['--cl-scrollbar-width'], + scrollbarWidth: { default: null, '@media (pointer: fine)': scrollbarVars['--cl-scrollbar-width'] }, minHeight: 0, overflowX: 'hidden', overflowY: 'auto', diff --git a/packages/ui/src/mosaic/tokens.stylex.ts b/packages/ui/src/mosaic/tokens.stylex.ts index c2b726ea5a3..560e4f77bbc 100644 --- a/packages/ui/src/mosaic/tokens.stylex.ts +++ b/packages/ui/src/mosaic/tokens.stylex.ts @@ -100,6 +100,10 @@ export const targetVars = stylex.defineVars(targetDefaults); // anything is scrolling. The tradeoff is a smaller drag target on the platforms whose // scrollbars are draggable at all — set `auto` to take it back. // +// Only applied under `@media (pointer: fine)`. A touch platform draws an overlay bar there is +// no width to apply to, and thinning a target that is already hard to hit would be actively +// worse — Polaris's `s-scroll-box` gates its scrollbar styling the same way. +// // Keyword-only, by the CSS spec: `scrollbar-width` accepts `auto | thin | none` and NOT a // length. A real pixel width exists only via `::-webkit-scrollbar`, which Firefox ignores // and which Chrome 121+ discards once `scrollbar-color` is set — so there is no honest way From 0756b4b8f2a13624455df4dfc34865725aa6c11b Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 31 Jul 2026 16:49:18 -0600 Subject: [PATCH 09/24] feat(ui): Paint the Mosaic scrollbar via ::-webkit-scrollbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces `scrollbar-color` / `scrollbar-width` with the webkit pseudo-elements, which is the only path that can express a per-interaction-state thumb colour and a real pixel width. The browser derived the hover state from `scrollbar-color` and got it backwards, lightening the thumb on hover; there is no syntax to correct that on the standard path. The two are mutually exclusive — a non-`auto` value for either standard property makes a UA ignore the pseudo-elements — so this is one code path, not two. Firefox implements neither and keeps its platform scrollbar. `::-webkit-scrollbar-thumb` cannot transition its own properties, so the colour is routed through an `@property`-registered var on the scroller and inherited into the pseudo-element, which only reads it. `--cl-scrollbar-width` becomes a length, and `--cl-scroll-fade-inset` is deleted: now that we specify the lane's width, the mask derives its inset from it rather than exposing a second name for the same number. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/lazy-pans-tickle.md | 23 +++ packages/swingset/src/stories/item.mdx | 54 ++++-- .../scroll-area/scroll-area.styles.ts | 156 +++++++++++++++--- .../scroll-area/scroll-area.test.ts | 22 ++- .../scroll-area/scroll-area.vars.stylex.ts | 17 ++ packages/ui/src/mosaic/tokens.stylex.ts | 55 ++++-- 6 files changed, 266 insertions(+), 61 deletions(-) create mode 100644 .changeset/lazy-pans-tickle.md diff --git a/.changeset/lazy-pans-tickle.md b/.changeset/lazy-pans-tickle.md new file mode 100644 index 00000000000..d8a068c8047 --- /dev/null +++ b/.changeset/lazy-pans-tickle.md @@ -0,0 +1,23 @@ +--- +'@clerk/ui': patch +--- + +Mosaic's scrolling surfaces now paint their own scrollbar, with a thumb that darkens on hover and again while you drag it. Previously the browser derived those states from `scrollbar-color` and got them backwards, lightening the thumb on hover. + +New tokens, applied to every scrolling surface at once: + +| Token | Default | +| ----------------------------- | -------------------------- | +| `--cl-scrollbar-thumb` | `--cl-color-neutral-faded` | +| `--cl-scrollbar-thumb-hover` | derived from the above | +| `--cl-scrollbar-thumb-active` | derived from the above | +| `--cl-scrollbar-thumb-inset` | `0.1875rem` | + +Setting `--cl-scrollbar-thumb: transparent` gives a hover-reveal scrollbar: the thumb paints nothing at rest and fades in when you reach the region. The lane stays reserved either way, so nothing moves. + +Two breaking notes if you were already theming these: + +- `--cl-scrollbar-width` now takes a **length** (default `0.625rem`) rather than the `auto | thin | none` keyword. Use `0px` where you previously used `none`. +- `--cl-scroll-fade-inset` is removed. The mask now derives its inset from `--cl-scrollbar-width`, which closes the gap where the edge fade covered part of the scrollbar. + +Firefox implements neither `::-webkit-scrollbar` nor an equivalent, so it keeps its platform scrollbar; touch platforms keep their native overlay bar as before. On macOS, styling the scrollbar takes it out of overlay mode, so the bar is always visible and always occupies its lane. diff --git a/packages/swingset/src/stories/item.mdx b/packages/swingset/src/stories/item.mdx index 0ae71db3855..424ed137219 100644 --- a/packages/swingset/src/stories/item.mdx +++ b/packages/swingset/src/stories/item.mdx @@ -72,25 +72,43 @@ runtime. It is a mask rather than a sticky overlay element, so it is paint-only content. A group with nothing to scroll shows no indicators, and browsers without scroll-driven animation support get a plain scrolling group rather than a broken one. -#### Theming the fade +#### Theming the fade and the scrollbar + +These tokens apply to every scrolling surface in Mosaic, so setting them once retunes all of them: + +| Token | Default | Description | +| ----------------------------- | -------------------------- | --------------------------------------------------------- | +| `--cl-scroll-fade-size` | `1.5rem` | Height of the fade band. | +| `--cl-scroll-fade-range` | `1.5rem` | How far you scroll before the fade reaches full strength. | +| `--cl-scrollbar-width` | `0.625rem` | Width of the scrollbar lane. `0px` hides it. | +| `--cl-scrollbar-thumb-inset` | `0.1875rem` | How far the thumb's paint is held inside that lane. | +| `--cl-scrollbar-thumb` | `--cl-color-neutral-faded` | Thumb colour at rest. | +| `--cl-scrollbar-thumb-hover` | derived from the above | Thumb colour while the region or the thumb is hovered. | +| `--cl-scrollbar-thumb-active` | derived from the above | Thumb colour while the thumb is being dragged. | + +The two derived colours reference `--cl-scrollbar-thumb` rather than baking its value in, so +setting the base re-derives both — and either state can still be pinned on its own. + +Mosaic paints the scrollbar through `::-webkit-scrollbar`, which is what buys a real width and a +thumb colour per interaction state; the standard `scrollbar-color` can express neither, and setting +it would make the engines that _do_ implement the pseudo-elements ignore them. Firefox implements +neither and keeps its platform scrollbar. Everything here is gated on `@media (pointer: fine)`, so +touch platforms keep the native overlay bar they already draw. The gutter is not gated: it is a +layout decision rather than an appearance one. + +One consequence worth knowing before you theme: styling the scrollbar takes macOS out of overlay +mode, so the bar is always visible and always occupies its lane rather than auto-hiding. That is the +cross-platform consistency the tokens exist for, but it is a change from the platform default. + +For a scrollbar that appears only on hover, set the rest colour to `transparent`. Nothing else is +needed — the thumb paints nothing at rest and the existing transition fades it in when you reach the +region. The lane stays reserved either way, so this changes only what is painted, never the layout: -Four tokens apply to every scrolling surface in Mosaic, so setting them once retunes all of them: - -| Token | Default | Description | -| ------------------------ | -------- | ----------------------------------------------------------- | -| `--cl-scroll-fade-size` | `1.5rem` | Height of the fade band. | -| `--cl-scroll-fade-range` | `1.5rem` | How far you scroll before the fade reaches full strength. | -| `--cl-scroll-fade-inset` | `0px` | Width at the inline end the fade is held back from. | -| `--cl-scrollbar-width` | `thin` | `auto`, `thin`, or `none` — keyword-only, per the CSS spec. | - -Scrollbar appearance — colour and width — only applies under `@media (pointer: fine)`, so touch -platforms keep the native overlay bar they already draw. The gutter is not gated: it is a layout -decision rather than an appearance one. - -`--cl-scroll-fade-inset` defaults to `0px` because **CSS cannot measure a scrollbar**: the width -differs per platform and browser, and on macOS it changes at runtime when a mouse is connected. Set -it only when you know the platform you're targeting. Until then the mask covers the scrollbar, which -is the one thing this approach can't solve without a custom scrollbar. +```css +:root { + --cl-scrollbar-thumb: transparent; +} +``` To replace the fade entirely, retire it with `mask-image: none` and read the two per-element vars the animations write — `--cl-scroll-area-progress-start` and `--cl-scroll-area-progress-end`, each diff --git a/packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts b/packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts index f506d5263e7..767b215cf50 100644 --- a/packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts +++ b/packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts @@ -1,7 +1,7 @@ import * as stylex from '@stylexjs/stylex'; -import { colorVars, scrollbarVars, scrollFadeVars, space } from '../../tokens.stylex'; -import { scrollAreaVars } from './scroll-area.vars.stylex'; +import { colorVars, durationVars, radiusVars, scrollbarVars, scrollFadeVars, space } from '../../tokens.stylex'; +import { scrollAreaVars, scrollbarThumbVars } from './scroll-area.vars.stylex'; // Same-file locals so the `var()` references read as names rather than as a wall of // bracket lookups inside the gradient. StyleX inlines them at build; an imported helper @@ -10,7 +10,8 @@ const progressStart = scrollAreaVars['--cl-scroll-area-progress-start']; const progressEnd = scrollAreaVars['--cl-scroll-area-progress-end']; const fadeSize = scrollFadeVars['--cl-scroll-fade-size']; const fadeRange = scrollFadeVars['--cl-scroll-fade-range']; -const fadeInset = scrollFadeVars['--cl-scroll-fade-inset']; +const scrollbarWidth = scrollbarVars['--cl-scrollbar-width']; +const thumbColor = scrollbarThumbVars['--_cl-scrollbar-thumb-color']; // One animation per edge, each writing its own progress var. The end fade counts DOWN // rather than running `animation-direction: reverse`: with `fill-mode: both` the two are @@ -42,9 +43,11 @@ const revealEnd = stylex.keyframes({ // collapses onto the edge it starts from, leaving a hard boundary that reads as fully // opaque — so "no scroll yet" and "not scrollable at all" render identically, for free. // -// The second layer is the scrollbar strip, held opaque so the fade never touches it. At the -// default `0px` inset it is zero-wide and contributes nothing. Layers composite with `add` -// by default, so no `mask-composite` declaration is needed. +// The second layer is the scrollbar strip, held opaque so the fade never touches it. Its width +// comes from `--cl-scrollbar-width` — the lane we specify ourselves — rather than a knob of its +// own, since the two can never legitimately differ. Where we do NOT paint the scrollbar the +// layer is zero-wide and contributes nothing. Layers composite with `add` by default, so no +// `mask-composite` declaration is needed. const maskImage = `linear-gradient(to bottom, transparent 0, #000 calc(${progressStart} * ${fadeSize}), #000 calc(100% - ${progressEnd} * ${fadeSize}), transparent 100%), linear-gradient(#000, #000)`; // Split by concern rather than one object per slot: the sort-keys rule reorders within an @@ -66,23 +69,115 @@ const styles = stylex.create({ flexBasis: 'auto', flexGrow: 1, flexShrink: 1, - // Appearance is gated on a fine pointer so touch platforms keep the native overlay bar - // they already draw — there is no scrollbar there for a colour or a width to apply to, and - // a thin one would only shrink a target that is already hard to hit. `scrollbar-gutter` - // stays ungated below: it is a layout decision, not an appearance one. - scrollbarColor: { + minHeight: 0, + overflowX: 'hidden', + overflowY: 'auto', + }, + + /** + * The thumb's colour, produced on the SCROLLER rather than on the pseudo-element that paints + * it. A `::-webkit-scrollbar-thumb` cannot transition anything of its own, so the animating + * value is declared here and inherited down into it, which only ever reads it. + * + * Every scrollbar declaration from here down repeats `{ default: null, '@media (pointer: + * fine)': … }`. A touch platform draws an overlay bar there is no width or colour to apply to, + * and — the reason the gate has to reach the SHAPE properties too, not just the visible ones — + * Blink switches an element to a custom scrollbar the moment ANY `::-webkit-scrollbar*` rule + * matches it, which would trade that overlay bar for a permanent one. `null` emits no + * declaration at all, so under a coarse pointer the pseudo-elements carry no rules and the + * platform keeps its own. Written out each time rather than wrapped in a local helper: the + * compiler evaluates a helper fine, but `@stylexjs/valid-styles` can't see through the call and + * rejects every value it wraps, trading this repetition for a wall of suppressions. + * + * Deliberately no `scrollbar-color` / `scrollbar-width` alongside: a non-`auto` value for + * either makes a UA ignore the `::-webkit-scrollbar*` family entirely, so keeping them would + * leave every rule in `scrollbar` below as dead code in exactly the engines that implement it. + * Firefox implements the pseudo-elements not at all and keeps its platform scrollbar. That is + * the whole cost of the trade, and it buys per-state thumb colours and a real pixel width, + * neither of which the standard properties can express. + */ + thumbColor: { + '--_cl-scrollbar-thumb-color': { default: null, '@media (pointer: fine)': { - default: `${colorVars['--cl-color-neutral-faded']} transparent`, - // Forced-colors users get the system scrollbar; a themed one loses its contrast - // guarantee against a palette we no longer control. - '@media (forced-colors: active)': 'auto', + default: scrollbarVars['--cl-scrollbar-thumb'], + // Reaching the region at all is what lifts the thumb out of its rest state. This is the + // step the browser's own derivation gets BACKWARDS from a specified `scrollbar-color` — + // it lightens the thumb on hover — and correcting it is most of why this path exists. + ':is(:hover, :focus-within)': scrollbarVars['--cl-scrollbar-thumb-hover'], + }, + }, + // Longer leaving than arriving, per the duration tokens: hover is direct pointer feedback, + // its decay is not. `linear` because this is a colour — an ease on top of an already + // perceptually non-uniform interpolation only makes the midpoint drag. + transitionDuration: { + default: null, + '@media (pointer: fine)': { + default: durationVars['--cl-duration-base'], + ':is(:hover, :focus-within)': durationVars['--cl-duration-fast'], + }, + }, + transitionProperty: { default: null, '@media (pointer: fine)': '--_cl-scrollbar-thumb-color' }, + transitionTimingFunction: { default: null, '@media (pointer: fine)': 'linear' }, + }, + + /** + * The scrollbar's own paint. Only the lane's size and the thumb are styled — the track is left + * alone, so the thumb reads as floating over the content rather than riding in a rail. + * + * The thumb's two states are COMBINED keys rather than a `:hover` nested inside the + * `::-webkit-scrollbar-thumb` block: StyleX emits a nested pseudo-class BEFORE the + * pseudo-element (`:hover::-webkit-scrollbar-thumb`), which asks whether the scroller is + * hovered — a question already answered on `thumbColor` above. These are the thumb's own + * states, and a combined key is the only way to reach them. Their source order is set by the + * sort-keys rule and doesn't matter: StyleX prices `:active` above `:hover` either way. + * + * Both snap rather than transition, by design and in common with Polaris: `transition` is not + * inherited, so the declaration on the scroller doesn't reach the pseudo-element. A pointer + * already on the thumb wants the response to feel like contact anyway. + */ + scrollbar: { + '::-webkit-scrollbar': { + width: { default: null, '@media (pointer: fine)': scrollbarWidth }, + }, + '::-webkit-scrollbar-thumb': { + // A transparent border clipped away is how you inset a pill thumb: the lane keeps its full + // width for hit-testing while the paint shrinks to the middle of it. Both Polaris and + // `references/stylex-ui` arrive at this independently — a scrollbar pseudo-element has no + // padding to do it with. (Key order here is the sort-keys rule's, not ours.) + borderColor: { default: null, '@media (pointer: fine)': 'transparent' }, + borderRadius: { default: null, '@media (pointer: fine)': radiusVars['--cl-radius-full'] }, + borderStyle: { default: null, '@media (pointer: fine)': 'solid' }, + borderWidth: { default: null, '@media (pointer: fine)': scrollbarVars['--cl-scrollbar-thumb-inset'] }, + backgroundClip: { default: null, '@media (pointer: fine)': 'content-box' }, + backgroundColor: { + default: null, + '@media (pointer: fine)': { + // The pseudo-element only reads; the animating value is produced on `thumbColor` above. + // eslint-disable-next-line @stylexjs/valid-styles -- valid-styles doesn't resolve a `stylex.types.color()` var to a colour; the compiler does. + default: thumbColor, + // No `scrollbar-color: auto` lever survives on this path, so forced colors need their + // own answer: pin the thumb to a system colour rather than let a themed one lose its + // contrast guarantee against a palette we no longer control. Declared on + // `background-color` rather than on the var so it holds across all four states at once. + '@media (forced-colors: active)': 'ButtonBorder', + }, + }, + }, + // eslint-disable-next-line @stylexjs/valid-styles -- StyleX's pseudo-element allowlist holds the bare selectors only; it compiles the combined form correctly. See the note above. + '::-webkit-scrollbar-thumb:active': { + '--_cl-scrollbar-thumb-color': { + default: null, + '@media (pointer: fine)': scrollbarVars['--cl-scrollbar-thumb-active'], + }, + }, + // eslint-disable-next-line @stylexjs/valid-styles -- see above. + '::-webkit-scrollbar-thumb:hover': { + '--_cl-scrollbar-thumb-color': { + default: null, + '@media (pointer: fine)': scrollbarVars['--cl-scrollbar-thumb-hover'], }, }, - scrollbarWidth: { default: null, '@media (pointer: fine)': scrollbarVars['--cl-scrollbar-width'] }, - minHeight: 0, - overflowX: 'hidden', - overflowY: 'auto', }, /** Paint-only, so it can never shift the content the way a sticky shadow element does. */ @@ -90,7 +185,18 @@ const styles = stylex.create({ maskImage, maskPosition: 'left top, right top', maskRepeat: 'no-repeat', - maskSize: `calc(100% - ${fadeInset}) 100%, ${fadeInset} 100%`, + // Held back from the scrollbar only where we actually paint one, which is the same pair of + // conditions the rules above run under: a fine pointer, and an engine that implements + // `::-webkit-scrollbar`. This is not a fallback branch for the scrollbar styling — there + // isn't one — it is the mask asking whether there is a lane to keep clear. Firefox answers + // no and gets the fade edge to edge rather than an unfaded strip beside a bar we never + // styled. + maskSize: { + default: '100% 100%, 0px 100%', + '@media (pointer: fine)': { + '@supports selector(::-webkit-scrollbar)': `calc(100% - ${scrollbarWidth}) 100%, ${scrollbarWidth} 100%`, + }, + }, }, // Only the name is gated on timeline support. A browser that ignores `animation-timeline` @@ -160,7 +266,15 @@ export type ScrollAreaGutter = keyof typeof gutters; * does anything on platforms that overlay their scrollbars. */ export function scrollAreaViewport(gutter: ScrollAreaGutter = 'auto') { - return [styles.viewport, styles.mask, styles.indicators, styles.focusRing, gutters[gutter]] as const; + return [ + styles.viewport, + styles.thumbColor, + styles.scrollbar, + styles.mask, + styles.indicators, + styles.focusRing, + gutters[gutter], + ] as const; } /** diff --git a/packages/ui/src/mosaic/components/scroll-area/scroll-area.test.ts b/packages/ui/src/mosaic/components/scroll-area/scroll-area.test.ts index a2c2c5eec26..793c3c21dec 100644 --- a/packages/ui/src/mosaic/components/scroll-area/scroll-area.test.ts +++ b/packages/ui/src/mosaic/components/scroll-area/scroll-area.test.ts @@ -2,11 +2,11 @@ import { describe, expect, it } from 'vitest'; import { scrollbarVars, scrollFadeVars } from '../../tokens.stylex'; import { scrollAreaRoot, scrollAreaViewport } from './scroll-area.styles'; -import { scrollAreaVars } from './scroll-area.vars.stylex'; +import { scrollAreaVars, scrollbarThumbVars } from './scroll-area.vars.stylex'; describe('Mosaic scroll area styles', () => { it('composes the viewport atoms into one spreadable set', () => { - expect(scrollAreaViewport()).toHaveLength(5); + expect(scrollAreaViewport()).toHaveLength(7); expect(scrollAreaRoot).toBeDefined(); }); @@ -34,11 +34,25 @@ describe('Mosaic scroll area styles', () => { }); it('reads the shared scroll tokens', () => { - expect(scrollbarVars).toMatchObject({ '--cl-scrollbar-width': 'var(--cl-scrollbar-width)' }); + expect(scrollbarVars).toMatchObject({ + '--cl-scrollbar-width': 'var(--cl-scrollbar-width)', + '--cl-scrollbar-thumb-inset': 'var(--cl-scrollbar-thumb-inset)', + '--cl-scrollbar-thumb': 'var(--cl-scrollbar-thumb)', + '--cl-scrollbar-thumb-hover': 'var(--cl-scrollbar-thumb-hover)', + '--cl-scrollbar-thumb-active': 'var(--cl-scrollbar-thumb-active)', + }); expect(scrollFadeVars).toMatchObject({ '--cl-scroll-fade-size': 'var(--cl-scroll-fade-size)', '--cl-scroll-fade-range': 'var(--cl-scroll-fade-range)', - '--cl-scroll-fade-inset': 'var(--cl-scroll-fade-inset)', }); }); + + // The counterpart to the assertion above: `--_cl-` is the marker for plumbing, so it must not + // drift into the themable `--cl-` namespace the way a rename easily could. + it('keeps the thumb colour carrier out of the public token namespace', () => { + expect(scrollbarThumbVars).toMatchObject({ + '--_cl-scrollbar-thumb-color': 'var(--_cl-scrollbar-thumb-color)', + }); + expect(Object.keys(scrollFadeVars)).not.toContain('--cl-scroll-fade-inset'); + }); }); diff --git a/packages/ui/src/mosaic/components/scroll-area/scroll-area.vars.stylex.ts b/packages/ui/src/mosaic/components/scroll-area/scroll-area.vars.stylex.ts index 5260b6d7171..ecd4f398dee 100644 --- a/packages/ui/src/mosaic/components/scroll-area/scroll-area.vars.stylex.ts +++ b/packages/ui/src/mosaic/components/scroll-area/scroll-area.vars.stylex.ts @@ -20,3 +20,20 @@ export const scrollAreaVars = stylex.defineVars({ '--cl-scroll-area-progress-start': stylex.types.number(0), '--cl-scroll-area-progress-end': stylex.types.number(0), }); + +// The animated carrier for the scrollbar thumb's colour. `::-webkit-scrollbar-thumb` cannot +// transition properties of its own, so the transition is declared on the SCROLLER against this +// property and the pseudo-element only ever reads it — `inherits: true`, which StyleX hardcodes +// for typed vars, is what carries the animating value down into it. Same primitive as the +// progress vars above, for the same reason: unregistered, it would snap at the halfway point +// instead of interpolating. +// +// The initial value has to be a literal. `@property`'s `initial-value` must be computationally +// independent, so it cannot be the `var(--cl-scrollbar-thumb)` reference the scroller actually +// assigns — an invalid one would drop the whole registration and take the transition with it. +// +// `--_cl-` rather than `--cl-`: this is plumbing between an element and its pseudo-element, not +// a themable contract. The knobs are the `--cl-scrollbar-thumb*` tokens this resolves to. +export const scrollbarThumbVars = stylex.defineVars({ + '--_cl-scrollbar-thumb-color': stylex.types.color('transparent'), +}); diff --git a/packages/ui/src/mosaic/tokens.stylex.ts b/packages/ui/src/mosaic/tokens.stylex.ts index 560e4f77bbc..4daf3d19836 100644 --- a/packages/ui/src/mosaic/tokens.stylex.ts +++ b/packages/ui/src/mosaic/tokens.stylex.ts @@ -91,25 +91,43 @@ export const targetVars = stylex.defineVars(targetDefaults); // Scrollbar Tokens // ============================================================================= // One opinion for every scrolling surface in Mosaic, set in one place. Mosaic has no -// reason to render differently-sized scrollbars in different components, so this is a -// token rather than a per-component prop — a consumer restyles all of them at once. +// reason to render differently-sized scrollbars in different components, so these are +// tokens rather than per-component props — a consumer restyles all of them at once. // -// `thin` rather than `auto`: these scroll regions are compact panels (member lists in a -// card or a popover), where a platform-default ~17px bar reads heavy, and where -// `scrollbar-gutter: stable` means the width is content space we give up whether or not -// anything is scrolling. The tradeoff is a smaller drag target on the platforms whose -// scrollbars are draggable at all — set `auto` to take it back. +// The width is a real LENGTH, not the `auto | thin | none` keyword `scrollbar-width` takes. +// Mosaic paints the scrollbar through `::-webkit-scrollbar`, which takes a length; the two +// paths are mutually exclusive (a non-`auto` `scrollbar-width` or `scrollbar-color` makes a +// UA ignore the pseudo-elements outright), so specifying a length is the honest option and +// the keyword one is gone. Firefox implements neither pseudo-element and keeps its platform +// scrollbar. `0.625rem` of lane carrying a `0.1875rem` inset leaves a 4px pill: compact +// enough for the panels these regions are — a member list in a card or a popover — without +// shrinking the drag target to a hairline. Set the width to `0px` to hide it outright, which +// is what the old `none` keyword did. +// +// The two derived colours reference `--cl-scrollbar-thumb` rather than baking its value in, +// so they resolve at use time: overriding the base re-derives both, while either state stays +// individually overridable. Mixing toward `--cl-color-card-foreground` deepens the thumb in +// light mode and lightens it in dark, since that token already carries both. +// +// Setting `--cl-scrollbar-thumb: transparent` gives a hover-reveal scrollbar with no feature +// of ours: the rest state paints nothing and the transition below fades the thumb in when the +// region is hovered. The lane is still reserved either way — only the thumb's paint is +// conditional, so nothing moves. // // Only applied under `@media (pointer: fine)`. A touch platform draws an overlay bar there is // no width to apply to, and thinning a target that is already hard to hit would be actively // worse — Polaris's `s-scroll-box` gates its scrollbar styling the same way. -// -// Keyword-only, by the CSS spec: `scrollbar-width` accepts `auto | thin | none` and NOT a -// length. A real pixel width exists only via `::-webkit-scrollbar`, which Firefox ignores -// and which Chrome 121+ discards once `scrollbar-color` is set — so there is no honest way -// to expose this as a length. + +// Self-reference by literal name: the group being defined can't read its own exported object, +// and `--`-prefixed keys are emitted verbatim, so the name is stable enough to write by hand. +const scrollbarThumb = 'var(--cl-scrollbar-thumb)'; + const scrollbarDefaults = { - '--cl-scrollbar-width': 'thin', + '--cl-scrollbar-width': '0.625rem', + '--cl-scrollbar-thumb-inset': '0.1875rem', + '--cl-scrollbar-thumb': colorVars['--cl-color-neutral-faded'], + '--cl-scrollbar-thumb-hover': `color-mix(in oklab, ${scrollbarThumb}, ${colorVars['--cl-color-card-foreground']} 25%)`, + '--cl-scrollbar-thumb-active': `color-mix(in oklab, ${scrollbarThumb}, ${colorVars['--cl-color-card-foreground']} 45%)`, } as const; export const scrollbarVars = stylex.defineVars(scrollbarDefaults); @@ -123,14 +141,15 @@ export const scrollbarVars = stylex.defineVars(scrollbarDefaults); // `size` and `range` default to the same value on purpose: the fade reaches full strength // after you've scrolled its own height, so it grows in at the rate the content moves. // -// `inset` holds the fade back from a strip at the inline end so a space-consuming scrollbar -// isn't faded along with the content. It defaults to `0px` because CSS cannot measure a -// scrollbar — the width differs per platform and browser, and on macOS it changes at runtime -// when a mouse is connected — so any non-zero default would be wrong more often than right. +// There is deliberately no `inset` knob for holding the fade back from the scrollbar. It +// existed only to compensate for a width CSS could not measure; now that `--cl-scrollbar-width` +// specifies that width, the two can never legitimately differ — a smaller inset lets the fade +// cover part of the scrollbar, a larger one leaves an unfaded strip beside it — so the mask +// derives its inset from the scrollbar token instead of exposing a second name for the same +// number. const scrollFadeDefaults = { '--cl-scroll-fade-size': '1.5rem', '--cl-scroll-fade-range': '1.5rem', - '--cl-scroll-fade-inset': '0px', } as const; export const scrollFadeVars = stylex.defineVars(scrollFadeDefaults); From 58f0a2e8a78517c5d782eb8e3c6be9fdfccdb2d6 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 31 Jul 2026 17:10:07 -0600 Subject: [PATCH 10/24] fix(ui): Scope the scrollbar hover to the thumb and clear the mask off the lane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three corrections to the webkit scrollbar, all found in the browser. The mask still covered the scrollbar: StyleX 0.19 rewrites the argument of `@supports selector(…)` with the same `:not(#\#)` specificity bump it applies to real selectors, turning the query into `selector(:not(#\#):not(#\#):not(#\#)::-webkit-scrollbar)` — false everywhere. Verified in Chrome, where the honest form is true and the rewritten one is false. Swapped for a property-based condition, which StyleX leaves alone. Hover was reading the SCROLLER's `:hover`, so the thumb lit up whenever the pointer was anywhere over the region. Both states now live on the thumb itself, and the transition moves to the pseudo-element with them. The lane goes to 8px carrying a 2px inset — a 4px pill — and the thumb starts mixed most of the way toward the surface colour, so a bar this thin reads as a hairline rather than a hard rule. In pixels, not rem: a scrollbar is chrome, and should not scale with the text around it. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/lazy-pans-tickle.md | 18 +-- packages/swingset/src/stories/item.mdx | 56 ++++++--- .../swingset/src/stories/item.stories.tsx | 109 +++++++++++------ .../scroll-area/scroll-area.styles.ts | 111 ++++++++---------- .../scroll-area/scroll-area.test.ts | 2 +- packages/ui/src/mosaic/tokens.stylex.ts | 36 +++--- 6 files changed, 189 insertions(+), 143 deletions(-) diff --git a/.changeset/lazy-pans-tickle.md b/.changeset/lazy-pans-tickle.md index d8a068c8047..957c6a80845 100644 --- a/.changeset/lazy-pans-tickle.md +++ b/.changeset/lazy-pans-tickle.md @@ -2,22 +2,22 @@ '@clerk/ui': patch --- -Mosaic's scrolling surfaces now paint their own scrollbar, with a thumb that darkens on hover and again while you drag it. Previously the browser derived those states from `scrollbar-color` and got them backwards, lightening the thumb on hover. +Mosaic's scrolling surfaces now paint their own scrollbar: a 4px pill that deepens while the pointer is on it and again while you drag it. Previously the browser derived those states from `scrollbar-color` and got them backwards, lightening the thumb on hover. New tokens, applied to every scrolling surface at once: -| Token | Default | -| ----------------------------- | -------------------------- | -| `--cl-scrollbar-thumb` | `--cl-color-neutral-faded` | -| `--cl-scrollbar-thumb-hover` | derived from the above | -| `--cl-scrollbar-thumb-active` | derived from the above | -| `--cl-scrollbar-thumb-inset` | `0.1875rem` | +| Token | Default | +| ----------------------------- | ------------------------ | +| `--cl-scrollbar-thumb` | derived from the palette | +| `--cl-scrollbar-thumb-hover` | derived from the above | +| `--cl-scrollbar-thumb-active` | derived from the above | +| `--cl-scrollbar-thumb-inset` | `2px` | -Setting `--cl-scrollbar-thumb: transparent` gives a hover-reveal scrollbar: the thumb paints nothing at rest and fades in when you reach the region. The lane stays reserved either way, so nothing moves. +Setting `--cl-scrollbar-thumb: transparent` hides the thumb without giving up its lane — it paints only while the pointer is on it, and nothing moves either way. Two breaking notes if you were already theming these: -- `--cl-scrollbar-width` now takes a **length** (default `0.625rem`) rather than the `auto | thin | none` keyword. Use `0px` where you previously used `none`. +- `--cl-scrollbar-width` now takes a **length** (default `8px`) rather than the `auto | thin | none` keyword. Use `0px` where you previously used `none`. - `--cl-scroll-fade-inset` is removed. The mask now derives its inset from `--cl-scrollbar-width`, which closes the gap where the edge fade covered part of the scrollbar. Firefox implements neither `::-webkit-scrollbar` nor an equivalent, so it keeps its platform scrollbar; touch platforms keep their native overlay bar as before. On macOS, styling the scrollbar takes it out of overlay mode, so the bar is always visible and always occupies its lane. diff --git a/packages/swingset/src/stories/item.mdx b/packages/swingset/src/stories/item.mdx index 424ed137219..6c8b93b8176 100644 --- a/packages/swingset/src/stories/item.mdx +++ b/packages/swingset/src/stories/item.mdx @@ -55,13 +55,25 @@ import { scrollAreaRoot, scrollAreaViewport } from '@clerk/ui/mosaic/components/ import * as stylex from '@stylexjs/stylex';
{organizations}
; ``` +`stylex.props()` returns a `className`, so a class of your own has to be **merged** with it rather +than written beside it — whichever comes last in JSX wins outright and silently drops the other: + +```tsx +const root = stylex.props(scrollAreaRoot); + +
; +``` + `scrollAreaViewport()` returns an array, hence the `...` spread. Its one argument is the scrollbar gutter: `auto` (the default, and CSS's own) takes the space only while the content overflows, while `stable` reserves it either way — worth it when the content can change height **in place**, such as @@ -76,18 +88,25 @@ animation support get a plain scrolling group rather than a broken one. These tokens apply to every scrolling surface in Mosaic, so setting them once retunes all of them: -| Token | Default | Description | -| ----------------------------- | -------------------------- | --------------------------------------------------------- | -| `--cl-scroll-fade-size` | `1.5rem` | Height of the fade band. | -| `--cl-scroll-fade-range` | `1.5rem` | How far you scroll before the fade reaches full strength. | -| `--cl-scrollbar-width` | `0.625rem` | Width of the scrollbar lane. `0px` hides it. | -| `--cl-scrollbar-thumb-inset` | `0.1875rem` | How far the thumb's paint is held inside that lane. | -| `--cl-scrollbar-thumb` | `--cl-color-neutral-faded` | Thumb colour at rest. | -| `--cl-scrollbar-thumb-hover` | derived from the above | Thumb colour while the region or the thumb is hovered. | -| `--cl-scrollbar-thumb-active` | derived from the above | Thumb colour while the thumb is being dragged. | +| Token | Default | Description | +| ----------------------------- | ------------------------ | --------------------------------------------------------- | +| `--cl-scroll-fade-size` | `1.5rem` | Height of the fade band. | +| `--cl-scroll-fade-range` | `1.5rem` | How far you scroll before the fade reaches full strength. | +| `--cl-scrollbar-width` | `8px` | Width of the scrollbar lane. `0px` hides it. | +| `--cl-scrollbar-thumb-inset` | `2px` | How far the thumb's paint is held inside that lane. | +| `--cl-scrollbar-thumb` | derived from the palette | Thumb colour at rest. | +| `--cl-scrollbar-thumb-hover` | derived from the above | Thumb colour while the pointer is over the thumb. | +| `--cl-scrollbar-thumb-active` | derived from the above | Thumb colour while the thumb is being dragged. | + +The two lane sizes are in pixels rather than on the `rem` scale, deliberately: a scrollbar is chrome +rather than content, so it should stay the same hairline whether or not the surrounding text scales. +The default is a 4px pill with a 2px track either side. -The two derived colours reference `--cl-scrollbar-thumb` rather than baking its value in, so -setting the base re-derives both — and either state can still be pinned on its own. +The two derived colours reference `--cl-scrollbar-thumb` rather than baking its value in, so setting +the base re-derives both — and either state can still be pinned on its own. + +`hover` and `active` are the **thumb's own** states, not the region's: the colour changes when the +pointer is over the thumb itself, not whenever it is somewhere over the scrolling area. Mosaic paints the scrollbar through `::-webkit-scrollbar`, which is what buys a real width and a thumb colour per interaction state; the standard `scrollbar-color` can express neither, and setting @@ -98,11 +117,10 @@ layout decision rather than an appearance one. One consequence worth knowing before you theme: styling the scrollbar takes macOS out of overlay mode, so the bar is always visible and always occupies its lane rather than auto-hiding. That is the -cross-platform consistency the tokens exist for, but it is a change from the platform default. - -For a scrollbar that appears only on hover, set the rest colour to `transparent`. Nothing else is -needed — the thumb paints nothing at rest and the existing transition fades it in when you reach the -region. The lane stays reserved either way, so this changes only what is painted, never the layout: +cross-platform consistency the tokens exist for, but it is a change from the platform default. To +hide the thumb without giving up the lane, set the rest colour to `transparent` — it then paints +only while the pointer is on it. Note that this is a precise target to find, so it works best where +the fade indicators are already carrying the signal that the region scrolls: ```css :root { @@ -110,6 +128,10 @@ region. The lane stays reserved either way, so this changes only what is painted } ``` +One layout note: the scrollbar takes its lane **inside** a scroller's own padding, so a padded +surface reads as padding plus lane at the inline end. Trim the inline-end padding on the scroller if +you want the scrollbar to sit in the gutter the padding was already holding. + To replace the fade entirely, retire it with `mask-image: none` and read the two per-element vars the animations write — `--cl-scroll-area-progress-start` and `--cl-scroll-area-progress-end`, each describing how much that edge still has to reveal. They live on the element carrying the atoms and diff --git a/packages/swingset/src/stories/item.stories.tsx b/packages/swingset/src/stories/item.stories.tsx index cc8074a94a5..0831959c837 100644 --- a/packages/swingset/src/stories/item.stories.tsx +++ b/packages/swingset/src/stories/item.stories.tsx @@ -3,6 +3,7 @@ import { Avatar } from '@clerk/ui/mosaic/components/avatar'; import { Button } from '@clerk/ui/mosaic/components/button'; import { Item } from '@clerk/ui/mosaic/components/item'; import { scrollAreaRoot, scrollAreaViewport } from '@clerk/ui/mosaic/components/scroll-area'; +import { space } from '@clerk/ui/mosaic/styles'; import * as stylex from '@stylexjs/stylex'; import * as React from 'react'; @@ -335,57 +336,87 @@ export function Group() { ); } -const organizations = [ - 'Acme Corporation', - 'Globex', - 'Initech', - 'Umbrella Health', - 'Stark Industries', - 'Wayne Enterprises', - 'Cyberdyne Systems', - 'Soylent Industries', - 'Tyrell Corporation', - 'Weyland-Yutani', +const accounts = [ + { email: 'cameron.walker@gmail.com', organizations: ['Clerk', 'Acme Corporation', 'Globex'] }, + { email: 'cameron@clerk.com', organizations: ['Clerk', 'Initech', 'Umbrella Health'] }, + { email: 'cam@designcloud.io', organizations: ['Clerk', 'DesignCloud'] }, ]; +function OrganizationRow({ name }: { name: string }) { + return ( + ( + + )} + > + + + + {name[0]} + + + + {name} + + + ); +} + // A capped-height group that scrolls, with fade indicators at whichever edge still has // something to reveal. The scroll surface is StyleX atoms rather than a component, so it goes // straight onto the `Item.Group` — no wrapper element, and the group keeps its `.cl-item-group` // slot, which stays the hook a theme targets. The outer box only exists to cap the height and // to give overlays something to anchor to. export function Scrolling() { + // `stylex.props()` returns a `className`, so it has to be MERGED with any class of your own + // rather than spread beside one — whichever comes last in JSX wins outright. + const root = stylex.props(scrollAreaRoot); + return (
- - {organizations.map(name => ( - ( - - )} - > - - - {name[0]} - - - - {name} - {organizations.indexOf(name) + 3} members - - + {/* The group pads all four sides, and the scrollbar takes its lane INSIDE that padding, so + the right edge otherwise reads as padding plus lane. Cutting the inline-end padding to + the smallest step lets the scrollbar occupy the gutter the padding was holding, while + still keeping the rows off it. */} + + {accounts.map(({ email, organizations }, index) => ( + + {/* The sections above are separate groups, so each one's own padding puts a gap either + side of the separator. Here they share one group — the scroller — so the gap has to + come from the separator itself. `space['2']` is the group's own padding step, so the + two stay in sync if the spacing scale is retuned. */} + {index > 0 ? : null} + + + {email} + + + {organizations.map(name => ( + + ))} + ))}
diff --git a/packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts b/packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts index 767b215cf50..9fcd1a3fae3 100644 --- a/packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts +++ b/packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts @@ -75,72 +75,49 @@ const styles = stylex.create({ }, /** - * The thumb's colour, produced on the SCROLLER rather than on the pseudo-element that paints - * it. A `::-webkit-scrollbar-thumb` cannot transition anything of its own, so the animating - * value is declared here and inherited down into it, which only ever reads it. + * The scrollbar's own paint. Only the lane's size and the thumb are styled — the track is left + * alone, so the thumb reads as floating over the content rather than riding in a rail. * - * Every scrollbar declaration from here down repeats `{ default: null, '@media (pointer: - * fine)': … }`. A touch platform draws an overlay bar there is no width or colour to apply to, - * and — the reason the gate has to reach the SHAPE properties too, not just the visible ones — - * Blink switches an element to a custom scrollbar the moment ANY `::-webkit-scrollbar*` rule - * matches it, which would trade that overlay bar for a permanent one. `null` emits no - * declaration at all, so under a coarse pointer the pseudo-elements carry no rules and the - * platform keeps its own. Written out each time rather than wrapped in a local helper: the - * compiler evaluates a helper fine, but `@stylexjs/valid-styles` can't see through the call and - * rejects every value it wraps, trading this repetition for a wall of suppressions. + * Every declaration here repeats `{ default: null, '@media (pointer: fine)': … }`. A touch + * platform draws an overlay bar there is no width or colour to apply to, and — the reason the + * gate has to reach the SHAPE properties too, not just the visible ones — Blink switches an + * element to a custom scrollbar the moment ANY `::-webkit-scrollbar*` rule matches it, which + * would trade that overlay bar for a permanent one. `null` emits no declaration at all, so + * under a coarse pointer the pseudo-elements carry no rules and the platform keeps its own. + * Written out each time rather than wrapped in a local helper: the compiler evaluates a helper + * fine, but `@stylexjs/valid-styles` can't see through the call and rejects every value it + * wraps, trading this repetition for a wall of suppressions. * - * Deliberately no `scrollbar-color` / `scrollbar-width` alongside: a non-`auto` value for + * Deliberately no `scrollbar-color` / `scrollbar-width` on the scroller: a non-`auto` value for * either makes a UA ignore the `::-webkit-scrollbar*` family entirely, so keeping them would - * leave every rule in `scrollbar` below as dead code in exactly the engines that implement it. - * Firefox implements the pseudo-elements not at all and keeps its platform scrollbar. That is - * the whole cost of the trade, and it buys per-state thumb colours and a real pixel width, - * neither of which the standard properties can express. - */ - thumbColor: { - '--_cl-scrollbar-thumb-color': { - default: null, - '@media (pointer: fine)': { - default: scrollbarVars['--cl-scrollbar-thumb'], - // Reaching the region at all is what lifts the thumb out of its rest state. This is the - // step the browser's own derivation gets BACKWARDS from a specified `scrollbar-color` — - // it lightens the thumb on hover — and correcting it is most of why this path exists. - ':is(:hover, :focus-within)': scrollbarVars['--cl-scrollbar-thumb-hover'], - }, - }, - // Longer leaving than arriving, per the duration tokens: hover is direct pointer feedback, - // its decay is not. `linear` because this is a colour — an ease on top of an already - // perceptually non-uniform interpolation only makes the midpoint drag. - transitionDuration: { - default: null, - '@media (pointer: fine)': { - default: durationVars['--cl-duration-base'], - ':is(:hover, :focus-within)': durationVars['--cl-duration-fast'], - }, - }, - transitionProperty: { default: null, '@media (pointer: fine)': '--_cl-scrollbar-thumb-color' }, - transitionTimingFunction: { default: null, '@media (pointer: fine)': 'linear' }, - }, - - /** - * The scrollbar's own paint. Only the lane's size and the thumb are styled — the track is left - * alone, so the thumb reads as floating over the content rather than riding in a rail. + * leave every rule here as dead code in exactly the engines that implement it. Firefox + * implements the pseudo-elements not at all and keeps its platform scrollbar. That is the whole + * cost of the trade, and it buys per-state thumb colours and a real pixel width, neither of + * which the standard properties can express. * - * The thumb's two states are COMBINED keys rather than a `:hover` nested inside the + * The thumb's states are COMBINED keys rather than a `:hover` nested inside the * `::-webkit-scrollbar-thumb` block: StyleX emits a nested pseudo-class BEFORE the - * pseudo-element (`:hover::-webkit-scrollbar-thumb`), which asks whether the scroller is - * hovered — a question already answered on `thumbColor` above. These are the thumb's own - * states, and a combined key is the only way to reach them. Their source order is set by the - * sort-keys rule and doesn't matter: StyleX prices `:active` above `:hover` either way. - * - * Both snap rather than transition, by design and in common with Polaris: `transition` is not - * inherited, so the declaration on the scroller doesn't reach the pseudo-element. A pointer - * already on the thumb wants the response to feel like contact anyway. + * pseudo-element (`:hover::-webkit-scrollbar-thumb`), which asks whether the SCROLLER is + * hovered — a much larger target that lights the thumb up whenever the pointer is anywhere over + * the region. These are the thumb's own states, and a combined key is the only way to reach + * them. Their source order is the sort-keys rule's and doesn't matter: StyleX prices `:active` + * above `:hover` either way. */ scrollbar: { '::-webkit-scrollbar': { width: { default: null, '@media (pointer: fine)': scrollbarWidth }, }, '::-webkit-scrollbar-thumb': { + // The colour is routed through an `@property`-registered var rather than transitioned as + // `background-color` directly, because `background-color` on a scrollbar part is not an + // animatable property in Blink — the registered custom property is, and the pseudo-element + // reads it. Longer leaving than arriving, per the duration tokens: reaching the thumb is + // direct pointer feedback, its decay is not. `linear` because this is a colour — an ease on + // top of an already perceptually non-uniform interpolation only makes the midpoint drag. + '--_cl-scrollbar-thumb-color': { + default: null, + '@media (pointer: fine)': scrollbarVars['--cl-scrollbar-thumb'], + }, // A transparent border clipped away is how you inset a pill thumb: the lane keeps its full // width for hit-testing while the paint shrinks to the middle of it. Both Polaris and // `references/stylex-ui` arrive at this independently — a scrollbar pseudo-element has no @@ -153,16 +130,18 @@ const styles = stylex.create({ backgroundColor: { default: null, '@media (pointer: fine)': { - // The pseudo-element only reads; the animating value is produced on `thumbColor` above. // eslint-disable-next-line @stylexjs/valid-styles -- valid-styles doesn't resolve a `stylex.types.color()` var to a colour; the compiler does. default: thumbColor, // No `scrollbar-color: auto` lever survives on this path, so forced colors need their // own answer: pin the thumb to a system colour rather than let a themed one lose its // contrast guarantee against a palette we no longer control. Declared on - // `background-color` rather than on the var so it holds across all four states at once. + // `background-color` rather than on the var so it holds across all three states at once. '@media (forced-colors: active)': 'ButtonBorder', }, }, + transitionDuration: { default: null, '@media (pointer: fine)': durationVars['--cl-duration-base'] }, + transitionProperty: { default: null, '@media (pointer: fine)': '--_cl-scrollbar-thumb-color' }, + transitionTimingFunction: { default: null, '@media (pointer: fine)': 'linear' }, }, // eslint-disable-next-line @stylexjs/valid-styles -- StyleX's pseudo-element allowlist holds the bare selectors only; it compiles the combined form correctly. See the note above. '::-webkit-scrollbar-thumb:active': { @@ -177,6 +156,8 @@ const styles = stylex.create({ default: null, '@media (pointer: fine)': scrollbarVars['--cl-scrollbar-thumb-hover'], }, + // Arriving is direct pointer feedback and reads better a touch quicker than the decay. + transitionDuration: { default: null, '@media (pointer: fine)': durationVars['--cl-duration-fast'] }, }, }, @@ -188,13 +169,20 @@ const styles = stylex.create({ // Held back from the scrollbar only where we actually paint one, which is the same pair of // conditions the rules above run under: a fine pointer, and an engine that implements // `::-webkit-scrollbar`. This is not a fallback branch for the scrollbar styling — there - // isn't one — it is the mask asking whether there is a lane to keep clear. Firefox answers - // no and gets the fade edge to edge rather than an unfaded strip beside a bar we never - // styled. + // isn't one — it is the mask asking whether there is a lane to keep clear. Gecko answers no + // and gets the fade edge to edge, rather than an unfaded strip beside a bar we never styled + // and whose width we don't know. + // + // `not (-moz-appearance: none)` stands in for the question we actually want to ask, + // `selector(::-webkit-scrollbar)`, because StyleX 0.19 rewrites the argument of + // `@supports selector(…)` with the same `:not(#\#)` specificity bump it applies to real + // selectors. That turns the query into `selector(:not(#\#):not(#\#):not(#\#)::-webkit-scrollbar)`, + // which every engine reports as false — verified in Chrome, where the honest form returns + // true and the rewritten one returns false. Any property-based condition is left alone. maskSize: { default: '100% 100%, 0px 100%', '@media (pointer: fine)': { - '@supports selector(::-webkit-scrollbar)': `calc(100% - ${scrollbarWidth}) 100%, ${scrollbarWidth} 100%`, + '@supports not (-moz-appearance: none)': `calc(100% - ${scrollbarWidth}) 100%, ${scrollbarWidth} 100%`, }, }, }, @@ -268,7 +256,6 @@ export type ScrollAreaGutter = keyof typeof gutters; export function scrollAreaViewport(gutter: ScrollAreaGutter = 'auto') { return [ styles.viewport, - styles.thumbColor, styles.scrollbar, styles.mask, styles.indicators, diff --git a/packages/ui/src/mosaic/components/scroll-area/scroll-area.test.ts b/packages/ui/src/mosaic/components/scroll-area/scroll-area.test.ts index 793c3c21dec..46e192cf535 100644 --- a/packages/ui/src/mosaic/components/scroll-area/scroll-area.test.ts +++ b/packages/ui/src/mosaic/components/scroll-area/scroll-area.test.ts @@ -6,7 +6,7 @@ import { scrollAreaVars, scrollbarThumbVars } from './scroll-area.vars.stylex'; describe('Mosaic scroll area styles', () => { it('composes the viewport atoms into one spreadable set', () => { - expect(scrollAreaViewport()).toHaveLength(7); + expect(scrollAreaViewport()).toHaveLength(6); expect(scrollAreaRoot).toBeDefined(); }); diff --git a/packages/ui/src/mosaic/tokens.stylex.ts b/packages/ui/src/mosaic/tokens.stylex.ts index 4daf3d19836..df50d859b26 100644 --- a/packages/ui/src/mosaic/tokens.stylex.ts +++ b/packages/ui/src/mosaic/tokens.stylex.ts @@ -99,20 +99,26 @@ export const targetVars = stylex.defineVars(targetDefaults); // paths are mutually exclusive (a non-`auto` `scrollbar-width` or `scrollbar-color` makes a // UA ignore the pseudo-elements outright), so specifying a length is the honest option and // the keyword one is gone. Firefox implements neither pseudo-element and keeps its platform -// scrollbar. `0.625rem` of lane carrying a `0.1875rem` inset leaves a 4px pill: compact -// enough for the panels these regions are — a member list in a card or a popover — without -// shrinking the drag target to a hairline. Set the width to `0px` to hide it outright, which -// is what the old `none` keyword did. +// scrollbar. Set the width to `0px` to hide it outright, which is what the old `none` did. +// +// Deliberately in PIXELS rather than on the `rem` scale the rest of the tokens use. A scrollbar +// is chrome, not content: it should stay the same hairline whether or not the surrounding text +// scales, and 8px of lane carrying a 2px inset — a 4px pill with a 2px track either side — is +// a specific hairline rather than a ratio of anything. Rounding also matters more here than +// elsewhere, since the thumb is only a few pixels wide to begin with. // // The two derived colours reference `--cl-scrollbar-thumb` rather than baking its value in, // so they resolve at use time: overriding the base re-derives both, while either state stays -// individually overridable. Mixing toward `--cl-color-card-foreground` deepens the thumb in -// light mode and lightens it in dark, since that token already carries both. +// individually overridable. The base is itself mixed most of the way toward `--cl-color-card`, +// which is what keeps a 4px bar reading as a hairline rather than a hard rule; the two states +// then step back toward `--cl-color-card-foreground`, deepening in light mode and lightening in +// dark, since that token already carries both. // -// Setting `--cl-scrollbar-thumb: transparent` gives a hover-reveal scrollbar with no feature -// of ours: the rest state paints nothing and the transition below fades the thumb in when the -// region is hovered. The lane is still reserved either way — only the thumb's paint is -// conditional, so nothing moves. +// Setting `--cl-scrollbar-thumb: transparent` hides the thumb without giving up its lane: the +// rest state simply paints nothing and the thumb reappears while the pointer is on it. Only the +// paint is conditional, so nothing moves. Worth knowing that the two states below are the +// THUMB's, not the region's, which makes that a precise target to find — it works best where +// the fade indicators are already carrying the signal that the region scrolls. // // Only applied under `@media (pointer: fine)`. A touch platform draws an overlay bar there is // no width to apply to, and thinning a target that is already hard to hit would be actively @@ -123,11 +129,11 @@ export const targetVars = stylex.defineVars(targetDefaults); const scrollbarThumb = 'var(--cl-scrollbar-thumb)'; const scrollbarDefaults = { - '--cl-scrollbar-width': '0.625rem', - '--cl-scrollbar-thumb-inset': '0.1875rem', - '--cl-scrollbar-thumb': colorVars['--cl-color-neutral-faded'], - '--cl-scrollbar-thumb-hover': `color-mix(in oklab, ${scrollbarThumb}, ${colorVars['--cl-color-card-foreground']} 25%)`, - '--cl-scrollbar-thumb-active': `color-mix(in oklab, ${scrollbarThumb}, ${colorVars['--cl-color-card-foreground']} 45%)`, + '--cl-scrollbar-width': '8px', + '--cl-scrollbar-thumb-inset': '2px', + '--cl-scrollbar-thumb': `color-mix(in oklab, ${colorVars['--cl-color-neutral-faded']}, ${colorVars['--cl-color-card']} 55%)`, + '--cl-scrollbar-thumb-hover': `color-mix(in oklab, ${scrollbarThumb}, ${colorVars['--cl-color-card-foreground']} 15%)`, + '--cl-scrollbar-thumb-active': `color-mix(in oklab, ${scrollbarThumb}, ${colorVars['--cl-color-card-foreground']} 30%)`, } as const; export const scrollbarVars = stylex.defineVars(scrollbarDefaults); From 7bce202a63fa1feb0ab611ff1f588c7d432ad1f8 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 31 Jul 2026 17:34:08 -0600 Subject: [PATCH 11/24] docs(swingset): Give atomic styles their own section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Half of the Item page — 129 of 242 lines — documented the scroll surface, which isn't Item and isn't a component. `Hooks` was already the precedent for a non-component section (`use-data-table.stories.tsx` is `meta` alone, no story exports), so `Styles` follows it: the scroll area gets a real page with room for examples that don't fit on a component page. Item keeps the demo and a pointer. This does revisit "Document the scroll surface on Item instead of its own page", but that move was made because a COMPONENT page was the wrong home for something that isn't a component. A Styles section answers that rather than reopening it. New examples the old page had no room for: a surface with nothing to scroll (the resting state costs nothing and needs no branch), `stable` vs `auto` gutter side by side, and a retuned scrollbar. Two rendering bugs surfaced by the first non-component, multi-word entry: the sidebar wrapped every non-hook title in JSX, so this read ``, and the breadcrumb only capitalised the first character, so it read "Scroll area". The sidebar now picks its form from the layer, and the breadcrumb resolves the registry's own `meta.title` — which also fixes `useDataTable`, previously shown as "Use data table". Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/gentle-hoops-relax.md | 2 + packages/swingset/CLAUDE.md | 11 +- .../swingset/src/components/ClientRoot.tsx | 16 +- .../swingset/src/components/DocsViewer.tsx | 4 + .../swingset/src/components/app-sidebar.tsx | 14 +- packages/swingset/src/lib/registry.ts | 17 ++ packages/swingset/src/stories/item.mdx | 113 +-------- .../swingset/src/stories/item.stories.tsx | 102 +++----- packages/swingset/src/stories/scroll-area.mdx | 179 ++++++++++++++ .../src/stories/scroll-area.stories.tsx | 229 ++++++++++++++++++ 10 files changed, 505 insertions(+), 182 deletions(-) create mode 100644 .changeset/gentle-hoops-relax.md create mode 100644 packages/swingset/src/stories/scroll-area.mdx create mode 100644 packages/swingset/src/stories/scroll-area.stories.tsx diff --git a/.changeset/gentle-hoops-relax.md b/.changeset/gentle-hoops-relax.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/gentle-hoops-relax.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/CLAUDE.md b/packages/swingset/CLAUDE.md index fc6aba1bfc0..177d3f235ce 100644 --- a/packages/swingset/CLAUDE.md +++ b/packages/swingset/CLAUDE.md @@ -57,7 +57,7 @@ Pick the archetype below by the component's **layer** (its `meta.group`), then f ### Layers -`meta.group` places a component in one of six layers. Sidebar order follows the `registry` array; group order follows first appearance there. Use these exact group strings: +`meta.group` places an entry in one of these layers. Sidebar order follows the `registry` array; group order follows first appearance there. Use these exact group strings: | Group | What lives here | Archetype | | ------------ | -------------------------------------------------------------- | --------- | @@ -67,9 +67,18 @@ Pick the archetype below by the component's **layer** (its `meta.group`), then f | `Blocks` | Reusable composite UI (e.g. `Destructive`) | C | | `Components` | Styled Mosaic components — simple CVA recipe (`Button`, `Input`) or compound/slot-based (`Dialog`, `Tabs`) | A | | `Primitives` | Headless `@clerk/headless` primitives (`Accordion`) | B | +| `Styles` | Atomic styles that ship as StyleX atoms, not components (`Scroll Area`) | B (adapted) | +| `Hooks` | Headless hooks (`useDataTable`) | B (adapted) | `AIO` → `Panels` → `Sections` → `Blocks` → `Components` → `Primitives` runs roughly high-level-composition → low-level-primitive. Composed layers (AIO/Panels/Sections/Blocks) are documented as compositions of lower layers (archetype C); leaf layers (Components, Primitives) get full prop/knob docs (archetypes A and B). +`Styles` and `Hooks` are the non-component layers: there is no element to knob, so they follow +archetype B's shape (Example → Usage → Parts → Styling) with `Props` replaced by whatever the export +actually surfaces — an argument table for a style function, a return-value table for a hook. A +`Styles` entry documents the theme tokens its atoms read, since those tokens _are_ its API; the +`Hooks` entry (`use-data-table.stories.tsx`) is `meta` alone, with no story exports at all, which is +the minimum a section entry needs. + Archetype A has two forms, chosen by whether the component exposes a single flat CVA recipe: **simple** components (`Button`, `Input`) are knob-driven; **compound** components built from slot recipes (`Dialog`, `Tabs`) have no flat variant props to knob, so they're documented like a primitive but themed. Both are detailed under Archetype A below. ### `meta` conventions (all archetypes) diff --git a/packages/swingset/src/components/ClientRoot.tsx b/packages/swingset/src/components/ClientRoot.tsx index 8725a699975..08a85129e6b 100644 --- a/packages/swingset/src/components/ClientRoot.tsx +++ b/packages/swingset/src/components/ClientRoot.tsx @@ -13,6 +13,7 @@ import { } from '@/components/ui/breadcrumb'; import { Separator } from '@/components/ui/separator'; import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar'; +import { getModule } from '@/lib/registry'; import { AppSidebar } from './app-sidebar'; import { ThemeToggle } from './ThemeToggle'; @@ -20,10 +21,17 @@ import { ThemeToggle } from './ThemeToggle'; function useBreadcrumb() { const pathname = usePathname(); // /components/button → ["Button"] - // /primitives/dialog → ["Dialog"] - // The first segment is the group; drop it and surface the component (plus any sub-path). - const parts = pathname.split('/').filter(Boolean).slice(1); - return parts.map(p => p.charAt(0).toUpperCase() + p.slice(1).replace(/-/g, ' ')); + // /styles/scroll-area → ["Scroll Area"] + // The first segment is the group; drop it and surface the entry (plus any sub-path). + const [groupSlug, ...parts] = pathname.split('/').filter(Boolean); + + // Prefer the registry's own `meta.title`, which is the only source that round-trips a slug back + // to how the entry is actually written — `scroll-area` → `Scroll Area`, `use-data-table` → + // `useDataTable`. Fall back to title-casing the slug for any path the registry doesn't cover. + return parts.map((part, index) => { + const title = index === 0 ? getModule(groupSlug, part)?.meta.title : undefined; + return title ?? part.replace(/(^|-)([a-z])/g, (_, sep: string, ch: string) => (sep ? ' ' : '') + ch.toUpperCase()); + }); } export function ClientRoot({ children }: { children: React.ReactNode }) { diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index d54119bbc35..9a19812fbf1 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -57,6 +57,10 @@ const docModules: Record> = { tabs: dynamic(() => import('../stories/tabs.mdx')), tooltip: dynamic(() => import('../stories/tooltip.mdx')), }, + styles: { + // Atomic styles — shipped as StyleX atoms rather than components. + 'scroll-area': dynamic(() => import('../stories/scroll-area.mdx')), + }, hooks: { // Headless hooks — alphabetical. 'use-data-table': dynamic(() => import('../stories/use-data-table.mdx')), diff --git a/packages/swingset/src/components/app-sidebar.tsx b/packages/swingset/src/components/app-sidebar.tsx index 35cd5ef3ad8..eba4369ff05 100644 --- a/packages/swingset/src/components/app-sidebar.tsx +++ b/packages/swingset/src/components/app-sidebar.tsx @@ -71,10 +71,16 @@ export function AppSidebar({ ...props }: React.ComponentProps) { {components.map(({ mod, componentSlug }) => { const href = `/${groupSlug}/${componentSlug}`; - // Hooks (e.g. `useDataTable`) are called, not rendered — show `useX()` rather - // than JSX ``. Everything else is a component. - const isHook = /^use[A-Z]/.test(mod.meta.title); - const usage = isHook ? `${mod.meta.title}()` : `<${mod.meta.title} />`; + // How an entry is USED differs by layer, so the label follows the layer rather + // than a guess at the title: hooks are called, atomic styles are a set of + // exports with no single call form worth privileging, and everything else is a + // component rendered as JSX. + const usage = + mod.meta.group === 'Hooks' + ? `${mod.meta.title}()` + : mod.meta.group === 'Styles' + ? mod.meta.title + : `<${mod.meta.title} />`; return ( -The scroll surface is **StyleX atoms, not a component** — everything it does is CSS, so a component -would only add a DOM node and an API to version. Spread `scrollAreaViewport()` onto the group -itself; it keeps its own `.cl-item-group` slot, which stays the hook a theme targets. `scrollAreaRoot` -goes on a positioned ancestor and is only needed when something has to anchor against the scroll -box — an overlay replacing the fade, for instance. - ```tsx import { scrollAreaRoot, scrollAreaViewport } from '@clerk/ui/mosaic/components/scroll-area'; import * as stylex from '@stylexjs/stylex'; @@ -62,105 +56,10 @@ import * as stylex from '@stylexjs/stylex';
; ``` -`stylex.props()` returns a `className`, so a class of your own has to be **merged** with it rather -than written beside it — whichever comes last in JSX wins outright and silently drops the other: - -```tsx -const root = stylex.props(scrollAreaRoot); - -
; -``` - -`scrollAreaViewport()` returns an array, hence the `...` spread. Its one argument is the scrollbar -gutter: `auto` (the default, and CSS's own) takes the space only while the content overflows, while -`stable` reserves it either way — worth it when the content can change height **in place**, such as -a filterable list, so crossing the overflow threshold doesn't shift the rows sideways. - -The fade is driven by two scroll-driven animations: no scroll listener, no measurement, nothing at -runtime. It is a mask rather than a sticky overlay element, so it is paint-only and cannot shift the -content. A group with nothing to scroll shows no indicators, and browsers without scroll-driven -animation support get a plain scrolling group rather than a broken one. - -#### Theming the fade and the scrollbar - -These tokens apply to every scrolling surface in Mosaic, so setting them once retunes all of them: - -| Token | Default | Description | -| ----------------------------- | ------------------------ | --------------------------------------------------------- | -| `--cl-scroll-fade-size` | `1.5rem` | Height of the fade band. | -| `--cl-scroll-fade-range` | `1.5rem` | How far you scroll before the fade reaches full strength. | -| `--cl-scrollbar-width` | `8px` | Width of the scrollbar lane. `0px` hides it. | -| `--cl-scrollbar-thumb-inset` | `2px` | How far the thumb's paint is held inside that lane. | -| `--cl-scrollbar-thumb` | derived from the palette | Thumb colour at rest. | -| `--cl-scrollbar-thumb-hover` | derived from the above | Thumb colour while the pointer is over the thumb. | -| `--cl-scrollbar-thumb-active` | derived from the above | Thumb colour while the thumb is being dragged. | - -The two lane sizes are in pixels rather than on the `rem` scale, deliberately: a scrollbar is chrome -rather than content, so it should stay the same hairline whether or not the surrounding text scales. -The default is a 4px pill with a 2px track either side. - -The two derived colours reference `--cl-scrollbar-thumb` rather than baking its value in, so setting -the base re-derives both — and either state can still be pinned on its own. - -`hover` and `active` are the **thumb's own** states, not the region's: the colour changes when the -pointer is over the thumb itself, not whenever it is somewhere over the scrolling area. - -Mosaic paints the scrollbar through `::-webkit-scrollbar`, which is what buys a real width and a -thumb colour per interaction state; the standard `scrollbar-color` can express neither, and setting -it would make the engines that _do_ implement the pseudo-elements ignore them. Firefox implements -neither and keeps its platform scrollbar. Everything here is gated on `@media (pointer: fine)`, so -touch platforms keep the native overlay bar they already draw. The gutter is not gated: it is a -layout decision rather than an appearance one. - -One consequence worth knowing before you theme: styling the scrollbar takes macOS out of overlay -mode, so the bar is always visible and always occupies its lane rather than auto-hiding. That is the -cross-platform consistency the tokens exist for, but it is a change from the platform default. To -hide the thumb without giving up the lane, set the rest colour to `transparent` — it then paints -only while the pointer is on it. Note that this is a precise target to find, so it works best where -the fade indicators are already carrying the signal that the region scrolls: - -```css -:root { - --cl-scrollbar-thumb: transparent; -} -``` - -One layout note: the scrollbar takes its lane **inside** a scroller's own padding, so a padded -surface reads as padding plus lane at the inline end. Trim the inline-end padding on the scroller if -you want the scrollbar to sit in the gutter the padding was already holding. - -To replace the fade entirely, retire it with `mask-image: none` and read the two per-element vars -the animations write — `--cl-scroll-area-progress-start` and `--cl-scroll-area-progress-end`, each -describing how much that edge still has to reveal. They live on the element carrying the atoms and -inherit downward. - -```css -.cl-item-group { - mask-image: none; -} -.cl-item-group::before { - content: ''; - position: absolute; - inset: 0 0 auto; - height: 2rem; - pointer-events: none; - background: linear-gradient(to bottom, color-mix(in oklab, var(--cl-color-card-foreground) 28%, transparent), transparent); - opacity: var(--cl-scroll-area-progress-start); -} -``` - -Position such overlays absolutely rather than with `position: sticky` — a sticky pseudo-element -takes space in the scroll flow, which is the layout shift the mask approach avoids. And mix the -scrim from a theme color rather than hardcoding black, which would darken a dark surface and look -identical to the mask it replaced. - -Chrome and Firefox make an overflowing scroll container keyboard-focusable on their own; **Safari -does not** (WCAG 2.1.1). `tabindex` isn't a style, so the atoms can't close that gap — set -`tabIndex={0}` yourself on a scroll surface that holds nothing focusable. A group of interactive -rows, like the one above, needs nothing: tabbing into the content already scrolls it. +The atoms aren't specific to `Item` — they go on anything that scrolls, and they carry the edge +fades, the scrollbar, the gutter, and the theming tokens with them. See +[Scroll Area](/styles/scroll-area) for the full surface: the gutter argument, what happens when +there is nothing to scroll, the token table, and how to replace the fade entirely. ## Usage diff --git a/packages/swingset/src/stories/item.stories.tsx b/packages/swingset/src/stories/item.stories.tsx index 0831959c837..f649101ca0b 100644 --- a/packages/swingset/src/stories/item.stories.tsx +++ b/packages/swingset/src/stories/item.stories.tsx @@ -336,49 +336,11 @@ export function Group() { ); } -const accounts = [ - { email: 'cameron.walker@gmail.com', organizations: ['Clerk', 'Acme Corporation', 'Globex'] }, - { email: 'cameron@clerk.com', organizations: ['Clerk', 'Initech', 'Umbrella Health'] }, - { email: 'cam@designcloud.io', organizations: ['Clerk', 'DesignCloud'] }, -]; +const organizations = ['Clerk', 'Acme Corporation', 'Globex', 'Initech', 'Umbrella Health', 'DesignCloud']; -function OrganizationRow({ name }: { name: string }) { - return ( - ( - - )} - > - - - - {name[0]} - - - - {name} - - - ); -} - -// A capped-height group that scrolls, with fade indicators at whichever edge still has -// something to reveal. The scroll surface is StyleX atoms rather than a component, so it goes -// straight onto the `Item.Group` — no wrapper element, and the group keeps its `.cl-item-group` -// slot, which stays the hook a theme targets. The outer box only exists to cap the height and -// to give overlays something to anchor to. +// `Item.Group` is the canonical scroll surface, so this shows the atoms doing the minimum: cap a +// height, spread them on, and the group fades its own edges. The Scroll Area page under Styles +// carries the full surface — the gutter argument, the resting state, and the theming tokens. export function Scrolling() { // `stylex.props()` returns a `className`, so it has to be MERGED with any class of your own // rather than spread beside one — whichever comes last in JSX wins outright. @@ -388,35 +350,43 @@ export function Scrolling() {
- {/* The group pads all four sides, and the scrollbar takes its lane INSIDE that padding, so - the right edge otherwise reads as padding plus lane. Cutting the inline-end padding to - the smallest step lets the scrollbar occupy the gutter the padding was holding, while - still keeping the rows off it. */} + {/* The group pads all four sides and the scrollbar takes its lane INSIDE that padding, so + the right edge otherwise reads as padding plus lane. */} - {accounts.map(({ email, organizations }, index) => ( - - {/* The sections above are separate groups, so each one's own padding puts a gap either - side of the separator. Here they share one group — the scroller — so the gap has to - come from the separator itself. `space['2']` is the group's own padding step, so the - two stay in sync if the spacing scale is retuned. */} - {index > 0 ? : null} - - - {email} - - - {organizations.map(name => ( - - ))} - + {organizations.map(name => ( + ( + + )} + > + + + + {name[0]} + + + + {name} + + ))}
diff --git a/packages/swingset/src/stories/scroll-area.mdx b/packages/swingset/src/stories/scroll-area.mdx new file mode 100644 index 00000000000..5f35e2dea76 --- /dev/null +++ b/packages/swingset/src/stories/scroll-area.mdx @@ -0,0 +1,179 @@ +import * as ScrollAreaStories from './scroll-area.stories'; + +# Scroll Area + +A scrolling surface that fades its content at whichever edge still has something to reveal, and +paints a scrollbar to match. It ships as **StyleX atoms rather than a component**: everything it +does is CSS, so a component would only add a DOM node and an API to version. Put the atoms on +whatever already scrolls — an `Item.Group`, a list, a panel body — and that element keeps its own +slot class, which stays the hook a theme targets. + +## Example + + + +## Usage + +`scrollAreaViewport()` returns an array, hence the `...` spread. `scrollAreaRoot` goes on a +positioned ancestor, and is only needed when something has to anchor against the scroll box — an +overlay replacing the fade, for instance. + +```tsx +import { scrollAreaRoot, scrollAreaViewport } from '@clerk/ui/mosaic/components/scroll-area'; +import * as stylex from '@stylexjs/stylex'; + +
+ {organizations} +
; +``` + +`stylex.props()` returns a `className`, so a class of your own has to be **merged** with it rather +than written beside it — whichever comes last in JSX wins outright and silently drops the other: + +```tsx +const root = stylex.props(scrollAreaRoot); + +
; +``` + +## Parts + +| Export | Goes on | Description | +| ----------------------------- | --------------------- | --------------------------------------------------------------------------------- | +| `scrollAreaViewport(gutter?)` | the scrolling element | The scroll box itself: overflow, the edge fades, the scrollbar, and a focus ring. | +| `scrollAreaRoot` | a positioned ancestor | Only needed when something anchors against the scroll box. | + +`gutter` is the one argument: `auto` (the default, and CSS's own) takes the scrollbar's space only +while the content overflows, while `stable` reserves it either way. It is an author-time decision +rather than a theme one — whether a region needs it depends on whether its content can resize in +place, which only that surface's author knows. + +## Examples + +### Nothing to scroll + + + +The atoms are unconditional: there is no "is it scrollable" branch to write, and no measurement at +runtime. A scroll timeline with no scrollable overflow is simply inactive, so both progress vars +hold at their registered `initial-value: 0` and the mask resolves to fully opaque. Browsers without +scroll-driven animation support get the same plain scrolling box rather than a broken one. + +### Gutter + + + +Neither box above overflows, so only `stable` is holding a lane open — watch the trailing rule, +which stops 8px short of where `auto` puts it. That 8px is the shift `auto` would produce the moment +the content grew past the box. + +Two conditions must **both** hold for the two to differ at all: space-consuming scrollbars, and +content that can stop overflowing. Where the platform overlays its scrollbars, they render +identically. + +### Theming the scrollbar + + + +## Styling + +The fade is driven by two scroll-driven animations — no scroll listener, no measurement, nothing at +runtime. It is a mask rather than a sticky overlay element, so it is paint-only and cannot shift the +content. + +These tokens are global, so setting them once retunes every scrolling surface in Mosaic: + +| Token | Default | Description | +| ----------------------------- | ------------------------ | --------------------------------------------------------- | +| `--cl-scroll-fade-size` | `1.5rem` | Height of the fade band. | +| `--cl-scroll-fade-range` | `1.5rem` | How far you scroll before the fade reaches full strength. | +| `--cl-scrollbar-width` | `8px` | Width of the scrollbar lane. `0px` hides it. | +| `--cl-scrollbar-thumb-inset` | `2px` | How far the thumb's paint is held inside that lane. | +| `--cl-scrollbar-thumb` | derived from the palette | Thumb colour at rest. | +| `--cl-scrollbar-thumb-hover` | derived from the above | Thumb colour while the pointer is over the thumb. | +| `--cl-scrollbar-thumb-active` | derived from the above | Thumb colour while the thumb is being dragged. | + +The two lane sizes are in pixels rather than on the `rem` scale, deliberately: a scrollbar is chrome +rather than content, so it should stay the same hairline whether or not the surrounding text scales. +The default is a 4px pill with a 2px track either side. + +The two derived colours reference `--cl-scrollbar-thumb` rather than baking its value in, so setting +the base re-derives both — and either state can still be pinned on its own. + +`hover` and `active` are the **thumb's own** states, not the region's: the colour changes when the +pointer is over the thumb itself, not whenever it is somewhere over the scrolling area. + +Mosaic paints the scrollbar through `::-webkit-scrollbar`, which is what buys a real width and a +thumb colour per interaction state; the standard `scrollbar-color` can express neither, and setting +it would make the engines that _do_ implement the pseudo-elements ignore them. Firefox implements +neither and keeps its platform scrollbar. Everything here is gated on `@media (pointer: fine)`, so +touch platforms keep the native overlay bar they already draw. The gutter is not gated: it is a +layout decision rather than an appearance one. + +One consequence worth knowing before you theme: styling the scrollbar takes macOS out of overlay +mode, so the bar is always visible and always occupies its lane rather than auto-hiding. That is the +cross-platform consistency the tokens exist for, but it is a change from the platform default. To +hide the thumb without giving up the lane, set the rest colour to `transparent` — it then paints +only while the pointer is on it. Note that this is a precise target to find, so it works best where +the fade indicators are already carrying the signal that the region scrolls: + +```css +:root { + --cl-scrollbar-thumb: transparent; +} +``` + +One layout note: the scrollbar takes its lane **inside** a scroller's own padding, so a padded +surface reads as padding plus lane at the inline end. Trim the inline-end padding on the scroller if +you want the scrollbar to sit in the gutter the padding was already holding. + +### Replacing the fade + +Retire it with `mask-image: none` and read the two per-element vars the animations write — +`--cl-scroll-area-progress-start` and `--cl-scroll-area-progress-end`, each describing how much that +edge still has to reveal. They live on the element carrying the atoms and inherit downward. + +```css +.cl-item-group { + mask-image: none; +} +.cl-item-group::before { + content: ''; + position: absolute; + inset: 0 0 auto; + height: 2rem; + pointer-events: none; + background: linear-gradient(to bottom, color-mix(in oklab, var(--cl-color-card-foreground) 28%, transparent), transparent); + opacity: var(--cl-scroll-area-progress-start); +} +``` + +Position such overlays absolutely rather than with `position: sticky` — a sticky pseudo-element +takes space in the scroll flow, which is the layout shift the mask approach avoids. And mix the +scrim from a theme color rather than hardcoding black, which would darken a dark surface and look +identical to the mask it replaced. + +## Accessibility + +Chrome and Firefox make an overflowing scroll container keyboard-focusable on their own; **Safari +does not** (WCAG 2.1.1). `tabindex` isn't a style, so the atoms can't close that gap — set +`tabIndex={0}` yourself on a scroll surface that holds nothing focusable. A group of interactive +rows, like the examples above, needs nothing: tabbing into the content already scrolls it. diff --git a/packages/swingset/src/stories/scroll-area.stories.tsx b/packages/swingset/src/stories/scroll-area.stories.tsx new file mode 100644 index 00000000000..6f53a27a459 --- /dev/null +++ b/packages/swingset/src/stories/scroll-area.stories.tsx @@ -0,0 +1,229 @@ +/** @jsxImportSource @emotion/react */ +import { Avatar } from '@clerk/ui/mosaic/components/avatar'; +import { Item } from '@clerk/ui/mosaic/components/item'; +import { scrollAreaRoot, scrollAreaViewport } from '@clerk/ui/mosaic/components/scroll-area'; +import { space } from '@clerk/ui/mosaic/styles'; +import * as stylex from '@stylexjs/stylex'; +import * as React from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +// Exposes this file's own source (via the `?raw` webpack rule) so each `` example +// renders a code footer with its function's source. See `StoryModule.__source`. +export { default as __source } from './scroll-area.stories?raw'; + +export const meta: StoryMeta = { + group: 'Styles', + title: 'Scroll Area', + source: 'packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts', +}; + +const accounts = [ + { email: 'cameron.walker@gmail.com', organizations: ['Clerk', 'Acme Corporation', 'Globex'] }, + { email: 'cameron@clerk.com', organizations: ['Clerk', 'Initech', 'Umbrella Health'] }, + { email: 'cam@designcloud.io', organizations: ['Clerk', 'DesignCloud'] }, +]; + +function OrganizationRow({ name }: { name: string }) { + return ( + ( + + )} + > + + + + {name[0]} + + + + {name} + + + ); +} + +/** + * The scroll surface, capped in height so it overflows. Everything visible here — both edge + * fades and the scrollbar — is CSS on the one element. + * + * `stylex.props()` returns a `className`, so a class of your own has to be MERGED with it rather + * than spread beside one: whichever comes last in JSX wins outright and silently drops the other. + */ +export function Default() { + const root = stylex.props(scrollAreaRoot); + + return ( +
+ {/* The group pads all four sides, and the scrollbar takes its lane INSIDE that padding, so + the right edge otherwise reads as padding plus lane. Cutting the inline-end padding to + the smallest step lets the scrollbar occupy the gutter the padding was holding, while + still keeping the rows off it. */} + + {accounts.map(({ email, organizations }, index) => ( + + {/* Sibling groups would each contribute their own padding either side of a separator. + These share one group — the scroller — so the gap comes from the separator itself. + `space['2']` is the group's own padding step, so the two stay in sync. */} + {index > 0 ? : null} + + + {email} + + + {organizations.map(name => ( + + ))} + + ))} + +
+ ); +} + +/** + * The same atoms on a surface whose content fits. Nothing is conditional in the markup and no + * measurement runs — an inactive scroll timeline leaves both progress vars at their registered + * `initial-value: 0`, which the mask reads as "no fade", and the browser draws no scrollbar. + * So the resting state costs nothing and there is no "is it scrollable" branch to write. + */ +export function NotScrollable() { + const root = stylex.props(scrollAreaRoot); + + return ( +
+ + {accounts[0].organizations.map(name => ( + + ))} + +
+ ); +} + +/** + * `stable` holds the scrollbar's lane open even when nothing overflows, so content doesn't shift + * sideways the moment it crosses the threshold. Both boxes below hold the same short list; only + * the left one reserves the space. Worth it for content that can change height IN PLACE — a + * filterable or paginated collection — and wasted width otherwise. + */ +export function Gutter() { + const root = stylex.props(scrollAreaRoot); + + return ( +
+ {(['stable', 'auto'] as const).map(gutter => ( +
+

{gutter}

+
+ + {accounts[2].organizations.map(name => ( + + + + + {name[0]} + + + + {name} + + {/* Neither box overflows, so the reserved lane is only legible against something + that reaches the content's right edge — hence the trailing rule. */} +
+ + ))} + +
+
+ ))} +
+ ); +} + +/** + * The scrollbar tokens are global, so setting them on any ancestor retunes every scrolling + * surface beneath it. This one goes wider and warmer; setting `--cl-scrollbar-thumb` to + * `transparent` instead would hide the thumb while keeping its lane. + */ +export function ThemedScrollbar() { + const root = stylex.props(scrollAreaRoot); + + return ( +
+ + {accounts.map(({ email, organizations }) => + organizations.map(name => ( + + )), + )} + +
+ ); +} From 21cdf7b38f016f82f379e264cab7d6338ce592d1 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 31 Jul 2026 17:36:09 -0600 Subject: [PATCH 12/24] docs(swingset): Double the rows in Item's scrolling example Co-Authored-By: Claude Opus 5 (1M context) --- packages/swingset/src/stories/item.stories.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/swingset/src/stories/item.stories.tsx b/packages/swingset/src/stories/item.stories.tsx index f649101ca0b..3a881cb76c6 100644 --- a/packages/swingset/src/stories/item.stories.tsx +++ b/packages/swingset/src/stories/item.stories.tsx @@ -336,7 +336,20 @@ export function Group() { ); } -const organizations = ['Clerk', 'Acme Corporation', 'Globex', 'Initech', 'Umbrella Health', 'DesignCloud']; +const organizations = [ + 'Clerk', + 'Acme Corporation', + 'Globex', + 'Initech', + 'Umbrella Health', + 'DesignCloud', + 'Stark Industries', + 'Wayne Enterprises', + 'Cyberdyne Systems', + 'Soylent Industries', + 'Tyrell Corporation', + 'Weyland-Yutani', +]; // `Item.Group` is the canonical scroll surface, so this shows the atoms doing the minimum: cap a // height, spread them on, and the group fades its own edges. The Scroll Area page under Styles From 007de24b35410c557c7bee339c769a226a566bb6 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 31 Jul 2026 17:54:49 -0600 Subject: [PATCH 13/24] fix(ui): Restore the thumb colour transition to the scroller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blink runs no transition declared on `::-webkit-scrollbar-thumb` — the reason Polaris declares its own on the scroller. Scoping hover to the thumb moved the transition onto the pseudo-element with it, which silently killed the fade. Putting it back on the scroller draws the line clearly: a change made ON THE SCROLLER animates and the thumb inherits it, a change made on the thumb itself can only snap. So `-hover` and `-active` are instant by construction, while a consumer retargeting `--cl-scrollbar-thumb` from the region's `:hover` gets a real fade. Confirmed by sampling painted pixels mid-transition. Also declares `::-webkit-scrollbar-track` transparent. Opting into a custom scrollbar makes the track ours, and leaving it undeclared falls back to the UA's painting for that part — which shows through as a dark rail the moment the thumb is less than opaque. Adds the hover-reveal example this all came out of, and documents the one trap in it: `transparent` is `rgba(0, 0, 0, 0)`, so fading out of the keyword drags the thumb through dark half-transparent greys. The rest value is the reveal colour at zero alpha instead. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/lazy-pans-tickle.md | 2 + packages/swingset/src/lib/registry.ts | 2 + .../swingset/src/stories/item.stories.tsx | 10 +- packages/swingset/src/stories/scroll-area.mdx | 59 ++++- .../src/stories/scroll-area.stories.tsx | 222 ++++++++++++------ .../scroll-area/scroll-area.styles.ts | 48 ++-- .../scroll-area/scroll-area.test.ts | 2 +- 7 files changed, 242 insertions(+), 103 deletions(-) diff --git a/.changeset/lazy-pans-tickle.md b/.changeset/lazy-pans-tickle.md index 957c6a80845..1649abb8dc8 100644 --- a/.changeset/lazy-pans-tickle.md +++ b/.changeset/lazy-pans-tickle.md @@ -21,3 +21,5 @@ Two breaking notes if you were already theming these: - `--cl-scroll-fade-inset` is removed. The mask now derives its inset from `--cl-scrollbar-width`, which closes the gap where the edge fade covered part of the scrollbar. Firefox implements neither `::-webkit-scrollbar` nor an equivalent, so it keeps its platform scrollbar; touch platforms keep their native overlay bar as before. On macOS, styling the scrollbar takes it out of overlay mode, so the bar is always visible and always occupies its lane. + +The thumb's `hover` and `active` colours switch instantly rather than fading. Blink runs no transition declared on `::-webkit-scrollbar-thumb`, so the transition lives on the scroller and the thumb inherits the animating value — meaning a change made on the scroller fades, while one made on the thumb itself can only snap. Retargeting `--cl-scrollbar-thumb` from a region's own `:hover` (to fade a scrollbar in) does transition. diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index b7425e2e215..9f3493a3dc6 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -98,6 +98,7 @@ import { meta as popoverMeta } from '../stories/popover.stories'; import { Default as ScrollAreaDefault, Gutter as ScrollAreaGutter, + HoverReveal as ScrollAreaHoverReveal, meta as scrollAreaMeta, NotScrollable as ScrollAreaNotScrollable, ThemedScrollbar as ScrollAreaThemedScrollbar, @@ -227,6 +228,7 @@ const scrollAreaModule: StoryModule = { Default: ScrollAreaDefault, NotScrollable: ScrollAreaNotScrollable, Gutter: ScrollAreaGutter, + HoverReveal: ScrollAreaHoverReveal, ThemedScrollbar: ScrollAreaThemedScrollbar, }; diff --git a/packages/swingset/src/stories/item.stories.tsx b/packages/swingset/src/stories/item.stories.tsx index 3a881cb76c6..f0b8ca7fc19 100644 --- a/packages/swingset/src/stories/item.stories.tsx +++ b/packages/swingset/src/stories/item.stories.tsx @@ -3,7 +3,6 @@ import { Avatar } from '@clerk/ui/mosaic/components/avatar'; import { Button } from '@clerk/ui/mosaic/components/button'; import { Item } from '@clerk/ui/mosaic/components/item'; import { scrollAreaRoot, scrollAreaViewport } from '@clerk/ui/mosaic/components/scroll-area'; -import { space } from '@clerk/ui/mosaic/styles'; import * as stylex from '@stylexjs/stylex'; import * as React from 'react'; @@ -362,15 +361,10 @@ export function Scrolling() { return (
- {/* The group pads all four sides and the scrollbar takes its lane INSIDE that padding, so - the right edge otherwise reads as padding plus lane. */} - + {organizations.map(name => ( -Neither box above overflows, so only `stable` is holding a lane open — watch the trailing rule, -which stops 8px short of where `auto` puts it. That 8px is the shift `auto` would produce the moment -the content grew past the box. +Add the rows and watch the trailing rules: `auto` jumps its content left by the lane's width as the +list starts overflowing, while `stable` — which was already holding that lane open — doesn't move. +That jump is the entire thing `stable` exists to prevent. Two conditions must **both** hold for the two to differ at all: space-consuming scrollbars, and content that can stop overflowing. Where the platform overlays its scrollbars, they render -identically. +identically, which is why `auto` is the default. + +### Reveal on hover + + + +Mosaic's `hover` token is the **thumb's** state, so it can't express this on its own — a thumb you +can't see isn't a target you can find. Region hover is two lines of your own CSS instead: + +```css +.my-scroller { + /* The reveal colour at zero alpha — NOT the `transparent` keyword. */ + --cl-scrollbar-thumb: oklch(from var(--cl-color-neutral-faded) l c h / 0); +} +.my-scroller:hover { + --cl-scrollbar-thumb: var(--cl-color-neutral-faded); +} +``` + +This one does fade. The thumb's colour resolves through an `@property`-registered custom property +that the SCROLLER transitions and the thumb inherits, so retargeting the token from the region is a +computed-value change the transition picks up. The thumb's own `-hover` / `-active` can't do this — +see [Styling](#styling). The lane stays reserved throughout — only the +paint is conditional, so nothing reflows on the way in or out. + +Reach for the zero-alpha colour rather than `transparent` in any fade like this one. `transparent` +is defined as `rgba(0, 0, 0, 0)` — transparent **black** — so interpolating out of it drags the +thumb through a run of dark, half-transparent greys, and a bar that should be fading in cleanly +reads as dirty instead. Relative colour syntax (`oklch(from … l c h / 0)`) keeps the token's own +channels and drops only the alpha, so the transition moves along a single axis. ### Theming the scrollbar @@ -93,6 +125,12 @@ identically. storyModule={ScrollAreaStories} /> +Each state gets a colour of its own here — amber at rest, pink under the pointer, violet while +dragging — which is far louder than anything you'd ship, but tells the three apart at a glance. +Hover the thumb, then drag it. Nothing changes while the pointer is merely over the region: these +are the thumb's own states, and they switch instantly rather than fading, for the reason described +under [Styling](#styling). + ## Styling The fade is driven by two scroll-driven animations — no scroll listener, no measurement, nothing at @@ -121,6 +159,13 @@ the base re-derives both — and either state can still be pinned on its own. `hover` and `active` are the **thumb's own** states, not the region's: the colour changes when the pointer is over the thumb itself, not whenever it is somewhere over the scrolling area. +Those two states switch instantly, and can't be made to animate. Blink doesn't run transitions +declared on `::-webkit-scrollbar-thumb`, so the transition lives on the scroller and the thumb +inherits the animating value — which means a change made **on the scroller** fades, while a change +made on the thumb itself can only snap. That's the line to keep in mind when theming: retargeting +`--cl-scrollbar-thumb` from the region (as [Reveal on hover](#reveal-on-hover) does) transitions; +`-hover` and `-active` do not. + Mosaic paints the scrollbar through `::-webkit-scrollbar`, which is what buys a real width and a thumb colour per interaction state; the standard `scrollbar-color` can express neither, and setting it would make the engines that _do_ implement the pseudo-elements ignore them. Firefox implements @@ -142,8 +187,10 @@ the fade indicators are already carrying the signal that the region scrolls: ``` One layout note: the scrollbar takes its lane **inside** a scroller's own padding, so a padded -surface reads as padding plus lane at the inline end. Trim the inline-end padding on the scroller if -you want the scrollbar to sit in the gutter the padding was already holding. +surface reads as padding plus lane at the inline end. Trimming the scroller's inline-end padding to +compensate is tempting, but only safe alongside `gutter: 'stable'` — with `auto` the lane is there +only while the content overflows, so the trimmed padding collapses to nothing the moment it doesn't, +and the rows sit flush against the edge. Left alone, the extra lane is the safer asymmetry. ### Replacing the fade diff --git a/packages/swingset/src/stories/scroll-area.stories.tsx b/packages/swingset/src/stories/scroll-area.stories.tsx index 6f53a27a459..0a64da12ed7 100644 --- a/packages/swingset/src/stories/scroll-area.stories.tsx +++ b/packages/swingset/src/stories/scroll-area.stories.tsx @@ -1,5 +1,6 @@ /** @jsxImportSource @emotion/react */ import { Avatar } from '@clerk/ui/mosaic/components/avatar'; +import { Button } from '@clerk/ui/mosaic/components/button'; import { Item } from '@clerk/ui/mosaic/components/item'; import { scrollAreaRoot, scrollAreaViewport } from '@clerk/ui/mosaic/components/scroll-area'; import { space } from '@clerk/ui/mosaic/styles'; @@ -62,6 +63,10 @@ function OrganizationRow({ name }: { name: string }) { * * `stylex.props()` returns a `className`, so a class of your own has to be MERGED with it rather * than spread beside one: whichever comes last in JSX wins outright and silently drops the other. + * + * The border marking the scroll container is on the ROOT, not the viewport: a mask applies to the + * element's whole rendering, borders included, so a border on the viewport would fade out at the + * same edges its content does. The root wraps the viewport exactly, so it outlines the same box. */ export function Default() { const root = stylex.props(scrollAreaRoot); @@ -69,17 +74,10 @@ export function Default() { return (
- {/* The group pads all four sides, and the scrollbar takes its lane INSIDE that padding, so - the right edge otherwise reads as padding plus lane. Cutting the inline-end padding to - the smallest step lets the scrollbar occupy the gutter the padding was holding, while - still keeping the rows off it. */} - + {accounts.map(({ email, organizations }, index) => ( {/* Sibling groups would each contribute their own padding either side of a separator. @@ -116,13 +114,10 @@ export function NotScrollable() { return (
- + {accounts[0].organizations.map(name => ( - {(['stable', 'auto'] as const).map(gutter => ( -
-

{gutter}

+
+ + +
+ {(['stable', 'auto'] as const).map(gutter => (
- - {accounts[2].organizations.map(name => ( - - - - - {name[0]} - - - - {name} - - {/* Neither box overflows, so the reserved lane is only legible against something - that reaches the content's right edge — hence the trailing rule. */} -
- - ))} - +

{gutter}

+
+ + {names.map(name => ( + + + + + {name[0]} + + + + {name} + + {/* The shift is only legible against something that reaches the content's + right edge — hence the trailing rule. */} +
+ + ))} + +
-
- ))} + ))} +
); } +const themedRows = [ + 'Clerk', + 'Acme Corporation', + 'Globex', + 'Initech', + 'Umbrella Health', + 'DesignCloud', + 'Stark Industries', + 'Wayne Enterprises', + 'Cyberdyne Systems', + 'Soylent Industries', + 'Tyrell Corporation', + 'Weyland-Yutani', + 'Massive Dynamic', + 'Aperture Science', + 'Black Mesa', + 'Oscorp', +]; + /** - * The scrollbar tokens are global, so setting them on any ancestor retunes every scrolling - * surface beneath it. This one goes wider and warmer; setting `--cl-scrollbar-thumb` to - * `transparent` instead would hide the thumb while keeping its lane. + * A scrollbar that stays invisible until you reach the region. Mosaic's own `hover` token is the + * THUMB's state, so it can't express this — a thumb you can't see is not a target you can find. + * Region hover is two lines of your own CSS instead: park the token at `transparent` and override + * it on the container's `:hover`. + * + * This one fades. The thumb's colour resolves through an `@property`-registered custom property + * that the SCROLLER transitions and the thumb inherits, so retargeting the token from the region is + * a computed-value change the transition picks up. The thumb's own `-hover` / `-active` cannot do + * the same — Blink runs no transition declared on `::-webkit-scrollbar-thumb`, so those snap. + * + * The rest value is the reveal colour at zero alpha, NOT the `transparent` keyword. `transparent` + * is `rgba(0, 0, 0, 0)` — transparent BLACK — so interpolating out of it drags the thumb through a + * series of dark, half-transparent greys and the bar reads as dirty on the way in. Relative colour + * syntax takes the token's own channels and drops only the alpha, so the fade moves along one axis. + * + * The lane stays reserved throughout: only the thumb's paint is conditional, so nothing reflows on + * the way in or out. + */ +export function HoverReveal() { + const root = stylex.props(scrollAreaRoot); + + return ( +
+ + {themedRows.map(name => ( + + ))} + +
+ ); +} + +/** + * The scrollbar tokens are global, so setting them on any ancestor retunes every scrolling surface + * beneath it. This one goes wider and gives each state a colour of its own — amber at rest, pink + * under the pointer, violet while dragging — which is deliberately louder than anything you'd ship, + * so the three are told apart at a glance. Hover the thumb, then drag it. Both switch instantly: + * a value changed on the thumb itself cannot transition. + * + * The states are the THUMB's, not the region's: nothing changes until the pointer is on the bar + * itself. Setting `--cl-scrollbar-thumb` to `transparent` would hide it at rest while keeping its + * lane, and the other two states would still work. */ export function ThemedScrollbar() { const root = stylex.props(scrollAreaRoot); @@ -201,28 +280,25 @@ export function ThemedScrollbar() { return (
- - {accounts.map(({ email, organizations }) => - organizations.map(name => ( - - )), - )} + + {themedRows.map(name => ( + + ))}
); diff --git a/packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts b/packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts index 9fcd1a3fae3..1a4fe513ffd 100644 --- a/packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts +++ b/packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts @@ -74,6 +74,31 @@ const styles = stylex.create({ overflowY: 'auto', }, + /** + * The thumb's colour, produced HERE on the scroller rather than on the pseudo-element that + * paints it, because Blink does not run transitions declared on `::-webkit-scrollbar-thumb` — + * verified by hand, and the reason Polaris declares its own on the scroller too. A registered + * custom property set here animates and inherits into the pseudo-element, which only reads it. + * + * The consequence is worth stating plainly, because it decides which states can move: a change + * made ON THE SCROLLER animates, and a change made on the thumb itself can only snap. So the + * thumb's own `:hover` / `:active` below are instant by construction, while anything driven from + * the scroller — including a consumer retargeting `--cl-scrollbar-thumb` on the region's + * `:hover` to fade the bar in — transitions through this declaration. + * + * `linear` because this is a colour: an ease on top of an already perceptually non-uniform + * interpolation only makes the midpoint drag. + */ + thumbColor: { + '--_cl-scrollbar-thumb-color': { + default: null, + '@media (pointer: fine)': scrollbarVars['--cl-scrollbar-thumb'], + }, + transitionDuration: { default: null, '@media (pointer: fine)': durationVars['--cl-duration-base'] }, + transitionProperty: { default: null, '@media (pointer: fine)': '--_cl-scrollbar-thumb-color' }, + transitionTimingFunction: { default: null, '@media (pointer: fine)': 'linear' }, + }, + /** * The scrollbar's own paint. Only the lane's size and the thumb are styled — the track is left * alone, so the thumb reads as floating over the content rather than riding in a rail. @@ -108,16 +133,6 @@ const styles = stylex.create({ width: { default: null, '@media (pointer: fine)': scrollbarWidth }, }, '::-webkit-scrollbar-thumb': { - // The colour is routed through an `@property`-registered var rather than transitioned as - // `background-color` directly, because `background-color` on a scrollbar part is not an - // animatable property in Blink — the registered custom property is, and the pseudo-element - // reads it. Longer leaving than arriving, per the duration tokens: reaching the thumb is - // direct pointer feedback, its decay is not. `linear` because this is a colour — an ease on - // top of an already perceptually non-uniform interpolation only makes the midpoint drag. - '--_cl-scrollbar-thumb-color': { - default: null, - '@media (pointer: fine)': scrollbarVars['--cl-scrollbar-thumb'], - }, // A transparent border clipped away is how you inset a pill thumb: the lane keeps its full // width for hit-testing while the paint shrinks to the middle of it. Both Polaris and // `references/stylex-ui` arrive at this independently — a scrollbar pseudo-element has no @@ -139,9 +154,6 @@ const styles = stylex.create({ '@media (forced-colors: active)': 'ButtonBorder', }, }, - transitionDuration: { default: null, '@media (pointer: fine)': durationVars['--cl-duration-base'] }, - transitionProperty: { default: null, '@media (pointer: fine)': '--_cl-scrollbar-thumb-color' }, - transitionTimingFunction: { default: null, '@media (pointer: fine)': 'linear' }, }, // eslint-disable-next-line @stylexjs/valid-styles -- StyleX's pseudo-element allowlist holds the bare selectors only; it compiles the combined form correctly. See the note above. '::-webkit-scrollbar-thumb:active': { @@ -156,8 +168,13 @@ const styles = stylex.create({ default: null, '@media (pointer: fine)': scrollbarVars['--cl-scrollbar-thumb-hover'], }, - // Arriving is direct pointer feedback and reads better a touch quicker than the decay. - transitionDuration: { default: null, '@media (pointer: fine)': durationVars['--cl-duration-fast'] }, + }, + // Declared transparent rather than left alone. Opting into a custom scrollbar at all means the + // track is OURS, and an undeclared one falls back to the UA's own painting for the part — + // which shows through the moment the thumb is anything less than opaque, and reads as a dark + // rail behind a thumb that was supposed to be invisible. "Unstyled" has to be said out loud. + '::-webkit-scrollbar-track': { + backgroundColor: { default: null, '@media (pointer: fine)': 'transparent' }, }, }, @@ -256,6 +273,7 @@ export type ScrollAreaGutter = keyof typeof gutters; export function scrollAreaViewport(gutter: ScrollAreaGutter = 'auto') { return [ styles.viewport, + styles.thumbColor, styles.scrollbar, styles.mask, styles.indicators, diff --git a/packages/ui/src/mosaic/components/scroll-area/scroll-area.test.ts b/packages/ui/src/mosaic/components/scroll-area/scroll-area.test.ts index 46e192cf535..793c3c21dec 100644 --- a/packages/ui/src/mosaic/components/scroll-area/scroll-area.test.ts +++ b/packages/ui/src/mosaic/components/scroll-area/scroll-area.test.ts @@ -6,7 +6,7 @@ import { scrollAreaVars, scrollbarThumbVars } from './scroll-area.vars.stylex'; describe('Mosaic scroll area styles', () => { it('composes the viewport atoms into one spreadable set', () => { - expect(scrollAreaViewport()).toHaveLength(6); + expect(scrollAreaViewport()).toHaveLength(7); expect(scrollAreaRoot).toBeDefined(); }); From fa253464b0db83229101d61564c763c4585685f3 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 31 Jul 2026 17:56:32 -0600 Subject: [PATCH 14/24] docs(swingset): Tighten the scroll example borders to the smallest radius MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 12px corner against an 8px scrollbar lane reads as a mismatch — the bar runs straight past the curve. `--cl-radius-inner` keeps the box out of the way of the thing being demonstrated. Real surfaces would decorate around this. Co-Authored-By: Claude Opus 5 (1M context) --- .../swingset/src/stories/item.stories.tsx | 5 +++-- .../src/stories/scroll-area.stories.tsx | 21 ++++++++++--------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/swingset/src/stories/item.stories.tsx b/packages/swingset/src/stories/item.stories.tsx index f0b8ca7fc19..242fb970330 100644 --- a/packages/swingset/src/stories/item.stories.tsx +++ b/packages/swingset/src/stories/item.stories.tsx @@ -3,6 +3,7 @@ import { Avatar } from '@clerk/ui/mosaic/components/avatar'; import { Button } from '@clerk/ui/mosaic/components/button'; import { Item } from '@clerk/ui/mosaic/components/item'; import { scrollAreaRoot, scrollAreaViewport } from '@clerk/ui/mosaic/components/scroll-area'; +import { radiusVars } from '@clerk/ui/mosaic/styles'; import * as stylex from '@stylexjs/stylex'; import * as React from 'react'; @@ -361,8 +362,8 @@ export function Scrolling() { return (
{organizations.map(name => ( diff --git a/packages/swingset/src/stories/scroll-area.stories.tsx b/packages/swingset/src/stories/scroll-area.stories.tsx index 0a64da12ed7..07a3897cce4 100644 --- a/packages/swingset/src/stories/scroll-area.stories.tsx +++ b/packages/swingset/src/stories/scroll-area.stories.tsx @@ -3,7 +3,7 @@ import { Avatar } from '@clerk/ui/mosaic/components/avatar'; import { Button } from '@clerk/ui/mosaic/components/button'; import { Item } from '@clerk/ui/mosaic/components/item'; import { scrollAreaRoot, scrollAreaViewport } from '@clerk/ui/mosaic/components/scroll-area'; -import { space } from '@clerk/ui/mosaic/styles'; +import { radiusVars, space } from '@clerk/ui/mosaic/styles'; import * as stylex from '@stylexjs/stylex'; import * as React from 'react'; @@ -74,8 +74,8 @@ export function Default() { return (
{accounts.map(({ email, organizations }, index) => ( @@ -114,8 +114,8 @@ export function NotScrollable() { return (
{accounts[0].organizations.map(name => ( @@ -162,8 +162,8 @@ export function Gutter() {

{gutter}

{names.map(name => ( @@ -244,12 +244,12 @@ export function HoverReveal() { return (
{themedRows.map(name => ( @@ -280,10 +280,11 @@ export function ThemedScrollbar() { return (
Date: Fri, 31 Jul 2026 18:02:53 -0600 Subject: [PATCH 15/24] docs(swingset): Give the theming example a step that actually animates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Its three colours were all thumb states, which snap by construction — no choice of colour makes them move. Added a scroller-driven step (rest amber → teal on region hover) so the example shows both halves of the rule: a change made on the scroller fades, a change made on the thumb does not. Verified by sampling mid-transition pixels on the first and confirming the second jumps. Notes in the description why the values are `oklch()` literals — one space keeps the animated step well defined — and points at the `transparent` trap rather than repeating it. Co-Authored-By: Claude Opus 5 (1M context) --- packages/swingset/src/stories/scroll-area.mdx | 17 +++++--- .../src/stories/scroll-area.stories.tsx | 43 +++++++++++-------- 2 files changed, 37 insertions(+), 23 deletions(-) diff --git a/packages/swingset/src/stories/scroll-area.mdx b/packages/swingset/src/stories/scroll-area.mdx index f511cf4aca4..885b93f7214 100644 --- a/packages/swingset/src/stories/scroll-area.mdx +++ b/packages/swingset/src/stories/scroll-area.mdx @@ -125,11 +125,18 @@ channels and drops only the alpha, so the transition moves along a single axis. storyModule={ScrollAreaStories} /> -Each state gets a colour of its own here — amber at rest, pink under the pointer, violet while -dragging — which is far louder than anything you'd ship, but tells the three apart at a glance. -Hover the thumb, then drag it. Nothing changes while the pointer is merely over the region: these -are the thumb's own states, and they switch instantly rather than fading, for the reason described -under [Styling](#styling). +Each step gets a colour of its own here — far louder than anything you'd ship, but told apart at a +glance. Move the pointer into the region, then onto the bar, then drag it. + +Only the first of those moves animates, and the reason is structural rather than chromatic: amber → +teal changes the region's own rest colour, so it happens on the scroller, where the transition +lives. Pink and violet are the thumb's own states, so they switch instantly however they're written +(see [Styling](#styling)). + +Every value here is an `oklch()` literal, which is what keeps the animated step well defined — the +registered property interpolates between two colours in one space rather than guessing across +notations. The trap to avoid is `transparent`, for the reason given under +[Reveal on hover](#reveal-on-hover). ## Styling diff --git a/packages/swingset/src/stories/scroll-area.stories.tsx b/packages/swingset/src/stories/scroll-area.stories.tsx index 07a3897cce4..8bd0d6c92e7 100644 --- a/packages/swingset/src/stories/scroll-area.stories.tsx +++ b/packages/swingset/src/stories/scroll-area.stories.tsx @@ -265,14 +265,20 @@ export function HoverReveal() { /** * The scrollbar tokens are global, so setting them on any ancestor retunes every scrolling surface - * beneath it. This one goes wider and gives each state a colour of its own — amber at rest, pink - * under the pointer, violet while dragging — which is deliberately louder than anything you'd ship, - * so the three are told apart at a glance. Hover the thumb, then drag it. Both switch instantly: - * a value changed on the thumb itself cannot transition. + * beneath it. This one goes wider and gives each step a colour of its own — deliberately louder + * than anything you'd ship, so they're told apart at a glance. Move the pointer into the region, + * then onto the bar, then drag it. * - * The states are the THUMB's, not the region's: nothing changes until the pointer is on the bar - * itself. Setting `--cl-scrollbar-thumb` to `transparent` would hide it at rest while keeping its - * lane, and the other two states would still work. + * Only the FIRST of those moves animates, and the reason is structural rather than chromatic: + * amber → teal is a change to the region's own rest colour, so it happens on the scroller, where + * the transition lives. Pink and violet are the thumb's own states, and Blink runs no transition + * declared on `::-webkit-scrollbar-thumb`, so those switch instantly however they're written. + * + * Every value here is an `oklch()` literal, which is what keeps the one animated step well + * defined — the registered property interpolates between two colours in a single space rather than + * guessing across notations. The trap to avoid is `transparent`: it means `rgba(0, 0, 0, 0)`, so + * fading out of it travels through dark greys. Use the target colour at zero alpha instead, as + * `HoverReveal` above does. */ export function ThemedScrollbar() { const root = stylex.props(scrollAreaRoot); @@ -281,17 +287,18 @@ export function ThemedScrollbar() {
{themedRows.map(name => ( From 041f7a90ee299020818883fc8f76eacdb11131b2 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 31 Jul 2026 18:09:48 -0600 Subject: [PATCH 16/24] docs(swingset): Quiet the hover reveal and slow the theming demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reveal jumped straight to full `--cl-color-neutral-faded`, which spent the contrast up front and left the thumb's own hover and active with nowhere to go. Scoping the override to `:not(:hover)` means reaching the region simply stops hiding the bar, so the revealed state is Mosaic's ordinary rest colour and the thumb's states read as a step up from it, exactly as on an unstyled scroll area. Measured: invisible, then 78 → 101 → 126. The theming example's one animated step runs at `--cl-duration-base`, which is 0.15s — over before you finish moving the pointer in, and on a scrollbar off to one side that reads as nothing happening. Slowed to 0.6s in that example only. Neither Item nor Avatar reads a duration token, so nothing else stretches. Co-Authored-By: Claude Opus 5 (1M context) --- packages/swingset/src/stories/scroll-area.mdx | 5 +++ .../src/stories/scroll-area.stories.tsx | 33 +++++++++++++------ 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/packages/swingset/src/stories/scroll-area.mdx b/packages/swingset/src/stories/scroll-area.mdx index 885b93f7214..34d506fb72d 100644 --- a/packages/swingset/src/stories/scroll-area.mdx +++ b/packages/swingset/src/stories/scroll-area.mdx @@ -133,6 +133,11 @@ teal changes the region's own rest colour, so it happens on the scroller, where lives. Pink and violet are the thumb's own states, so they switch instantly however they're written (see [Styling](#styling)). +This example deliberately slows the transition to `0.6s`. At Mosaic's real `--cl-duration-base` of +`0.15s` the fade is over before you've finished moving the pointer in, which is easy to read as +nothing happening at all — a scrollbar is small and off to one side, so it gets far less of your +attention than a control you're aiming at. + Every value here is an `oklch()` literal, which is what keeps the animated step well defined — the registered property interpolates between two colours in one space rather than guessing across notations. The trap to avoid is `transparent`, for the reason given under diff --git a/packages/swingset/src/stories/scroll-area.stories.tsx b/packages/swingset/src/stories/scroll-area.stories.tsx index 8bd0d6c92e7..185c40e574f 100644 --- a/packages/swingset/src/stories/scroll-area.stories.tsx +++ b/packages/swingset/src/stories/scroll-area.stories.tsx @@ -3,7 +3,7 @@ import { Avatar } from '@clerk/ui/mosaic/components/avatar'; import { Button } from '@clerk/ui/mosaic/components/button'; import { Item } from '@clerk/ui/mosaic/components/item'; import { scrollAreaRoot, scrollAreaViewport } from '@clerk/ui/mosaic/components/scroll-area'; -import { radiusVars, space } from '@clerk/ui/mosaic/styles'; +import { colorVars, radiusVars, space } from '@clerk/ui/mosaic/styles'; import * as stylex from '@stylexjs/stylex'; import * as React from 'react'; @@ -219,21 +219,31 @@ const themedRows = [ 'Oscorp', ]; +// Mosaic's own default rest colour, written out so the hidden state can be THAT colour at zero +// alpha. Fading between two different colours would swing the hue as the bar appears; this way the +// only channel moving is alpha. +const thumbRest = `color-mix(in oklab, ${colorVars['--cl-color-neutral-faded']}, ${colorVars['--cl-color-card']} 55%)`; + /** * A scrollbar that stays invisible until you reach the region. Mosaic's own `hover` token is the * THUMB's state, so it can't express this — a thumb you can't see is not a target you can find. - * Region hover is two lines of your own CSS instead: park the token at `transparent` and override - * it on the container's `:hover`. + * Region hover is one rule of your own CSS instead. + * + * Written as `:not(:hover)` so that reaching the region simply stops overriding, leaving the thumb + * at Mosaic's ordinary rest colour. That keeps the reveal deliberately quiet: it restores the + * default rather than jumping to something louder, so the thumb's own `-hover` and `-active` still + * read as a step UP from it, exactly as they do on an unstyled scroll area. Revealing straight to a + * strong colour spends the contrast early and leaves the thumb's own states nowhere to go. * * This one fades. The thumb's colour resolves through an `@property`-registered custom property * that the SCROLLER transitions and the thumb inherits, so retargeting the token from the region is * a computed-value change the transition picks up. The thumb's own `-hover` / `-active` cannot do * the same — Blink runs no transition declared on `::-webkit-scrollbar-thumb`, so those snap. * - * The rest value is the reveal colour at zero alpha, NOT the `transparent` keyword. `transparent` + * The hidden value is the rest colour at zero alpha, NOT the `transparent` keyword. `transparent` * is `rgba(0, 0, 0, 0)` — transparent BLACK — so interpolating out of it drags the thumb through a * series of dark, half-transparent greys and the bar reads as dirty on the way in. Relative colour - * syntax takes the token's own channels and drops only the alpha, so the fade moves along one axis. + * syntax takes the colour's own channels and drops only the alpha. * * The lane stays reserved throughout: only the thumb's paint is conditional, so nothing reflows on * the way in or out. @@ -245,10 +255,7 @@ export function HoverReveal() {
@@ -272,7 +279,8 @@ export function HoverReveal() { * Only the FIRST of those moves animates, and the reason is structural rather than chromatic: * amber → teal is a change to the region's own rest colour, so it happens on the scroller, where * the transition lives. Pink and violet are the thumb's own states, and Blink runs no transition - * declared on `::-webkit-scrollbar-thumb`, so those switch instantly however they're written. + * declared on `::-webkit-scrollbar-thumb`, so those switch instantly however they're written. The + * demo slows the transition well past Mosaic's default to make that first step legible. * * Every value here is an `oklch()` literal, which is what keeps the one animated step well * defined — the registered property interpolates between two colours in a single space rather than @@ -290,6 +298,11 @@ export function ThemedScrollbar() { css={{ '--cl-scrollbar-width': '14px', '--cl-scrollbar-thumb-inset': '4px', + // Slowed well past Mosaic's own `base` step purely so the one animated transition is + // impossible to miss — at the real 0.15s the amber → teal step is over before you've + // finished moving the pointer, which reads as no animation at all. Nothing else in these + // rows reads a duration token, so this only stretches the scrollbar. + '--cl-duration-base': '0.6s', // Rest, and the one step that fades: it is set on the scroller, so the scroller's // transition carries it. '--cl-scrollbar-thumb': 'oklch(0.77 0.16 70)', From 9ca546c7a49d4b1b6eaf3278478677e13bbf6288 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 31 Jul 2026 18:12:46 -0600 Subject: [PATCH 17/24] docs(swingset): Match the hover-reveal code sample to the example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The snippet still showed the two-rule form that revealed straight to full `--cl-color-neutral-faded` — the loud version the example moved away from. It now matches: one `:not(:hover)` rule hiding the default rest colour at zero alpha, plus why hiding beats revealing (it leaves the thumb's own states their headroom). The sample names the colour in an intermediate custom property for readability, which the story inlines; checked that the two resolve identically before shipping it as copy-paste. Co-Authored-By: Claude Opus 5 (1M context) --- packages/swingset/src/stories/scroll-area.mdx | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/packages/swingset/src/stories/scroll-area.mdx b/packages/swingset/src/stories/scroll-area.mdx index 34d506fb72d..761c3c1fdb7 100644 --- a/packages/swingset/src/stories/scroll-area.mdx +++ b/packages/swingset/src/stories/scroll-area.mdx @@ -94,28 +94,36 @@ identically, which is why `auto` is the default. /> Mosaic's `hover` token is the **thumb's** state, so it can't express this on its own — a thumb you -can't see isn't a target you can find. Region hover is two lines of your own CSS instead: +can't see isn't a target you can find. Region hover is one rule of your own CSS instead: ```css .my-scroller { - /* The reveal colour at zero alpha — NOT the `transparent` keyword. */ - --cl-scrollbar-thumb: oklch(from var(--cl-color-neutral-faded) l c h / 0); + /* Mosaic's own rest colour, named once so the hidden state can be that same colour at zero + alpha. Fading between two different colours would swing the hue as the bar appears. */ + --thumb-rest: color-mix(in oklab, var(--cl-color-neutral-faded), var(--cl-color-card) 55%); } -.my-scroller:hover { - --cl-scrollbar-thumb: var(--cl-color-neutral-faded); + +/* `:not(:hover)`, so reaching the region simply stops overriding. */ +.my-scroller:not(:hover) { + --cl-scrollbar-thumb: oklch(from var(--thumb-rest) l c h / 0); } ``` +Hiding on `:not(:hover)` rather than revealing on `:hover` is what keeps this quiet: the revealed +state is just Mosaic's ordinary rest colour, so the thumb's own `-hover` and `-active` still read as +a step up from it, exactly as they do on a scroll area you haven't touched. Revealing straight to a +strong colour spends the contrast on arrival and leaves those two states nowhere to go. + This one does fade. The thumb's colour resolves through an `@property`-registered custom property that the SCROLLER transitions and the thumb inherits, so retargeting the token from the region is a computed-value change the transition picks up. The thumb's own `-hover` / `-active` can't do this — -see [Styling](#styling). The lane stays reserved throughout — only the -paint is conditional, so nothing reflows on the way in or out. +see [Styling](#styling). The lane stays reserved throughout — only the paint is conditional, so +nothing reflows on the way in or out. Reach for the zero-alpha colour rather than `transparent` in any fade like this one. `transparent` is defined as `rgba(0, 0, 0, 0)` — transparent **black** — so interpolating out of it drags the thumb through a run of dark, half-transparent greys, and a bar that should be fading in cleanly -reads as dirty instead. Relative colour syntax (`oklch(from … l c h / 0)`) keeps the token's own +reads as dirty instead. Relative colour syntax (`oklch(from … l c h / 0)`) keeps the colour's own channels and drops only the alpha, so the transition moves along a single axis. ### Theming the scrollbar From afa09e2db37d18fa2dc5336aefb85b43a0d0cbd7 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 31 Jul 2026 18:27:22 -0600 Subject: [PATCH 18/24] feat(ui): Dim the scrollbar thumb until the pointer reaches the region MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `--cl-scrollbar-thumb-idle`, a fourth colour sitting a notch below the base, used while the pointer is elsewhere. The four now run quietest to loudest: idle, base on region hover, then the thumb's own hover and active. Idle → base is set on the scroller, so it is the one step that fades. That also collapses the hover-reveal recipe to a single declaration — `--cl-scrollbar-thumb-idle: oklch(from var(--cl-scrollbar-thumb) l c h / 0)` — where the example previously needed a `:not(:hover)` rule of its own. Adds `--cl-scrollbar-thumb-offset` (default 1px) to nudge the thumb toward the content. A scrollbar's lane is placed by the browser at the inline end of the padding box and takes no margin, offset, or transform, so the only way to move anything is to move the thumb inside its lane: the offset comes off the inset on one side and goes onto the other, shifting the pill without resizing it. Logical properties, so the nudge stays on the content side in RTL, and the near side is floored at 0 — a negative border width is invalid and would drop the declaration back to `medium`, which looks like a broken thumb rather than a slightly large token. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/lazy-pans-tickle.md | 6 ++- packages/swingset/src/stories/scroll-area.mdx | 52 +++++++++---------- .../src/stories/scroll-area.stories.tsx | 35 ++++--------- .../scroll-area/scroll-area.styles.ts | 37 +++++++++++-- .../scroll-area/scroll-area.test.ts | 2 + packages/ui/src/mosaic/tokens.stylex.ts | 33 ++++++++---- 6 files changed, 100 insertions(+), 65 deletions(-) diff --git a/.changeset/lazy-pans-tickle.md b/.changeset/lazy-pans-tickle.md index 1649abb8dc8..e150d0be609 100644 --- a/.changeset/lazy-pans-tickle.md +++ b/.changeset/lazy-pans-tickle.md @@ -9,11 +9,15 @@ New tokens, applied to every scrolling surface at once: | Token | Default | | ----------------------------- | ------------------------ | | `--cl-scrollbar-thumb` | derived from the palette | +| `--cl-scrollbar-thumb-idle` | derived from the above | | `--cl-scrollbar-thumb-hover` | derived from the above | | `--cl-scrollbar-thumb-active` | derived from the above | | `--cl-scrollbar-thumb-inset` | `2px` | +| `--cl-scrollbar-thumb-offset` | `1px` | -Setting `--cl-scrollbar-thumb: transparent` hides the thumb without giving up its lane — it paints only while the pointer is on it, and nothing moves either way. +The colours are four states running quietest to loudest: `idle` while the pointer is elsewhere, the base once it reaches the region, then `hover` and `active` for the thumb's own two. `--cl-scrollbar-thumb-idle: oklch(from var(--cl-scrollbar-thumb) l c h / 0)` is the whole recipe for a scrollbar that fades in on approach and gives up no layout doing it. + +`--cl-scrollbar-thumb-offset` shifts the thumb toward the content without resizing it. The lane itself can't be moved — the browser places it at the inline end of the padding box and it takes no margin, offset, or transform — so this adds to the inset on one side and takes it off the other. Two breaking notes if you were already theming these: diff --git a/packages/swingset/src/stories/scroll-area.mdx b/packages/swingset/src/stories/scroll-area.mdx index 761c3c1fdb7..75033718a34 100644 --- a/packages/swingset/src/stories/scroll-area.mdx +++ b/packages/swingset/src/stories/scroll-area.mdx @@ -93,31 +93,21 @@ identically, which is why `auto` is the default. storyModule={ScrollAreaStories} /> -Mosaic's `hover` token is the **thumb's** state, so it can't express this on its own — a thumb you -can't see isn't a target you can find. Region hover is one rule of your own CSS instead: +This is the far end of what `--cl-scrollbar-thumb-idle` is for — one declaration, no rules of your +own: ```css .my-scroller { - /* Mosaic's own rest colour, named once so the hidden state can be that same colour at zero - alpha. Fading between two different colours would swing the hue as the bar appears. */ - --thumb-rest: color-mix(in oklab, var(--cl-color-neutral-faded), var(--cl-color-card) 55%); -} - -/* `:not(:hover)`, so reaching the region simply stops overriding. */ -.my-scroller:not(:hover) { - --cl-scrollbar-thumb: oklch(from var(--thumb-rest) l c h / 0); + --cl-scrollbar-thumb-idle: oklch(from var(--cl-scrollbar-thumb) l c h / 0); } ``` -Hiding on `:not(:hover)` rather than revealing on `:hover` is what keeps this quiet: the revealed -state is just Mosaic's ordinary rest colour, so the thumb's own `-hover` and `-active` still read as -a step up from it, exactly as they do on a scroll area you haven't touched. Revealing straight to a -strong colour spends the contrast on arrival and leaves those two states nowhere to go. +Mosaic already dims the bar while the pointer is elsewhere, so this only takes that existing state +to zero alpha. The revealed colour stays the ordinary base, which is why the thumb's own `-hover` +and `-active` still read as a step up from it. -This one does fade. The thumb's colour resolves through an `@property`-registered custom property -that the SCROLLER transitions and the thumb inherits, so retargeting the token from the region is a -computed-value change the transition picks up. The thumb's own `-hover` / `-active` can't do this — -see [Styling](#styling). The lane stays reserved throughout — only the paint is conditional, so +It fades because idle → base is the one step set on the scroller, which owns the transition — see +[Styling](#styling). The lane stays reserved throughout, so only the paint is conditional and nothing reflows on the way in or out. Reach for the zero-alpha colour rather than `transparent` in any fade like this one. `transparent` @@ -165,7 +155,9 @@ These tokens are global, so setting them once retunes every scrolling surface in | `--cl-scroll-fade-range` | `1.5rem` | How far you scroll before the fade reaches full strength. | | `--cl-scrollbar-width` | `8px` | Width of the scrollbar lane. `0px` hides it. | | `--cl-scrollbar-thumb-inset` | `2px` | How far the thumb's paint is held inside that lane. | -| `--cl-scrollbar-thumb` | derived from the palette | Thumb colour at rest. | +| `--cl-scrollbar-thumb-offset` | `1px` | Nudges the thumb toward the content, without resizing it. | +| `--cl-scrollbar-thumb` | derived from the palette | Thumb colour once the pointer reaches the region. | +| `--cl-scrollbar-thumb-idle` | derived from the above | Thumb colour while the pointer is elsewhere. | | `--cl-scrollbar-thumb-hover` | derived from the above | Thumb colour while the pointer is over the thumb. | | `--cl-scrollbar-thumb-active` | derived from the above | Thumb colour while the thumb is being dragged. | @@ -173,18 +165,26 @@ The two lane sizes are in pixels rather than on the `rem` scale, deliberately: a rather than content, so it should stay the same hairline whether or not the surrounding text scales. The default is a 4px pill with a 2px track either side. -The two derived colours reference `--cl-scrollbar-thumb` rather than baking its value in, so setting -the base re-derives both — and either state can still be pinned on its own. +The colours are four states running quietest to loudest: `idle` while the pointer is elsewhere, the +base once it reaches the region, then `hover` and `active` for the thumb's own two. Each of the +three derives from `--cl-scrollbar-thumb` rather than baking its value in, so setting the base +re-derives all of them — and any one can still be pinned on its own. + +`hover` and `active` are the **thumb's own** states, not the region's: they change when the pointer +is over the thumb itself. `idle` is the region's, which is what gives the bar somewhere quieter to +sit while you are not near it. -`hover` and `active` are the **thumb's own** states, not the region's: the colour changes when the -pointer is over the thumb itself, not whenever it is somewhere over the scrolling area. +The lane itself cannot be moved — the browser places it at the inline end of the padding box, and it +takes no margin, offset, or transform. `--cl-scrollbar-thumb-offset` moves the **thumb inside** the +lane instead, adding to the inset on one side and taking it off the other, so the pill shifts +without changing width. Keep it below the inset: a larger offset drives the near-side border +negative. Those two states switch instantly, and can't be made to animate. Blink doesn't run transitions declared on `::-webkit-scrollbar-thumb`, so the transition lives on the scroller and the thumb inherits the animating value — which means a change made **on the scroller** fades, while a change -made on the thumb itself can only snap. That's the line to keep in mind when theming: retargeting -`--cl-scrollbar-thumb` from the region (as [Reveal on hover](#reveal-on-hover) does) transitions; -`-hover` and `-active` do not. +made on the thumb itself can only snap. That's the line to keep in mind when theming: `idle` → base +transitions, because reaching the region is a change on the scroller; `-hover` and `-active` do not. Mosaic paints the scrollbar through `::-webkit-scrollbar`, which is what buys a real width and a thumb colour per interaction state; the standard `scrollbar-color` can express neither, and setting diff --git a/packages/swingset/src/stories/scroll-area.stories.tsx b/packages/swingset/src/stories/scroll-area.stories.tsx index 185c40e574f..0236cbd325f 100644 --- a/packages/swingset/src/stories/scroll-area.stories.tsx +++ b/packages/swingset/src/stories/scroll-area.stories.tsx @@ -3,7 +3,7 @@ import { Avatar } from '@clerk/ui/mosaic/components/avatar'; import { Button } from '@clerk/ui/mosaic/components/button'; import { Item } from '@clerk/ui/mosaic/components/item'; import { scrollAreaRoot, scrollAreaViewport } from '@clerk/ui/mosaic/components/scroll-area'; -import { colorVars, radiusVars, space } from '@clerk/ui/mosaic/styles'; +import { radiusVars, space } from '@clerk/ui/mosaic/styles'; import * as stylex from '@stylexjs/stylex'; import * as React from 'react'; @@ -219,31 +219,18 @@ const themedRows = [ 'Oscorp', ]; -// Mosaic's own default rest colour, written out so the hidden state can be THAT colour at zero -// alpha. Fading between two different colours would swing the hue as the bar appears; this way the -// only channel moving is alpha. -const thumbRest = `color-mix(in oklab, ${colorVars['--cl-color-neutral-faded']}, ${colorVars['--cl-color-card']} 55%)`; - /** - * A scrollbar that stays invisible until you reach the region. Mosaic's own `hover` token is the - * THUMB's state, so it can't express this — a thumb you can't see is not a target you can find. - * Region hover is one rule of your own CSS instead. - * - * Written as `:not(:hover)` so that reaching the region simply stops overriding, leaving the thumb - * at Mosaic's ordinary rest colour. That keeps the reveal deliberately quiet: it restores the - * default rather than jumping to something louder, so the thumb's own `-hover` and `-active` still - * read as a step UP from it, exactly as they do on an unstyled scroll area. Revealing straight to a - * strong colour spends the contrast early and leaves the thumb's own states nowhere to go. + * The far end of what `--cl-scrollbar-thumb-idle` is for. Mosaic already dims the bar while the + * pointer is elsewhere; taking that token to zero alpha removes it entirely, so the scrollbar + * appears only once you reach the region. One declaration, no rules of your own. * - * This one fades. The thumb's colour resolves through an `@property`-registered custom property - * that the SCROLLER transitions and the thumb inherits, so retargeting the token from the region is - * a computed-value change the transition picks up. The thumb's own `-hover` / `-active` cannot do - * the same — Blink runs no transition declared on `::-webkit-scrollbar-thumb`, so those snap. + * `oklch(from … / 0)` rather than the `transparent` keyword: `transparent` is `rgba(0, 0, 0, 0)` — + * transparent BLACK — so interpolating out of it drags the thumb through a series of dark, + * half-transparent greys and the bar reads as dirty on the way in. Relative colour syntax reads the + * base token's own channels and drops only the alpha, so the only thing moving is opacity. * - * The hidden value is the rest colour at zero alpha, NOT the `transparent` keyword. `transparent` - * is `rgba(0, 0, 0, 0)` — transparent BLACK — so interpolating out of it drags the thumb through a - * series of dark, half-transparent greys and the bar reads as dirty on the way in. Relative colour - * syntax takes the colour's own channels and drops only the alpha. + * It fades because idle → base is the one step set on the SCROLLER, which owns the transition. The + * thumb's `-hover` and `-active` still work from there, and still snap. * * The lane stays reserved throughout: only the thumb's paint is conditional, so nothing reflows on * the way in or out. @@ -255,7 +242,7 @@ export function HoverReveal() {
diff --git a/packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts b/packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts index 1a4fe513ffd..079eb741176 100644 --- a/packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts +++ b/packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts @@ -92,9 +92,23 @@ const styles = stylex.create({ thumbColor: { '--_cl-scrollbar-thumb-color': { default: null, - '@media (pointer: fine)': scrollbarVars['--cl-scrollbar-thumb'], + '@media (pointer: fine)': { + // Quietest while the pointer is elsewhere; the region itself is the first thing that lifts + // it. `:focus-within` comes along so a keyboard user arrowing through the content gets the + // same bar a pointer user does. + default: scrollbarVars['--cl-scrollbar-thumb-idle'], + ':is(:hover, :focus-within)': scrollbarVars['--cl-scrollbar-thumb'], + }, + }, + // Longer leaving than arriving, per the duration tokens: reaching the region is direct pointer + // feedback, its decay is not. + transitionDuration: { + default: null, + '@media (pointer: fine)': { + default: durationVars['--cl-duration-base'], + ':is(:hover, :focus-within)': durationVars['--cl-duration-fast'], + }, }, - transitionDuration: { default: null, '@media (pointer: fine)': durationVars['--cl-duration-base'] }, transitionProperty: { default: null, '@media (pointer: fine)': '--_cl-scrollbar-thumb-color' }, transitionTimingFunction: { default: null, '@media (pointer: fine)': 'linear' }, }, @@ -140,7 +154,6 @@ const styles = stylex.create({ borderColor: { default: null, '@media (pointer: fine)': 'transparent' }, borderRadius: { default: null, '@media (pointer: fine)': radiusVars['--cl-radius-full'] }, borderStyle: { default: null, '@media (pointer: fine)': 'solid' }, - borderWidth: { default: null, '@media (pointer: fine)': scrollbarVars['--cl-scrollbar-thumb-inset'] }, backgroundClip: { default: null, '@media (pointer: fine)': 'content-box' }, backgroundColor: { default: null, @@ -150,10 +163,26 @@ const styles = stylex.create({ // No `scrollbar-color: auto` lever survives on this path, so forced colors need their // own answer: pin the thumb to a system colour rather than let a themed one lose its // contrast guarantee against a palette we no longer control. Declared on - // `background-color` rather than on the var so it holds across all three states at once. + // `background-color` rather than on the var so it holds across all four states at once. '@media (forced-colors: active)': 'ButtonBorder', }, }, + // Asymmetric on purpose: `offset` comes off the near side and goes onto the far one, which + // slides the pill toward the content without resizing it. Logical, so the nudge stays on the + // content side when the scrollbar moves to the left edge in RTL. The `max(0px, …)` floor + // matters: an offset larger than the inset would otherwise compute a NEGATIVE border width, + // which is invalid, so the declaration would be dropped and the border fall back to its + // initial `medium` — a thumb suddenly much narrower on one side, for a token value that only + // looks slightly too big. (Key order is the sort-keys rule's, not ours.) + borderBlockWidth: { default: null, '@media (pointer: fine)': scrollbarVars['--cl-scrollbar-thumb-inset'] }, + borderInlineEndWidth: { + default: null, + '@media (pointer: fine)': `calc(${scrollbarVars['--cl-scrollbar-thumb-inset']} + ${scrollbarVars['--cl-scrollbar-thumb-offset']})`, + }, + borderInlineStartWidth: { + default: null, + '@media (pointer: fine)': `max(0px, calc(${scrollbarVars['--cl-scrollbar-thumb-inset']} - ${scrollbarVars['--cl-scrollbar-thumb-offset']}))`, + }, }, // eslint-disable-next-line @stylexjs/valid-styles -- StyleX's pseudo-element allowlist holds the bare selectors only; it compiles the combined form correctly. See the note above. '::-webkit-scrollbar-thumb:active': { diff --git a/packages/ui/src/mosaic/components/scroll-area/scroll-area.test.ts b/packages/ui/src/mosaic/components/scroll-area/scroll-area.test.ts index 793c3c21dec..e69a10b68f8 100644 --- a/packages/ui/src/mosaic/components/scroll-area/scroll-area.test.ts +++ b/packages/ui/src/mosaic/components/scroll-area/scroll-area.test.ts @@ -37,7 +37,9 @@ describe('Mosaic scroll area styles', () => { expect(scrollbarVars).toMatchObject({ '--cl-scrollbar-width': 'var(--cl-scrollbar-width)', '--cl-scrollbar-thumb-inset': 'var(--cl-scrollbar-thumb-inset)', + '--cl-scrollbar-thumb-offset': 'var(--cl-scrollbar-thumb-offset)', '--cl-scrollbar-thumb': 'var(--cl-scrollbar-thumb)', + '--cl-scrollbar-thumb-idle': 'var(--cl-scrollbar-thumb-idle)', '--cl-scrollbar-thumb-hover': 'var(--cl-scrollbar-thumb-hover)', '--cl-scrollbar-thumb-active': 'var(--cl-scrollbar-thumb-active)', }); diff --git a/packages/ui/src/mosaic/tokens.stylex.ts b/packages/ui/src/mosaic/tokens.stylex.ts index df50d859b26..349f891d958 100644 --- a/packages/ui/src/mosaic/tokens.stylex.ts +++ b/packages/ui/src/mosaic/tokens.stylex.ts @@ -107,18 +107,29 @@ export const targetVars = stylex.defineVars(targetDefaults); // a specific hairline rather than a ratio of anything. Rounding also matters more here than // elsewhere, since the thumb is only a few pixels wide to begin with. // -// The two derived colours reference `--cl-scrollbar-thumb` rather than baking its value in, -// so they resolve at use time: overriding the base re-derives both, while either state stays +// `offset` nudges the pill toward the content and away from the outer edge, WITHOUT narrowing it: +// it is added to the inset on one side and taken off the other, so only the position moves. This +// is the only positional control there is — a scrollbar's lane is placed by the browser at the +// inline end of the padding box and takes no margin, offset, or transform, so anything that looks +// like moving the scrollbar has to happen to the thumb inside it. Keep it under the inset, since +// an offset that exceeds it drives the near-side border negative and the pill stops being centred +// in any meaningful sense. +// +// The colours are FOUR states, not three, and they run from quietest to loudest: `idle` while the +// pointer is elsewhere, the base once it reaches the region, then `hover` and `active` for the +// thumb's own two. Each derives from `--cl-scrollbar-thumb` rather than baking its value in, so +// they resolve at use time — overriding the base re-derives all three, while any one stays // individually overridable. The base is itself mixed most of the way toward `--cl-color-card`, -// which is what keeps a 4px bar reading as a hairline rather than a hard rule; the two states -// then step back toward `--cl-color-card-foreground`, deepening in light mode and lightening in -// dark, since that token already carries both. +// which keeps a 4px bar reading as a hairline rather than a hard rule; `idle` carries on in that +// direction, and the other two step back toward `--cl-color-card-foreground`, deepening in light +// mode and lightening in dark, since that token already carries both. +// +// Only the idle → base step can animate. It is set on the scroller, which owns the transition; +// the thumb's own two are set on the pseudo-element, and Blink runs no transition there. // -// Setting `--cl-scrollbar-thumb: transparent` hides the thumb without giving up its lane: the -// rest state simply paints nothing and the thumb reappears while the pointer is on it. Only the -// paint is conditional, so nothing moves. Worth knowing that the two states below are the -// THUMB's, not the region's, which makes that a precise target to find — it works best where -// the fade indicators are already carrying the signal that the region scrolls. +// `--cl-scrollbar-thumb-idle: oklch(from var(--cl-scrollbar-thumb) l c h / 0)` is the whole recipe +// for a scrollbar that fades in on approach: the pill paints nothing until the pointer reaches the +// region, and the lane is reserved either way, so nothing moves. // // Only applied under `@media (pointer: fine)`. A touch platform draws an overlay bar there is // no width to apply to, and thinning a target that is already hard to hit would be actively @@ -131,7 +142,9 @@ const scrollbarThumb = 'var(--cl-scrollbar-thumb)'; const scrollbarDefaults = { '--cl-scrollbar-width': '8px', '--cl-scrollbar-thumb-inset': '2px', + '--cl-scrollbar-thumb-offset': '1px', '--cl-scrollbar-thumb': `color-mix(in oklab, ${colorVars['--cl-color-neutral-faded']}, ${colorVars['--cl-color-card']} 55%)`, + '--cl-scrollbar-thumb-idle': `color-mix(in oklab, ${scrollbarThumb}, ${colorVars['--cl-color-card']} 45%)`, '--cl-scrollbar-thumb-hover': `color-mix(in oklab, ${scrollbarThumb}, ${colorVars['--cl-color-card-foreground']} 15%)`, '--cl-scrollbar-thumb-active': `color-mix(in oklab, ${scrollbarThumb}, ${colorVars['--cl-color-card-foreground']} 30%)`, } as const; From c8cde2b743e44a5b23b08fd2117cf249aaf5c9f0 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 31 Jul 2026 18:35:54 -0600 Subject: [PATCH 19/24] =?UTF-8?q?fix(ui):=20Drop=20the=20thumb=20offset=20?= =?UTF-8?q?=E2=80=94=20it=20deformed=20the=20pill's=20caps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shifting the thumb within its lane means unequal insets, and unequal insets break the shape. `background-clip: content-box` clips the paint to the content box using the INNER radius, which CSS derives per corner as the outer radius minus that side's own border width. Different borders, different curvature on each half of every cap. Measured on the 4px pill: a symmetric `35 76 76 35` becomes `24 60 78 54`, and the outer edge squares off. The geometry was right — the pill stayed 4px at every offset — which is why the first pass at this looked fine. The cost was in the corners. Reverted to a single uniform `border-width`, with the finding written down in both the styles and the token docs so the next person doesn't retry it. Caps are mirrored again on every row of the cap. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/lazy-pans-tickle.md | 3 --- packages/swingset/src/stories/scroll-area.mdx | 13 ++++++----- .../scroll-area/scroll-area.styles.ts | 23 ++++++------------- .../scroll-area/scroll-area.test.ts | 1 - packages/ui/src/mosaic/tokens.stylex.ts | 14 +++++------ 5 files changed, 20 insertions(+), 34 deletions(-) diff --git a/.changeset/lazy-pans-tickle.md b/.changeset/lazy-pans-tickle.md index e150d0be609..51465bfa18d 100644 --- a/.changeset/lazy-pans-tickle.md +++ b/.changeset/lazy-pans-tickle.md @@ -13,12 +13,9 @@ New tokens, applied to every scrolling surface at once: | `--cl-scrollbar-thumb-hover` | derived from the above | | `--cl-scrollbar-thumb-active` | derived from the above | | `--cl-scrollbar-thumb-inset` | `2px` | -| `--cl-scrollbar-thumb-offset` | `1px` | The colours are four states running quietest to loudest: `idle` while the pointer is elsewhere, the base once it reaches the region, then `hover` and `active` for the thumb's own two. `--cl-scrollbar-thumb-idle: oklch(from var(--cl-scrollbar-thumb) l c h / 0)` is the whole recipe for a scrollbar that fades in on approach and gives up no layout doing it. -`--cl-scrollbar-thumb-offset` shifts the thumb toward the content without resizing it. The lane itself can't be moved — the browser places it at the inline end of the padding box and it takes no margin, offset, or transform — so this adds to the inset on one side and takes it off the other. - Two breaking notes if you were already theming these: - `--cl-scrollbar-width` now takes a **length** (default `8px`) rather than the `auto | thin | none` keyword. Use `0px` where you previously used `none`. diff --git a/packages/swingset/src/stories/scroll-area.mdx b/packages/swingset/src/stories/scroll-area.mdx index 75033718a34..3309003f5bc 100644 --- a/packages/swingset/src/stories/scroll-area.mdx +++ b/packages/swingset/src/stories/scroll-area.mdx @@ -155,7 +155,6 @@ These tokens are global, so setting them once retunes every scrolling surface in | `--cl-scroll-fade-range` | `1.5rem` | How far you scroll before the fade reaches full strength. | | `--cl-scrollbar-width` | `8px` | Width of the scrollbar lane. `0px` hides it. | | `--cl-scrollbar-thumb-inset` | `2px` | How far the thumb's paint is held inside that lane. | -| `--cl-scrollbar-thumb-offset` | `1px` | Nudges the thumb toward the content, without resizing it. | | `--cl-scrollbar-thumb` | derived from the palette | Thumb colour once the pointer reaches the region. | | `--cl-scrollbar-thumb-idle` | derived from the above | Thumb colour while the pointer is elsewhere. | | `--cl-scrollbar-thumb-hover` | derived from the above | Thumb colour while the pointer is over the thumb. | @@ -174,11 +173,13 @@ re-derives all of them — and any one can still be pinned on its own. is over the thumb itself. `idle` is the region's, which is what gives the bar somewhere quieter to sit while you are not near it. -The lane itself cannot be moved — the browser places it at the inline end of the padding box, and it -takes no margin, offset, or transform. `--cl-scrollbar-thumb-offset` moves the **thumb inside** the -lane instead, adding to the inset on one side and taking it off the other, so the pill shifts -without changing width. Keep it below the inset: a larger offset drives the near-side border -negative. +There is no knob for nudging the thumb sideways within its lane, and it isn't an oversight. The lane +can't move — the browser places it at the inline end of the padding box, and it takes no margin, +offset, or transform — so the only lever is making the thumb's insets asymmetric. That shifts the +pill, but it also deforms it: the paint is clipped to the content box using the **inner** radius, +which CSS derives per corner as the outer radius minus that side's own border width, so unequal +insets draw the two halves of each cap with different curvature. On a 4px pill the caps stop being +round. Position the surrounding padding instead. Those two states switch instantly, and can't be made to animate. Blink doesn't run transitions declared on `::-webkit-scrollbar-thumb`, so the transition lives on the scroller and the thumb diff --git a/packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts b/packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts index 079eb741176..1dc99a1a148 100644 --- a/packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts +++ b/packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts @@ -154,6 +154,13 @@ const styles = stylex.create({ borderColor: { default: null, '@media (pointer: fine)': 'transparent' }, borderRadius: { default: null, '@media (pointer: fine)': radiusVars['--cl-radius-full'] }, borderStyle: { default: null, '@media (pointer: fine)': 'solid' }, + // Uniform, and it has to stay uniform. Nudging the pill sideways by making these asymmetric + // works geometrically but wrecks the caps: `background-clip: content-box` clips to the + // content box using the INNER radius, which CSS derives per corner as the outer radius minus + // that side's border width, so unequal borders give the two halves of each cap different + // curvature. Measured on a 4px pill, the cap goes from a mirrored `35 76 76 35` to a lopsided + // `24 60 78 54`. There is no offsetting the thumb within its lane without paying that. + borderWidth: { default: null, '@media (pointer: fine)': scrollbarVars['--cl-scrollbar-thumb-inset'] }, backgroundClip: { default: null, '@media (pointer: fine)': 'content-box' }, backgroundColor: { default: null, @@ -167,22 +174,6 @@ const styles = stylex.create({ '@media (forced-colors: active)': 'ButtonBorder', }, }, - // Asymmetric on purpose: `offset` comes off the near side and goes onto the far one, which - // slides the pill toward the content without resizing it. Logical, so the nudge stays on the - // content side when the scrollbar moves to the left edge in RTL. The `max(0px, …)` floor - // matters: an offset larger than the inset would otherwise compute a NEGATIVE border width, - // which is invalid, so the declaration would be dropped and the border fall back to its - // initial `medium` — a thumb suddenly much narrower on one side, for a token value that only - // looks slightly too big. (Key order is the sort-keys rule's, not ours.) - borderBlockWidth: { default: null, '@media (pointer: fine)': scrollbarVars['--cl-scrollbar-thumb-inset'] }, - borderInlineEndWidth: { - default: null, - '@media (pointer: fine)': `calc(${scrollbarVars['--cl-scrollbar-thumb-inset']} + ${scrollbarVars['--cl-scrollbar-thumb-offset']})`, - }, - borderInlineStartWidth: { - default: null, - '@media (pointer: fine)': `max(0px, calc(${scrollbarVars['--cl-scrollbar-thumb-inset']} - ${scrollbarVars['--cl-scrollbar-thumb-offset']}))`, - }, }, // eslint-disable-next-line @stylexjs/valid-styles -- StyleX's pseudo-element allowlist holds the bare selectors only; it compiles the combined form correctly. See the note above. '::-webkit-scrollbar-thumb:active': { diff --git a/packages/ui/src/mosaic/components/scroll-area/scroll-area.test.ts b/packages/ui/src/mosaic/components/scroll-area/scroll-area.test.ts index e69a10b68f8..4669507d8a9 100644 --- a/packages/ui/src/mosaic/components/scroll-area/scroll-area.test.ts +++ b/packages/ui/src/mosaic/components/scroll-area/scroll-area.test.ts @@ -37,7 +37,6 @@ describe('Mosaic scroll area styles', () => { expect(scrollbarVars).toMatchObject({ '--cl-scrollbar-width': 'var(--cl-scrollbar-width)', '--cl-scrollbar-thumb-inset': 'var(--cl-scrollbar-thumb-inset)', - '--cl-scrollbar-thumb-offset': 'var(--cl-scrollbar-thumb-offset)', '--cl-scrollbar-thumb': 'var(--cl-scrollbar-thumb)', '--cl-scrollbar-thumb-idle': 'var(--cl-scrollbar-thumb-idle)', '--cl-scrollbar-thumb-hover': 'var(--cl-scrollbar-thumb-hover)', diff --git a/packages/ui/src/mosaic/tokens.stylex.ts b/packages/ui/src/mosaic/tokens.stylex.ts index 349f891d958..09371e11f8b 100644 --- a/packages/ui/src/mosaic/tokens.stylex.ts +++ b/packages/ui/src/mosaic/tokens.stylex.ts @@ -107,13 +107,12 @@ export const targetVars = stylex.defineVars(targetDefaults); // a specific hairline rather than a ratio of anything. Rounding also matters more here than // elsewhere, since the thumb is only a few pixels wide to begin with. // -// `offset` nudges the pill toward the content and away from the outer edge, WITHOUT narrowing it: -// it is added to the inset on one side and taken off the other, so only the position moves. This -// is the only positional control there is — a scrollbar's lane is placed by the browser at the -// inline end of the padding box and takes no margin, offset, or transform, so anything that looks -// like moving the scrollbar has to happen to the thumb inside it. Keep it under the inset, since -// an offset that exceeds it drives the near-side border negative and the pill stops being centred -// in any meaningful sense. +// There is deliberately no knob for nudging the thumb sideways within its lane. The lane itself +// cannot move — the browser places it at the inline end of the padding box, and it takes no margin, +// offset, or transform — so the only lever is making the thumb's insets asymmetric, and that +// visibly deforms the pill: `background-clip: content-box` clips to the inner radius, which is the +// outer radius minus each side's own border width, so unequal insets draw the two halves of every +// cap with different curvature. Tried and measured; not worth a hairline of position. // // The colours are FOUR states, not three, and they run from quietest to loudest: `idle` while the // pointer is elsewhere, the base once it reaches the region, then `hover` and `active` for the @@ -142,7 +141,6 @@ const scrollbarThumb = 'var(--cl-scrollbar-thumb)'; const scrollbarDefaults = { '--cl-scrollbar-width': '8px', '--cl-scrollbar-thumb-inset': '2px', - '--cl-scrollbar-thumb-offset': '1px', '--cl-scrollbar-thumb': `color-mix(in oklab, ${colorVars['--cl-color-neutral-faded']}, ${colorVars['--cl-color-card']} 55%)`, '--cl-scrollbar-thumb-idle': `color-mix(in oklab, ${scrollbarThumb}, ${colorVars['--cl-color-card']} 45%)`, '--cl-scrollbar-thumb-hover': `color-mix(in oklab, ${scrollbarThumb}, ${colorVars['--cl-color-card-foreground']} 15%)`, From 1b2c81c78f8627b85d7d0929894825f07ad371f7 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 31 Jul 2026 19:04:09 -0600 Subject: [PATCH 20/24] docs(swingset): Replace the scroll fade with shadow overlays in an example Turns the prose-only "Replacing the fade" note into a running example. The mask comes off and two pseudo-element scrims take over, each driving both opacity and a translate from its edge's progress var, so a scrim slides out from behind its edge rather than only fading in. The scrim mixes from `--cl-color-card-foreground` so it reads as a shadow in light mode and a glow in dark, where the hardcoded black the old snippet used would have been invisible. `mask-image` is dropped inline rather than from the demo's stylesheet: in dev, swingset injects StyleX atoms with a `:not(#\#)` specificity bump that no selector can outrank. The extracted production sheet puts the same atoms in a cascade layer, so the documented CSS wins there on its own. Co-Authored-By: Claude Opus 5 (1M context) --- packages/swingset/src/stories/scroll-area.mdx | 76 ++++++++++++------- .../src/stories/scroll-area.stories.tsx | 61 +++++++++++++++ 2 files changed, 111 insertions(+), 26 deletions(-) diff --git a/packages/swingset/src/stories/scroll-area.mdx b/packages/swingset/src/stories/scroll-area.mdx index 3309003f5bc..d743f624fed 100644 --- a/packages/swingset/src/stories/scroll-area.mdx +++ b/packages/swingset/src/stories/scroll-area.mdx @@ -141,6 +141,56 @@ registered property interpolates between two colours in one space rather than gu notations. The trap to avoid is `transparent`, for the reason given under [Reveal on hover](#reveal-on-hover). +### Shadows instead of the fade + + + +Retire the mask with `mask-image: none` and read the two per-element vars the animations write — +`--cl-scroll-area-progress-start` and `--cl-scroll-area-progress-end`, each describing how much that +edge still has to reveal. They live on the element carrying the atoms and inherit downward, so a +pseudo-element can drive itself from them. + +```css +.cl-item-group { + mask-image: none; +} +.cl-item-group::before, +.cl-item-group::after { + content: ''; + position: absolute; + inset-inline: 0; + height: var(--cl-scroll-fade-size); + pointer-events: none; +} +.cl-item-group::before { + top: 0; + background: linear-gradient(to bottom, color-mix(in oklab, var(--cl-color-card-foreground) 22%, transparent), transparent); + opacity: var(--cl-scroll-area-progress-start); + transform: translateY(calc((var(--cl-scroll-area-progress-start) - 1) * var(--cl-scroll-fade-size))); +} +.cl-item-group::after { + bottom: 0; + background: linear-gradient(to top, color-mix(in oklab, var(--cl-color-card-foreground) 22%, transparent), transparent); + opacity: var(--cl-scroll-area-progress-end); + transform: translateY(calc((1 - var(--cl-scroll-area-progress-end)) * var(--cl-scroll-fade-size))); +} +``` + +The vars are registered as ``, so they drive position as readily as opacity: each scrim +slides out from behind its own edge as it fades in. Clip the root — `overflow: hidden` — so the half +that is still offscreen stays there. + +Mix the scrim from a theme colour rather than hardcoding black: `--cl-color-card-foreground` inverts +with the theme, so one declaration reads as a shadow on light and a soft glow on dark, where black +would vanish. + +Position such overlays absolutely against `scrollAreaRoot` — this is the case the root exists for — +rather than with `position: sticky`, which takes space in the scroll flow and reintroduces the +layout shift the mask avoids. + ## Styling The fade is driven by two scroll-driven animations — no scroll listener, no measurement, nothing at @@ -213,32 +263,6 @@ compensate is tempting, but only safe alongside `gutter: 'stable'` — with `aut only while the content overflows, so the trimmed padding collapses to nothing the moment it doesn't, and the rows sit flush against the edge. Left alone, the extra lane is the safer asymmetry. -### Replacing the fade - -Retire it with `mask-image: none` and read the two per-element vars the animations write — -`--cl-scroll-area-progress-start` and `--cl-scroll-area-progress-end`, each describing how much that -edge still has to reveal. They live on the element carrying the atoms and inherit downward. - -```css -.cl-item-group { - mask-image: none; -} -.cl-item-group::before { - content: ''; - position: absolute; - inset: 0 0 auto; - height: 2rem; - pointer-events: none; - background: linear-gradient(to bottom, color-mix(in oklab, var(--cl-color-card-foreground) 28%, transparent), transparent); - opacity: var(--cl-scroll-area-progress-start); -} -``` - -Position such overlays absolutely rather than with `position: sticky` — a sticky pseudo-element -takes space in the scroll flow, which is the layout shift the mask approach avoids. And mix the -scrim from a theme color rather than hardcoding black, which would darken a dark surface and look -identical to the mask it replaced. - ## Accessibility Chrome and Firefox make an overflowing scroll container keyboard-focusable on their own; **Safari diff --git a/packages/swingset/src/stories/scroll-area.stories.tsx b/packages/swingset/src/stories/scroll-area.stories.tsx index 0236cbd325f..4a76d6d419d 100644 --- a/packages/swingset/src/stories/scroll-area.stories.tsx +++ b/packages/swingset/src/stories/scroll-area.stories.tsx @@ -311,3 +311,64 @@ export function ThemedScrollbar() {
); } + +/** + * The mask retired for overlay scrims, each reading the progress var for its edge. The scrim mixes + * from `--cl-color-card-foreground`, so it reads as a shadow on light and a glow on dark; + * hardcoded black would vanish on a dark surface. + * + * Each scrim slides in from behind its edge as well as fading, so the two vars drive position and + * opacity together. `overflow: hidden` on the root both rounds their corners and hides the + * offscreen half. + * + * `mask-image` is retired inline rather than from the stylesheet: swingset's dev server injects + * StyleX atoms with a specificity bump no selector of ours can outrank. The extracted production + * sheet puts them in a cascade layer, where the plain CSS below would win on its own. + */ +export function ShadowIndicators() { + const root = stylex.props(scrollAreaRoot); + + return ( + <> + +
+ + {themedRows.map(name => ( + + ))} + +
+ + ); +} From 78dee07156e3d321d2d1847c0a50405fe6d542cb Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 31 Jul 2026 19:05:00 -0600 Subject: [PATCH 21/24] docs(swingset): Tighten the scroll area copy and correct stale claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cuts the page down without dropping anything it was actually saying, and strips the inline comments from the story bodies — those render in each example's "View code" footer, unlike the JSDoc above them, which `extractStorySource` leaves out. Three things had gone stale: - The recipe for keeping the scrollbar's lane without its bar set only `--cl-scrollbar-thumb: transparent`. `-idle` is a `color-mix` from that base, so it resolved to roughly 45% of the card colour rather than to nothing. It now sets both resting colours and says the thumb still paints faintly on hover. - "Those two states switch instantly" had drifted two paragraphs away from the hover/active states it refers to, after the thumb-offset note landed between them. - `:focus-within` lifts the thumb to its base colour just as the pointer does, which the token docs never mentioned. Also renames `themedRows`, now that three examples share it. Co-Authored-By: Claude Opus 5 (1M context) --- packages/swingset/src/stories/scroll-area.mdx | 111 ++++++++---------- .../src/stories/scroll-area.stories.tsx | 85 +++----------- 2 files changed, 66 insertions(+), 130 deletions(-) diff --git a/packages/swingset/src/stories/scroll-area.mdx b/packages/swingset/src/stories/scroll-area.mdx index d743f624fed..4056873218d 100644 --- a/packages/swingset/src/stories/scroll-area.mdx +++ b/packages/swingset/src/stories/scroll-area.mdx @@ -17,9 +17,7 @@ slot class, which stays the hook a theme targets. ## Usage -`scrollAreaViewport()` returns an array, hence the `...` spread. `scrollAreaRoot` goes on a -positioned ancestor, and is only needed when something has to anchor against the scroll box — an -overlay replacing the fade, for instance. +`scrollAreaViewport()` returns an array, hence the `...` spread. ```tsx import { scrollAreaRoot, scrollAreaViewport } from '@clerk/ui/mosaic/components/scroll-area'; @@ -52,10 +50,9 @@ const root = stylex.props(scrollAreaRoot); | `scrollAreaViewport(gutter?)` | the scrolling element | The scroll box itself: overflow, the edge fades, the scrollbar, and a focus ring. | | `scrollAreaRoot` | a positioned ancestor | Only needed when something anchors against the scroll box. | -`gutter` is the one argument: `auto` (the default, and CSS's own) takes the scrollbar's space only -while the content overflows, while `stable` reserves it either way. It is an author-time decision -rather than a theme one — whether a region needs it depends on whether its content can resize in -place, which only that surface's author knows. +`gutter` is the one argument — `auto` (the default) or `stable`, see [Gutter](#gutter). It is an +author-time decision rather than a theme one, since whether a region needs it depends on whether its +content can resize in place. ## Examples @@ -66,10 +63,10 @@ place, which only that surface's author knows. storyModule={ScrollAreaStories} /> -The atoms are unconditional: there is no "is it scrollable" branch to write, and no measurement at -runtime. A scroll timeline with no scrollable overflow is simply inactive, so both progress vars -hold at their registered `initial-value: 0` and the mask resolves to fully opaque. Browsers without -scroll-driven animation support get the same plain scrolling box rather than a broken one. +The atoms are unconditional: no "is it scrollable" branch to write, no measurement at runtime. A +scroll timeline with no scrollable overflow is inactive, so both progress vars hold at their +registered `initial-value: 0` and the mask resolves to fully opaque. Browsers without scroll-driven +animation get the same plain scrolling box rather than a broken one. ### Gutter @@ -78,12 +75,12 @@ scroll-driven animation support get the same plain scrolling box rather than a b storyModule={ScrollAreaStories} /> -Add the rows and watch the trailing rules: `auto` jumps its content left by the lane's width as the -list starts overflowing, while `stable` — which was already holding that lane open — doesn't move. -That jump is the entire thing `stable` exists to prevent. +`auto` takes the scrollbar's space only while the content overflows; `stable` reserves it either +way. Add the rows and watch the trailing rules: `auto` jumps its content left by the lane's width as +the list starts overflowing, `stable` doesn't move. Two conditions must **both** hold for the two to differ at all: space-consuming scrollbars, and -content that can stop overflowing. Where the platform overlays its scrollbars, they render +content that can stop overflowing. Where the platform overlays its scrollbars they render identically, which is why `auto` is the default. ### Reveal on hover @@ -93,8 +90,7 @@ identically, which is why `auto` is the default. storyModule={ScrollAreaStories} /> -This is the far end of what `--cl-scrollbar-thumb-idle` is for — one declaration, no rules of your -own: +The far end of what `--cl-scrollbar-thumb-idle` is for — one declaration, no rules of your own: ```css .my-scroller { @@ -102,19 +98,14 @@ own: } ``` -Mosaic already dims the bar while the pointer is elsewhere, so this only takes that existing state -to zero alpha. The revealed colour stays the ordinary base, which is why the thumb's own `-hover` -and `-active` still read as a step up from it. +Mosaic already dims the bar while the pointer is elsewhere; this takes that state to zero alpha. It +fades because idle → base is the one step set on the scroller, which owns the transition — see +[Styling](#styling). The lane stays reserved throughout, so nothing reflows on the way in or out. -It fades because idle → base is the one step set on the scroller, which owns the transition — see -[Styling](#styling). The lane stays reserved throughout, so only the paint is conditional and -nothing reflows on the way in or out. - -Reach for the zero-alpha colour rather than `transparent` in any fade like this one. `transparent` -is defined as `rgba(0, 0, 0, 0)` — transparent **black** — so interpolating out of it drags the -thumb through a run of dark, half-transparent greys, and a bar that should be fading in cleanly -reads as dirty instead. Relative colour syntax (`oklch(from … l c h / 0)`) keeps the colour's own -channels and drops only the alpha, so the transition moves along a single axis. +Reach for a zero-alpha colour rather than `transparent` in any fade like this. `transparent` is +defined as `rgba(0, 0, 0, 0)` — transparent **black** — so interpolating out of it drags the thumb +through dark, half-transparent greys and reads as dirty. Relative colour syntax +(`oklch(from … l c h / 0)`) keeps the colour's own channels and moves only the alpha. ### Theming the scrollbar @@ -123,23 +114,18 @@ channels and drops only the alpha, so the transition moves along a single axis. storyModule={ScrollAreaStories} /> -Each step gets a colour of its own here — far louder than anything you'd ship, but told apart at a -glance. Move the pointer into the region, then onto the bar, then drag it. +A colour per state, far louder than anything you'd ship but told apart at a glance. Move the pointer +into the region, then onto the bar, then drag it. Only the first of those moves animates, and the reason is structural rather than chromatic: amber → teal changes the region's own rest colour, so it happens on the scroller, where the transition -lives. Pink and violet are the thumb's own states, so they switch instantly however they're written -(see [Styling](#styling)). - -This example deliberately slows the transition to `0.6s`. At Mosaic's real `--cl-duration-base` of -`0.15s` the fade is over before you've finished moving the pointer in, which is easy to read as -nothing happening at all — a scrollbar is small and off to one side, so it gets far less of your -attention than a control you're aiming at. +lives. Pink and violet are the thumb's own states and switch instantly however they're written (see +[Styling](#styling)). The example stretches the transition to `0.6s` because at Mosaic's real +`0.15s` the fade is over before you've finished moving the pointer in. -Every value here is an `oklch()` literal, which is what keeps the animated step well defined — the +Every value is an `oklch()` literal, which is what keeps the animated step well defined — the registered property interpolates between two colours in one space rather than guessing across -notations. The trap to avoid is `transparent`, for the reason given under -[Reveal on hover](#reveal-on-hover). +notations. ### Shadows instead of the fade @@ -149,9 +135,9 @@ notations. The trap to avoid is `transparent`, for the reason given under /> Retire the mask with `mask-image: none` and read the two per-element vars the animations write — -`--cl-scroll-area-progress-start` and `--cl-scroll-area-progress-end`, each describing how much that -edge still has to reveal. They live on the element carrying the atoms and inherit downward, so a -pseudo-element can drive itself from them. +`--cl-scroll-area-progress-start` and `--cl-scroll-area-progress-end`, each how much that edge still +has to reveal. They live on the element carrying the atoms and inherit downward, so a pseudo-element +can drive itself from them. ```css .cl-item-group { @@ -212,16 +198,18 @@ These tokens are global, so setting them once retunes every scrolling surface in The two lane sizes are in pixels rather than on the `rem` scale, deliberately: a scrollbar is chrome rather than content, so it should stay the same hairline whether or not the surrounding text scales. -The default is a 4px pill with a 2px track either side. +The default is a 4px pill in an 8px lane. The colours are four states running quietest to loudest: `idle` while the pointer is elsewhere, the -base once it reaches the region, then `hover` and `active` for the thumb's own two. Each of the -three derives from `--cl-scrollbar-thumb` rather than baking its value in, so setting the base -re-derives all of them — and any one can still be pinned on its own. +base once it reaches the region — or the content takes keyboard focus — then `hover` and `active` +for the thumb's own two. Each of the three derives from `--cl-scrollbar-thumb` rather than baking +its value in, so setting the base re-derives all of them, and any one can still be pinned on its +own. -`hover` and `active` are the **thumb's own** states, not the region's: they change when the pointer -is over the thumb itself. `idle` is the region's, which is what gives the bar somewhere quieter to -sit while you are not near it. +Only the idle → base step can animate. Blink doesn't run transitions declared on +`::-webkit-scrollbar-thumb`, so the transition lives on the scroller and the thumb inherits the +animating value: a change made **on the scroller** fades, a change made on the thumb itself can only +snap. `-hover` and `-active` are the thumb's own states, so they are instant by construction. There is no knob for nudging the thumb sideways within its lane, and it isn't an oversight. The lane can't move — the browser places it at the inline end of the padding box, and it takes no margin, @@ -231,12 +219,6 @@ which CSS derives per corner as the outer radius minus that side's own border wi insets draw the two halves of each cap with different curvature. On a 4px pill the caps stop being round. Position the surrounding padding instead. -Those two states switch instantly, and can't be made to animate. Blink doesn't run transitions -declared on `::-webkit-scrollbar-thumb`, so the transition lives on the scroller and the thumb -inherits the animating value — which means a change made **on the scroller** fades, while a change -made on the thumb itself can only snap. That's the line to keep in mind when theming: `idle` → base -transitions, because reaching the region is a change on the scroller; `-hover` and `-active` do not. - Mosaic paints the scrollbar through `::-webkit-scrollbar`, which is what buys a real width and a thumb colour per interaction state; the standard `scrollbar-color` can express neither, and setting it would make the engines that _do_ implement the pseudo-elements ignore them. Firefox implements @@ -247,21 +229,24 @@ layout decision rather than an appearance one. One consequence worth knowing before you theme: styling the scrollbar takes macOS out of overlay mode, so the bar is always visible and always occupies its lane rather than auto-hiding. That is the cross-platform consistency the tokens exist for, but it is a change from the platform default. To -hide the thumb without giving up the lane, set the rest colour to `transparent` — it then paints -only while the pointer is on it. Note that this is a precise target to find, so it works best where -the fade indicators are already carrying the signal that the region scrolls: +keep the lane without the bar, take both resting colours transparent: ```css :root { --cl-scrollbar-thumb: transparent; + --cl-scrollbar-thumb-idle: transparent; } ``` +The thumb then paints only while the pointer is on it, and faintly — `-hover` and `-active` still +derive from the base, so pin them too if you want more. It is a precise target to find, so this +works best where the fades are already carrying the signal that the region scrolls. + One layout note: the scrollbar takes its lane **inside** a scroller's own padding, so a padded surface reads as padding plus lane at the inline end. Trimming the scroller's inline-end padding to -compensate is tempting, but only safe alongside `gutter: 'stable'` — with `auto` the lane is there -only while the content overflows, so the trimmed padding collapses to nothing the moment it doesn't, -and the rows sit flush against the edge. Left alone, the extra lane is the safer asymmetry. +compensate is only safe alongside `gutter: 'stable'` — with `auto` the lane is there only while the +content overflows, so the trimmed padding collapses the moment it doesn't and the rows sit flush +against the edge. Left alone, the extra lane is the safer asymmetry. ## Accessibility diff --git a/packages/swingset/src/stories/scroll-area.stories.tsx b/packages/swingset/src/stories/scroll-area.stories.tsx index 4a76d6d419d..4199aed5e9e 100644 --- a/packages/swingset/src/stories/scroll-area.stories.tsx +++ b/packages/swingset/src/stories/scroll-area.stories.tsx @@ -58,15 +58,8 @@ function OrganizationRow({ name }: { name: string }) { } /** - * The scroll surface, capped in height so it overflows. Everything visible here — both edge - * fades and the scrollbar — is CSS on the one element. - * - * `stylex.props()` returns a `className`, so a class of your own has to be MERGED with it rather - * than spread beside one: whichever comes last in JSX wins outright and silently drops the other. - * - * The border marking the scroll container is on the ROOT, not the viewport: a mask applies to the - * element's whole rendering, borders included, so a border on the viewport would fade out at the - * same edges its content does. The root wraps the viewport exactly, so it outlines the same box. + * The border is on the ROOT, not the viewport: a mask applies to the element's whole rendering, + * borders included, so a border on the viewport would fade out at the same edges its content does. */ export function Default() { const root = stylex.props(scrollAreaRoot); @@ -80,9 +73,6 @@ export function Default() { {accounts.map(({ email, organizations }, index) => ( - {/* Sibling groups would each contribute their own padding either side of a separator. - These share one group — the scroller — so the gap comes from the separator itself. - `space['2']` is the group's own padding step, so the two stay in sync. */} {index > 0 ? : null} @@ -102,12 +92,7 @@ export function Default() { ); } -/** - * The same atoms on a surface whose content fits. Nothing is conditional in the markup and no - * measurement runs — an inactive scroll timeline leaves both progress vars at their registered - * `initial-value: 0`, which the mask reads as "no fade", and the browser draws no scrollbar. - * So the resting state costs nothing and there is no "is it scrollable" branch to write. - */ +/** The same atoms on a surface whose content fits. Nothing in the markup is conditional. */ export function NotScrollable() { const root = stylex.props(scrollAreaRoot); @@ -132,11 +117,8 @@ export function NotScrollable() { const gutterRows = ['Clerk', 'DesignCloud', 'Acme Corporation', 'Globex', 'Initech', 'Umbrella Health']; /** - * `stable` holds the scrollbar's lane open even when nothing overflows, so content doesn't shift - * sideways the moment it crosses the threshold. Toggling the row count is the whole demonstration: - * `auto` jumps its content left by the lane's width as the list starts overflowing, `stable` does - * not move. Worth it for content that can change height IN PLACE — a filterable or paginated - * collection — and wasted width otherwise. + * Toggling the row count is the whole demonstration: `auto` jumps its content left by the lane's + * width as the list starts overflowing, `stable` does not move. */ export function Gutter() { const [scrollable, setScrollable] = React.useState(false); @@ -186,8 +168,7 @@ export function Gutter() { {name} - {/* The shift is only legible against something that reaches the content's - right edge — hence the trailing rule. */} + {/* The shift is only legible against something reaching the content's right edge. */}
))} @@ -200,7 +181,7 @@ export function Gutter() { ); } -const themedRows = [ +const manyRows = [ 'Clerk', 'Acme Corporation', 'Globex', @@ -220,20 +201,9 @@ const themedRows = [ ]; /** - * The far end of what `--cl-scrollbar-thumb-idle` is for. Mosaic already dims the bar while the - * pointer is elsewhere; taking that token to zero alpha removes it entirely, so the scrollbar - * appears only once you reach the region. One declaration, no rules of your own. - * - * `oklch(from … / 0)` rather than the `transparent` keyword: `transparent` is `rgba(0, 0, 0, 0)` — - * transparent BLACK — so interpolating out of it drags the thumb through a series of dark, - * half-transparent greys and the bar reads as dirty on the way in. Relative colour syntax reads the - * base token's own channels and drops only the alpha, so the only thing moving is opacity. - * - * It fades because idle → base is the one step set on the SCROLLER, which owns the transition. The - * thumb's `-hover` and `-active` still work from there, and still snap. - * - * The lane stays reserved throughout: only the thumb's paint is conditional, so nothing reflows on - * the way in or out. + * Taking `--cl-scrollbar-thumb-idle` to zero alpha removes the bar entirely until the pointer + * reaches the region. `oklch(from … / 0)` rather than `transparent`, which is transparent BLACK and + * drags the fade through dark greys. */ export function HoverReveal() { const root = stylex.props(scrollAreaRoot); @@ -246,7 +216,7 @@ export function HoverReveal() { style={{ height: 260, borderRadius: radiusVars['--cl-radius-inner'] }} > - {themedRows.map(name => ( + {manyRows.map(name => ( - {themedRows.map(name => ( + {manyRows.map(name => ( - {themedRows.map(name => ( + {manyRows.map(name => ( Date: Fri, 31 Jul 2026 19:09:05 -0600 Subject: [PATCH 22/24] chore(repo): Drop the scroll area changesets in favour of the empty one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing in the scroll area is reachable from a published entry point: the `exports` map exposes `./styles.css` but not `./mosaic`, so `scrollAreaViewport` and `scrollAreaRoot` cannot be imported, and no shipped Mosaic component applies the atoms. All a consumer's `styles.css` gains is a handful of inert `:root` declarations and unused atoms. Both changesets also described token churn that only ever happened inside this branch — `--cl-scroll-fade-inset` being removed, `--cl-scrollbar-width` changing from a keyword to a length — which would have read as breaking changes against a state nobody was ever given. The tokens become a real API the moment a shipped component scrolls with them or `./mosaic` is exported; that is the change worth a changeset. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/lazy-pans-tickle.md | 26 -------------------------- .changeset/mosaic-scroll-area.md | 19 ------------------- 2 files changed, 45 deletions(-) delete mode 100644 .changeset/lazy-pans-tickle.md delete mode 100644 .changeset/mosaic-scroll-area.md diff --git a/.changeset/lazy-pans-tickle.md b/.changeset/lazy-pans-tickle.md deleted file mode 100644 index 51465bfa18d..00000000000 --- a/.changeset/lazy-pans-tickle.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -'@clerk/ui': patch ---- - -Mosaic's scrolling surfaces now paint their own scrollbar: a 4px pill that deepens while the pointer is on it and again while you drag it. Previously the browser derived those states from `scrollbar-color` and got them backwards, lightening the thumb on hover. - -New tokens, applied to every scrolling surface at once: - -| Token | Default | -| ----------------------------- | ------------------------ | -| `--cl-scrollbar-thumb` | derived from the palette | -| `--cl-scrollbar-thumb-idle` | derived from the above | -| `--cl-scrollbar-thumb-hover` | derived from the above | -| `--cl-scrollbar-thumb-active` | derived from the above | -| `--cl-scrollbar-thumb-inset` | `2px` | - -The colours are four states running quietest to loudest: `idle` while the pointer is elsewhere, the base once it reaches the region, then `hover` and `active` for the thumb's own two. `--cl-scrollbar-thumb-idle: oklch(from var(--cl-scrollbar-thumb) l c h / 0)` is the whole recipe for a scrollbar that fades in on approach and gives up no layout doing it. - -Two breaking notes if you were already theming these: - -- `--cl-scrollbar-width` now takes a **length** (default `8px`) rather than the `auto | thin | none` keyword. Use `0px` where you previously used `none`. -- `--cl-scroll-fade-inset` is removed. The mask now derives its inset from `--cl-scrollbar-width`, which closes the gap where the edge fade covered part of the scrollbar. - -Firefox implements neither `::-webkit-scrollbar` nor an equivalent, so it keeps its platform scrollbar; touch platforms keep their native overlay bar as before. On macOS, styling the scrollbar takes it out of overlay mode, so the bar is always visible and always occupies its lane. - -The thumb's `hover` and `active` colours switch instantly rather than fading. Blink runs no transition declared on `::-webkit-scrollbar-thumb`, so the transition lives on the scroller and the thumb inherits the animating value — meaning a change made on the scroller fades, while one made on the thumb itself can only snap. Retargeting `--cl-scrollbar-thumb` from a region's own `:hover` (to fade a scrollbar in) does transition. diff --git a/.changeset/mosaic-scroll-area.md b/.changeset/mosaic-scroll-area.md deleted file mode 100644 index 5e416c4ffe3..00000000000 --- a/.changeset/mosaic-scroll-area.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@clerk/ui': minor ---- - -Add a Mosaic scroll area: a scrolling region that fades its content at whichever edge still has something to reveal. - -It ships as StyleX atoms rather than a component, because everything it does is CSS — a component would only add a DOM node and an API to version. `scrollAreaViewport(gutter?)` returns the atoms for the element that scrolls, and `scrollAreaRoot` styles a positioned ancestor for cases where an overlay has to anchor against the scroll box. The atoms bring no class of their own, so the element you apply them to keeps its existing `.cl-` class, and that stays the hook a theme targets. - -```tsx -{rows} -``` - -The indicators are driven by scroll-driven animations — no scroll listener and no measurement. Because the fade is a mask rather than a sticky overlay element, it is paint-only and cannot shift the content. Browsers without scroll-driven animation support get a plain scroll area rather than a broken one, and a region with nothing to scroll shows no indicators at all. - -`gutter` defaults to `auto`, matching CSS. Pass `stable` for a collection that can change height in place — a filterable list, a paginated table — so that crossing the overflow threshold doesn't shift its rows sideways. - -The treatment is replaceable in plain CSS. Set `mask-image: none` on the element carrying the atoms to retire the default fade, and read `--cl-scroll-area-progress-start` / `--cl-scroll-area-progress-end` — per-element values the animations write, describing how much each edge still has to reveal — to drive a shadow or any other indicator. - -Also adds four theme tokens that apply to every scrolling surface in Mosaic rather than to one component: `--cl-scroll-fade-size` and `--cl-scroll-fade-range` (both `1.5rem`) tune the fade's height and how far you scroll before it reaches full strength, `--cl-scroll-fade-inset` (`0px`) holds the fade back from a space-consuming scrollbar, and `--cl-scrollbar-width` (`thin`) sets the scrollbar size, applied only under `@media (pointer: fine)` so touch platforms keep the native overlay bar. Per the CSS spec that last one is keyword-only (`auto`, `thin`, or `none`) — `scrollbar-width` does not accept a length. From 356258d323a11f6f809f61975e9db20912550247 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Mon, 3 Aug 2026 10:51:10 -0600 Subject: [PATCH 23/24] docs(swingset): Register the shadow example and drop a redundant alt Co-Authored-By: Claude Opus 5 (1M context) --- packages/swingset/src/lib/registry.ts | 2 ++ packages/swingset/src/stories/item.stories.tsx | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 9f3493a3dc6..93db371c0e5 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -101,6 +101,7 @@ import { HoverReveal as ScrollAreaHoverReveal, meta as scrollAreaMeta, NotScrollable as ScrollAreaNotScrollable, + ShadowIndicators as ScrollAreaShadowIndicators, ThemedScrollbar as ScrollAreaThemedScrollbar, } from '../stories/scroll-area.stories'; import { meta as selectMeta } from '../stories/select.stories'; @@ -230,6 +231,7 @@ const scrollAreaModule: StoryModule = { Gutter: ScrollAreaGutter, HoverReveal: ScrollAreaHoverReveal, ThemedScrollbar: ScrollAreaThemedScrollbar, + ShadowIndicators: ScrollAreaShadowIndicators, }; const useDataTableModule: StoryModule = { meta: useDataTableMeta }; diff --git a/packages/swingset/src/stories/item.stories.tsx b/packages/swingset/src/stories/item.stories.tsx index 242fb970330..cbf66563c04 100644 --- a/packages/swingset/src/stories/item.stories.tsx +++ b/packages/swingset/src/stories/item.stories.tsx @@ -386,7 +386,7 @@ export function Scrolling() { > {name[0]} From e1da884899df80109eb6a7c8284d8b9096b06c9b Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Mon, 3 Aug 2026 10:51:11 -0600 Subject: [PATCH 24/24] test(ui): Split the fade inset assertion into its own case Co-Authored-By: Claude Opus 5 (1M context) --- .../ui/src/mosaic/components/scroll-area/scroll-area.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/ui/src/mosaic/components/scroll-area/scroll-area.test.ts b/packages/ui/src/mosaic/components/scroll-area/scroll-area.test.ts index 4669507d8a9..3acd569ae2d 100644 --- a/packages/ui/src/mosaic/components/scroll-area/scroll-area.test.ts +++ b/packages/ui/src/mosaic/components/scroll-area/scroll-area.test.ts @@ -54,6 +54,9 @@ describe('Mosaic scroll area styles', () => { expect(scrollbarThumbVars).toMatchObject({ '--_cl-scrollbar-thumb-color': 'var(--_cl-scrollbar-thumb-color)', }); + }); + + it('keeps the fade inset off the public token namespace', () => { expect(Object.keys(scrollFadeVars)).not.toContain('--cl-scroll-fade-inset'); }); });