Skip to content
19 changes: 19 additions & 0 deletions .changeset/mosaic-scroll-area.md
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.
2 changes: 2 additions & 0 deletions packages/swingset/src/lib/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -174,6 +175,7 @@ const itemModule: StoryModule = {
Default: ItemDefault,
Interactive: ItemInteractive,
Group: ItemGroup,
Scrolling: ItemScrolling,
};

const headingModule: StoryModule = {
Expand Down
88 changes: 88 additions & 0 deletions packages/swingset/src/stories/item.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<Story
name='Scrolling'
storyModule={ItemStories}
/>

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';

<div
style={{ height: 260 }}
{...stylex.props(scrollAreaRoot)}
>
<Item.Group {...stylex.props(...scrollAreaViewport())}>{organizations}</Item.Group>
</div>;
```

`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
Expand Down
59 changes: 59 additions & 0 deletions packages/swingset/src/stories/item.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -332,3 +334,60 @@ export function Group() {
</div>
);
}

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 (
<div
className='w-full max-w-sm'
style={{ height: 260 }}
{...stylex.props(scrollAreaRoot)}
>
<Item.Group {...stylex.props(...scrollAreaViewport())}>
{organizations.map(name => (
<Item.Root
key={name}
render={({ children, ...props }) => (
<button
{...props}
type='button'
>
{children}
</button>
)}
>
<Item.Media>
<Avatar.Root
size='fit'
shape='circle'
>
<Avatar.Fallback>{name[0]}</Avatar.Fallback>
</Avatar.Root>
</Item.Media>
<Item.Content>
<Item.Title>{name}</Item.Title>
<Item.Description>{organizations.indexOf(name) + 3} members</Item.Description>
</Item.Content>
</Item.Root>
))}
</Item.Group>
</div>
);
}
3 changes: 3 additions & 0 deletions packages/ui/src/mosaic/components/scroll-area/index.ts
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 packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts
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)`;
Comment on lines +40 to +48

Copy link
Copy Markdown
Contributor

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:

#!/bin/bash
set -euo pipefail

echo "## locate files"
fd -a 'scroll-area\.styles\.ts|tokens\.stylex\.ts' . | sed 's#^\./##'

echo "## scroll-area.styles outline"
ast-grep outline packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts || true

echo "## relevant scroll-area.styles.ts"
sed -n '1,130p' packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts

echo "## search scroll fade inset and scrollbar-related usages"
rg -n "`@cl-scroll-fade-inset`|--cl-scroll-fade-inset|scrollbar|fade" -S packages/ui/src packages -g '!**/node_modules' --glob '*.ts' --glob '*.tsx' | head -200

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-inset is documented as holding the fade back from a strip at the inline end, but maskPosition: '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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts` around
lines 40 - 48, Update the mask positioning associated with maskImage so the
scrollbar strip uses the logical inline-end position rather than a physical
right-edge position. Replace the physical positioning keywords in the relevant
maskPosition declaration with logical keywords, preserving the existing
first-layer positioning and inset behavior for both LTR and RTL layouts.


// 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;
Loading
Loading