diff --git a/.changeset/mosaic-scroll-area.md b/.changeset/mosaic-scroll-area.md new file mode 100644 index 00000000000..5e416c4ffe3 --- /dev/null +++ b/.changeset/mosaic-scroll-area.md @@ -0,0 +1,19 @@ +--- +'@clerk/ui': minor +--- + +Add a Mosaic scroll area: a scrolling region that fades its content at whichever edge still has something to reveal. + +It ships as StyleX atoms rather than a component, because everything it does is CSS — a component would only add a DOM node and an API to version. `scrollAreaViewport(gutter?)` returns the atoms for the element that scrolls, and `scrollAreaRoot` styles a positioned ancestor for cases where an overlay has to anchor against the scroll box. The atoms bring no class of their own, so the element you apply them to keeps its existing `.cl-` class, and that stays the hook a theme targets. + +```tsx +{rows} +``` + +The indicators are driven by scroll-driven animations — no scroll listener and no measurement. Because the fade is a mask rather than a sticky overlay element, it is paint-only and cannot shift the content. Browsers without scroll-driven animation support get a plain scroll area rather than a broken one, and a region with nothing to scroll shows no indicators at all. + +`gutter` defaults to `auto`, matching CSS. Pass `stable` for a collection that can change height in place — a filterable list, a paginated table — so that crossing the overflow threshold doesn't shift its rows sideways. + +The treatment is replaceable in plain CSS. Set `mask-image: none` on the element carrying the atoms to retire the default fade, and read `--cl-scroll-area-progress-start` / `--cl-scroll-area-progress-end` — per-element values the animations write, describing how much each edge still has to reveal — to drive a shadow or any other indicator. + +Also adds four theme tokens that apply to every scrolling surface in Mosaic rather than to one component: `--cl-scroll-fade-size` and `--cl-scroll-fade-range` (both `1.5rem`) tune the fade's height and how far you scroll before it reaches full strength, `--cl-scroll-fade-inset` (`0px`) holds the fade back from a space-consuming scrollbar, and `--cl-scrollbar-width` (`thin`) sets the scrollbar size, applied only under `@media (pointer: fine)` so touch platforms keep the native overlay bar. Per the CSS spec that last one is keyword-only (`auto`, `thin`, or `none`) — `scrollbar-width` does not accept a length. diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 3b1e386414c..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'; @@ -174,6 +175,7 @@ const itemModule: StoryModule = { Default: ItemDefault, Interactive: ItemInteractive, Group: ItemGroup, + Scrolling: ItemScrolling, }; const headingModule: StoryModule = { diff --git a/packages/swingset/src/stories/item.mdx b/packages/swingset/src/stories/item.mdx index 2aea12d0cac..0ae71db3855 100644 --- a/packages/swingset/src/stories/item.mdx +++ b/packages/swingset/src/stories/item.mdx @@ -34,6 +34,94 @@ 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. | + +Scrollbar appearance — colour and width — only applies under `@media (pointer: fine)`, so touch +platforms keep the native overlay bar they already draw. The gutter is not gated: it is a layout +decision rather than an appearance one. + +`--cl-scroll-fade-inset` defaults to `0px` because **CSS cannot measure a scrollbar**: the width +differs per platform and browser, and on macOS it changes at runtime when a mouse is connected. Set +it only when you know the platform you're targeting. Until then the mask covers the scrollbar, which +is the one thing this approach can't solve without a custom scrollbar. + +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/ui/src/mosaic/components/scroll-area/index.ts b/packages/ui/src/mosaic/components/scroll-area/index.ts new file mode 100644 index 00000000000..5c72aeb1986 --- /dev/null +++ b/packages/ui/src/mosaic/components/scroll-area/index.ts @@ -0,0 +1,3 @@ +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 new file mode 100644 index 00000000000..f506d5263e7 --- /dev/null +++ b/packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts @@ -0,0 +1,171 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, scrollbarVars, scrollFadeVars, 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 = scrollFadeVars['--cl-scroll-fade-size']; +const fadeRange = scrollFadeVars['--cl-scroll-fade-range']; +const fadeInset = scrollFadeVars['--cl-scroll-fade-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. `scrollAreaViewport()` recomposes them, so callers spread one thing. +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, + // 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: 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: { default: null, '@media (pointer: fine)': scrollbarVars['--cl-scrollbar-width'] }, + minHeight: 0, + overflowX: 'hidden', + overflowY: 'auto', + }, + + /** Paint-only, so it can never shift the content the way a sticky shadow element does. */ + mask: { + maskImage, + maskPosition: 'left top, right top', + maskRepeat: 'no-repeat', + maskSize: `calc(100% - ${fadeInset}) 100%, ${fadeInset} 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. +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', + }, +}); + +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.vars.stylex.ts b/packages/ui/src/mosaic/components/scroll-area/scroll-area.vars.stylex.ts new file mode 100644 index 00000000000..5260b6d7171 --- /dev/null +++ b/packages/ui/src/mosaic/components/scroll-area/scroll-area.vars.stylex.ts @@ -0,0 +1,22 @@ +import * as stylex from '@stylexjs/stylex'; + +// 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: +// +// 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), +}); diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index e18ee008525..b70be31dd94 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 { 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'; @@ -46,6 +48,8 @@ import { easingVars, fontWeightVars, radiusVars, + scrollbarVars, + scrollFadeVars, space, spacingVars, targetVars, @@ -58,6 +62,8 @@ export { easingVars, fontWeightVars, radiusVars, + scrollbarVars, + scrollFadeVars, space, spacingVars, targetVars, @@ -72,6 +78,8 @@ 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 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 4f5ba031af9..560e4f77bbc 100644 --- a/packages/ui/src/mosaic/tokens.stylex.ts +++ b/packages/ui/src/mosaic/tokens.stylex.ts @@ -87,6 +87,54 @@ 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. +// +// Only applied under `@media (pointer: fine)`. A touch platform draws an overlay bar there is +// no width to apply to, and thinning a target that is already hard to hit would be actively +// worse — Polaris's `s-scroll-box` gates its scrollbar styling the same way. +// +// Keyword-only, by the CSS spec: `scrollbar-width` accepts `auto | thin | none` and NOT a +// length. A real pixel width exists only via `::-webkit-scrollbar`, which Firefox ignores +// and which Chrome 121+ discards once `scrollbar-color` is set — so there is no honest way +// to expose this as a length. +const scrollbarDefaults = { + '--cl-scrollbar-width': 'thin', +} as const; + +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 // =============================================================================