From e8040ab5627af400bf6b99d8e3e3064960b8d2ec Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 31 Jul 2026 14:14:59 -0600 Subject: [PATCH 1/8] 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 e18ee008525..e4b531bc9bb 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -26,6 +26,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'; @@ -46,6 +48,7 @@ import { easingVars, fontWeightVars, radiusVars, + scrollbarVars, space, spacingVars, targetVars, @@ -58,6 +61,7 @@ export { easingVars, fontWeightVars, radiusVars, + scrollbarVars, space, spacingVars, targetVars, @@ -72,6 +76,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 9af8d590dc700913410909ef03989ea009f466d8 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 31 Jul 2026 14:15:10 -0600 Subject: [PATCH 2/8] 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 6d30e73de5d761ce340f3eb4feedb4104a8549b1 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 31 Jul 2026 14:26:50 -0600 Subject: [PATCH 3/8] 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 e4b531bc9bb..5dcc8782c4f 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -49,6 +49,7 @@ import { fontWeightVars, radiusVars, scrollbarVars, + scrollFadeVars, space, spacingVars, targetVars, @@ -62,6 +63,7 @@ export { fontWeightVars, radiusVars, scrollbarVars, + scrollFadeVars, space, spacingVars, targetVars, @@ -77,6 +79,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 6f83773f962caa5644e16516a5d411ad1e555b43 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 31 Jul 2026 14:40:54 -0600 Subject: [PATCH 4/8] 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 4b13ce57b0220481afe42259bb5c2685891231bd Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 31 Jul 2026 14:54:10 -0600 Subject: [PATCH 5/8] 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 7c519e0e639172f64e6521d8d58578c4ae156a79 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 31 Jul 2026 15:29:47 -0600 Subject: [PATCH 6/8] 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 5dcc8782c4f..b70be31dd94 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -26,8 +26,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 80d0beef6f136578046e8a0883a31dca2b59491e Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 31 Jul 2026 15:41:39 -0600 Subject: [PATCH 7/8] 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 162c5d47cdc3f8a80226c5c3e537e26869044310 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 31 Jul 2026 15:49:47 -0600 Subject: [PATCH 8/8] 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