Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
4ee6ad4
feat(ui): Add Mosaic ScrollArea with scroll-driven fade indicators
maxyinger Jul 31, 2026
49ead2a
docs(swingset): Document Mosaic ScrollArea
maxyinger Jul 31, 2026
29d28db
refactor(ui): Promote the scroll fade knobs to theme tokens
maxyinger Jul 31, 2026
1a66945
fix(ui): Manage ScrollArea's tab stop so consumers don't have to
maxyinger Jul 31, 2026
3d9408e
feat(ui): Add a render prop to both ScrollArea parts
maxyinger Jul 31, 2026
9b2c996
refactor(ui): Ship the scroll area as StyleX atoms instead of a compo…
maxyinger Jul 31, 2026
74b5793
docs(swingset): Document the scroll surface on Item instead of its ow…
maxyinger Jul 31, 2026
0c77987
fix(ui): Only style the scrollbar under a fine pointer
maxyinger Jul 31, 2026
0756b4b
feat(ui): Paint the Mosaic scrollbar via ::-webkit-scrollbar
maxyinger Jul 31, 2026
58f0a2e
fix(ui): Scope the scrollbar hover to the thumb and clear the mask of…
maxyinger Jul 31, 2026
7bce202
docs(swingset): Give atomic styles their own section
maxyinger Jul 31, 2026
21cdf7b
docs(swingset): Double the rows in Item's scrolling example
maxyinger Jul 31, 2026
007de24
fix(ui): Restore the thumb colour transition to the scroller
maxyinger Jul 31, 2026
fa25346
docs(swingset): Tighten the scroll example borders to the smallest ra…
maxyinger Jul 31, 2026
a32746c
docs(swingset): Give the theming example a step that actually animates
maxyinger Aug 1, 2026
041f7a9
docs(swingset): Quiet the hover reveal and slow the theming demo
maxyinger Aug 1, 2026
9ca546c
docs(swingset): Match the hover-reveal code sample to the example
maxyinger Aug 1, 2026
afa09e2
feat(ui): Dim the scrollbar thumb until the pointer reaches the region
maxyinger Aug 1, 2026
c8cde2b
fix(ui): Drop the thumb offset — it deformed the pill's caps
maxyinger Aug 1, 2026
1b2c81c
docs(swingset): Replace the scroll fade with shadow overlays in an ex…
maxyinger Aug 1, 2026
78dee07
docs(swingset): Tighten the scroll area copy and correct stale claims
maxyinger Aug 1, 2026
cef0fc7
chore(repo): Drop the scroll area changesets in favour of the empty one
maxyinger Aug 1, 2026
356258d
docs(swingset): Register the shadow example and drop a redundant alt
maxyinger Aug 3, 2026
e1da884
test(ui): Split the fade inset assertion into its own case
maxyinger Aug 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/gentle-hoops-relax.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
11 changes: 10 additions & 1 deletion packages/swingset/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ Pick the archetype below by the component's **layer** (its `meta.group`), then f

### Layers

`meta.group` places a component in one of six layers. Sidebar order follows the `registry` array; group order follows first appearance there. Use these exact group strings:
`meta.group` places an entry in one of these layers. Sidebar order follows the `registry` array; group order follows first appearance there. Use these exact group strings:

| Group | What lives here | Archetype |
| ------------ | -------------------------------------------------------------- | --------- |
Expand All @@ -67,9 +67,18 @@ Pick the archetype below by the component's **layer** (its `meta.group`), then f
| `Blocks` | Reusable composite UI (e.g. `Destructive`) | C |
| `Components` | Styled Mosaic components — simple CVA recipe (`Button`, `Input`) or compound/slot-based (`Dialog`, `Tabs`) | A |
| `Primitives` | Headless `@clerk/headless` primitives (`Accordion`) | B |
| `Styles` | Atomic styles that ship as StyleX atoms, not components (`Scroll Area`) | B (adapted) |
| `Hooks` | Headless hooks (`useDataTable`) | B (adapted) |

`AIO` → `Panels` → `Sections` → `Blocks` → `Components` → `Primitives` runs roughly high-level-composition → low-level-primitive. Composed layers (AIO/Panels/Sections/Blocks) are documented as compositions of lower layers (archetype C); leaf layers (Components, Primitives) get full prop/knob docs (archetypes A and B).

`Styles` and `Hooks` are the non-component layers: there is no element to knob, so they follow
archetype B's shape (Example → Usage → Parts → Styling) with `Props` replaced by whatever the export
actually surfaces — an argument table for a style function, a return-value table for a hook. A
`Styles` entry documents the theme tokens its atoms read, since those tokens _are_ its API; the
`Hooks` entry (`use-data-table.stories.tsx`) is `meta` alone, with no story exports at all, which is
the minimum a section entry needs.

Archetype A has two forms, chosen by whether the component exposes a single flat CVA recipe: **simple** components (`Button`, `Input`) are knob-driven; **compound** components built from slot recipes (`Dialog`, `Tabs`) have no flat variant props to knob, so they're documented like a primitive but themed. Both are detailed under Archetype A below.

### `meta` conventions (all archetypes)
Expand Down
16 changes: 12 additions & 4 deletions packages/swingset/src/components/ClientRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,25 @@ import {
} from '@/components/ui/breadcrumb';
import { Separator } from '@/components/ui/separator';
import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar';
import { getModule } from '@/lib/registry';

import { AppSidebar } from './app-sidebar';
import { ThemeToggle } from './ThemeToggle';

function useBreadcrumb() {
const pathname = usePathname();
// /components/button → ["Button"]
// /primitives/dialog → ["Dialog"]
// The first segment is the group; drop it and surface the component (plus any sub-path).
const parts = pathname.split('/').filter(Boolean).slice(1);
return parts.map(p => p.charAt(0).toUpperCase() + p.slice(1).replace(/-/g, ' '));
// /styles/scroll-area → ["Scroll Area"]
// The first segment is the group; drop it and surface the entry (plus any sub-path).
const [groupSlug, ...parts] = pathname.split('/').filter(Boolean);

// Prefer the registry's own `meta.title`, which is the only source that round-trips a slug back
// to how the entry is actually written — `scroll-area` → `Scroll Area`, `use-data-table` →
// `useDataTable`. Fall back to title-casing the slug for any path the registry doesn't cover.
return parts.map((part, index) => {
const title = index === 0 ? getModule(groupSlug, part)?.meta.title : undefined;
return title ?? part.replace(/(^|-)([a-z])/g, (_, sep: string, ch: string) => (sep ? ' ' : '') + ch.toUpperCase());
});
}

export function ClientRoot({ children }: { children: React.ReactNode }) {
Expand Down
4 changes: 4 additions & 0 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ const docModules: Record<string, Record<string, React.ComponentType>> = {
tabs: dynamic(() => import('../stories/tabs.mdx')),
tooltip: dynamic(() => import('../stories/tooltip.mdx')),
},
styles: {
// Atomic styles — shipped as StyleX atoms rather than components.
'scroll-area': dynamic(() => import('../stories/scroll-area.mdx')),
},
hooks: {
// Headless hooks — alphabetical.
'use-data-table': dynamic(() => import('../stories/use-data-table.mdx')),
Expand Down
14 changes: 10 additions & 4 deletions packages/swingset/src/components/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,16 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<SidebarMenu>
{components.map(({ mod, componentSlug }) => {
const href = `/${groupSlug}/${componentSlug}`;
// Hooks (e.g. `useDataTable`) are called, not rendered — show `useX()` rather
// than JSX `<useX />`. Everything else is a component.
const isHook = /^use[A-Z]/.test(mod.meta.title);
const usage = isHook ? `${mod.meta.title}()` : `<${mod.meta.title} />`;
// How an entry is USED differs by layer, so the label follows the layer rather
// than a guess at the title: hooks are called, atomic styles are a set of
// exports with no single call form worth privileging, and everything else is a
// component rendered as JSX.
const usage =
mod.meta.group === 'Hooks'
? `${mod.meta.title}()`
: mod.meta.group === 'Styles'
? mod.meta.title
: `<${mod.meta.title} />`;
return (
<SidebarMenuItem key={mod.meta.title}>
<SidebarMenuButton
Expand Down
23 changes: 23 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 @@ -94,6 +95,15 @@ import {
Placement as PopoverComponentPlacement,
} from '../stories/popover.component.stories';
import { meta as popoverMeta } from '../stories/popover.stories';
import {
Default as ScrollAreaDefault,
Gutter as ScrollAreaGutter,
HoverReveal as ScrollAreaHoverReveal,
meta as scrollAreaMeta,
NotScrollable as ScrollAreaNotScrollable,
ShadowIndicators as ScrollAreaShadowIndicators,
ThemedScrollbar as ScrollAreaThemedScrollbar,
} from '../stories/scroll-area.stories';
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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';
Expand Down Expand Up @@ -174,6 +184,7 @@ const itemModule: StoryModule = {
Default: ItemDefault,
Interactive: ItemInteractive,
Group: ItemGroup,
Scrolling: ItemScrolling,
};

const headingModule: StoryModule = {
Expand Down Expand Up @@ -213,6 +224,16 @@ const selectModule: StoryModule = { meta: selectMeta };
const tabsModule: StoryModule = { meta: tabsMeta };
const tooltipModule: StoryModule = { meta: tooltipMeta };

const scrollAreaModule: StoryModule = {
meta: scrollAreaMeta,
Default: ScrollAreaDefault,
NotScrollable: ScrollAreaNotScrollable,
Gutter: ScrollAreaGutter,
HoverReveal: ScrollAreaHoverReveal,
ThemedScrollbar: ScrollAreaThemedScrollbar,
ShadowIndicators: ScrollAreaShadowIndicators,
};

const useDataTableModule: StoryModule = { meta: useDataTableMeta };

export const registry: StoryModule[] = [
Expand Down Expand Up @@ -254,6 +275,8 @@ export const registry: StoryModule[] = [
selectModule,
tabsModule,
tooltipModule,
// Styles — atomic styles that ship as StyleX atoms rather than components.
scrollAreaModule,
// Hooks — alphabetical within the group.
useDataTableModule,
];
Expand Down
27 changes: 27 additions & 0 deletions packages/swingset/src/stories/item.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,33 @@ Set `size` once on `Item.Root` and the row scales as a unit: it fixes the row's
storyModule={ItemStories}
/>

### Scrolling

`Item.Group` is the canonical scroll surface in Mosaic: cap its height, spread the scroll-area atoms
onto it, and it fades its content at whichever edge still has something to reveal.

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

```tsx
import { scrollAreaRoot, scrollAreaViewport } from '@clerk/ui/mosaic/components/scroll-area';
import * as stylex from '@stylexjs/stylex';

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

The atoms aren't specific to `Item` — they go on anything that scrolls, and they carry the edge
fades, the scrollbar, the gutter, and the theming tokens with them. See
[Scroll Area](/styles/scroll-area) for the full surface: the gutter argument, what happens when
there is nothing to scroll, the token table, and how to replace the fade entirely.

## Usage

```tsx
Expand Down
68 changes: 68 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,9 @@
import { Avatar } from '@clerk/ui/mosaic/components/avatar';
import { Button } from '@clerk/ui/mosaic/components/button';
import { Item } from '@clerk/ui/mosaic/components/item';
import { scrollAreaRoot, scrollAreaViewport } from '@clerk/ui/mosaic/components/scroll-area';
import { radiusVars } from '@clerk/ui/mosaic/styles';
import * as stylex from '@stylexjs/stylex';
import * as React from 'react';

import type { StoryMeta } from '@/lib/types';
Expand Down Expand Up @@ -332,3 +335,68 @@ export function Group() {
</div>
);
}

const organizations = [
'Clerk',
'Acme Corporation',
'Globex',
'Initech',
'Umbrella Health',
'DesignCloud',
'Stark Industries',
'Wayne Enterprises',
'Cyberdyne Systems',
'Soylent Industries',
'Tyrell Corporation',
'Weyland-Yutani',
];

// `Item.Group` is the canonical scroll surface, so this shows the atoms doing the minimum: cap a
// height, spread them on, and the group fades its own edges. The Scroll Area page under Styles
// carries the full surface — the gutter argument, the resting state, and the theming tokens.
export function Scrolling() {
// `stylex.props()` returns a `className`, so it has to be MERGED with any class of your own
// rather than spread beside one — whichever comes last in JSX wins outright.
const root = stylex.props(scrollAreaRoot);

return (
<div
{...root}
className={`${root.className} border-border w-full border`}
style={{ height: 200, borderRadius: radiusVars['--cl-radius-inner'] }}
>
<Item.Group {...stylex.props(...scrollAreaViewport())}>
{organizations.map(name => (
<Item.Root
key={name}
size='xs'
render={({ children, ...props }) => (
<button
{...props}
type='button'
>
{children}
</button>
)}
>
<Item.Media>
<Avatar.Root
size='fit'
shape='square'
>
<Avatar.Image
src='https://github.com/clerk.png'
alt=''
/>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<Avatar.Fallback>{name[0]}</Avatar.Fallback>
</Avatar.Root>
</Item.Media>
<Item.Content>
<Item.Title>{name}</Item.Title>
</Item.Content>
</Item.Root>
))}
</Item.Group>
</div>
);
}
Loading
Loading