Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 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
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
21 changes: 21 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,14 @@ 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,
ThemedScrollbar as ScrollAreaThemedScrollbar,
} from '../stories/scroll-area.stories';
Comment on lines +98 to +105

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.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Find consumers that enumerate story-module keys generically, beyond `.meta`.
rg -n 'Object\.(keys|entries|values)\(' packages/swingset/src --glob '*.{ts,tsx}' -C3
rg -n 'storyModule\b' packages/swingset/src --glob '*.{ts,tsx}' -C3

Repository: clerk/javascript

Length of output: 10068


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== registry imports and scrollAreaModule =="
sed -n '90,110p;220,236p' packages/swingset/src/lib/registry.ts

echo
echo "== scroll-area stories exports and shadow indicators =="
rg -n "^export (const|function|interface|type|default) (ShadowIndicators|meta|Default|Gutter|HoverReveal|NotScrollable|ThemedScrollbar)" packages/swingset/src/stories/scroll-area.stories.tsx -C 2 || true

echo
echo "== registry.ts StoryModule type =="
rg -n "interface StoryModule|type StoryModule|meta:" packages/swingset/src/lib/registry.ts -C 3

echo
echo "== docsViewer registry usage =="
rg -n "getModule|docsModules|registry" packages/swingset/src/ -g '*.ts' -g '*.tsx' -C 2

Repository: clerk/javascript

Length of output: 12457


Register the missing ShadowIndicators story.

scroll-area.stories.tsx exports ShadowIndicators, but registry.ts imports and registers only the other five scroll area stories. Add ShadowIndicators to both the import and scrollAreaModule so lib/registry.ts remains the single source of truth for the story components.

🤖 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/swingset/src/lib/registry.ts` around lines 98 - 105, Update the
scroll-area story registration by importing the exported ShadowIndicators symbol
alongside the existing ScrollArea story imports and adding it to
scrollAreaModule with the other registered stories. Keep registry.ts as the
single source of truth for all six scroll-area stories.

Source: Path instructions

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 +183,7 @@ const itemModule: StoryModule = {
Default: ItemDefault,
Interactive: ItemInteractive,
Group: ItemGroup,
Scrolling: ItemScrolling,
};

const headingModule: StoryModule = {
Expand Down Expand Up @@ -213,6 +223,15 @@ 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,
};

const useDataTableModule: StoryModule = { meta: useDataTableMeta };

export const registry: StoryModule[] = [
Expand Down Expand Up @@ -254,6 +273,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={name}
/>
Comment on lines +387 to +390

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

Avoid duplicating the organization name in the accessible label.

Item.Title already renders name inside the button. alt={name} can cause assistive technology to announce the organization name twice. Use alt='', as shown in packages/swingset/src/stories/item.mdx Line 74, unless the image conveys information different from the title.

🤖 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/swingset/src/stories/item.stories.tsx` around lines 387 - 390,
Update the Avatar.Image in the Item story to use an empty alt value because
Item.Title already provides the organization name; follow the existing pattern
in the corresponding Item MDX example and preserve the current image source.

<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