-
Notifications
You must be signed in to change notification settings - Fork 460
feat(ui): Add a Mosaic scroll area as StyleX atoms #9311
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
maxyinger
wants to merge
8
commits into
main
Choose a base branch
from
max/scroll-area
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
e8040ab
feat(ui): Add Mosaic ScrollArea with scroll-driven fade indicators
maxyinger 9af8d59
docs(swingset): Document Mosaic ScrollArea
maxyinger 6d30e73
refactor(ui): Promote the scroll fade knobs to theme tokens
maxyinger 6f83773
fix(ui): Manage ScrollArea's tab stop so consumers don't have to
maxyinger 4b13ce5
feat(ui): Add a render prop to both ScrollArea parts
maxyinger 7c519e0
refactor(ui): Ship the scroll area as StyleX atoms instead of a compo…
maxyinger 80d0bee
docs(swingset): Document the scroll surface on Item instead of its ow…
maxyinger 162c5d4
fix(ui): Only style the scrollbar under a fine pointer
maxyinger File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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-<slot>` class, and that stays the hook a theme targets. | ||
|
|
||
| ```tsx | ||
| <Item.Group {...stylex.props(...scrollAreaViewport())}>{rows}</Item.Group> | ||
| ``` | ||
|
|
||
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'; |
171 changes: 171 additions & 0 deletions
171
packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 `<ScrollArea>` 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 | ||
| * <div {...stylex.props(scrollAreaRoot)}> | ||
| * <Item.Group {...stylex.props(...scrollAreaViewport())}>{rows}</Item.Group> | ||
| * </div> | ||
| * ``` | ||
| * | ||
| * @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; | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: clerk/javascript
Length of output: 31972
🌐 Web query:
CSS Masking mask-position logical keywords inline-start inline-end browser support💡 Result:
As of July 31, 2026, the CSS property mask-position supports flow-relative (logical) keywords such as inline-start, inline-end, block-start, and block-end as part of the data type [1][2]. These keywords allow developers to position mask images relative to the writing mode and text direction of the element, rather than relying on fixed physical directions like top, left, right, or bottom [2]. Regarding browser support: - While the data type has been updated in specifications to include these logical keywords [2], full browser support for using them specifically within the mask-position property is generally aligned with the support for the mask-position property itself [1][3]. - As of the current date, mask-position has achieved broad support across all major modern browsers, including Chrome, Edge, Firefox, and Safari [1][3]. - Because mask-position follows the standard grammar, environments that support mask-position generally interpret these logical keywords as part of the standard position syntax [1][2]. You can use these keywords in the same manner as physical keywords within the property value, for example: mask-position: inline-start 10px block-start 20px; This will position the mask 10px from the inline-start edge and 20px from the block-start edge of the element's positioning area, adapting automatically if the writing mode changes [4][2].
Citations:
Use the inline-side mask position for the scrollbar strip.
--cl-scroll-fade-insetis documented as holding the fade back from a strip at the inline end, butmaskPosition: 'left top, right top'fixes the opaque strip to the physical right edge. With a non-zero inset in an RTL layout, the fade would cover the actual scrollbar space on the left while the right side has an unfaded opaque strip. Use logical keywords that match the scrollbar gutter/overlay direction.🌐 Proposed fix using logical position keywords
mask: { maskImage, - maskPosition: 'left top, right top', + maskPosition: 'inline-start top, inline-end top', maskRepeat: 'no-repeat', mask size: `calc(100% - ${fadeInset}) 100%, ${fadeInset} 100%`, },🤖 Prompt for AI Agents