Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions .changeset/mosaic-banner-component.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@clerk/ui': minor
---

Add the Mosaic `Banner` component: a tinted surface that annotates the content around it with a status message. Compose it from `Banner.Root`, `Banner.Label`, and `Banner.Description`. `Banner.Root` takes a `color` of `neutral`, `warning`, or `negative`, and renders the icon for that color itself. It sets no ARIA role, so pass `role='status'` (or `role='alert'`) when the banner appears in response to something the user did.

Also adds an `info-circle` glyph to the Mosaic icon set.
1 change: 1 addition & 0 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const docModules: Record<string, Record<string, React.ComponentType>> = {
components: {
avatar: dynamic(() => import('../stories/avatar.mdx')),
badge: dynamic(() => import('../stories/badge.mdx')),
banner: dynamic(() => import('../stories/banner.mdx')),
button: dynamic(() => import('../stories/button.mdx')),
card: dynamic(() => import('../stories/card.component.mdx')),
input: dynamic(() => import('../stories/input.mdx')),
Expand Down
2 changes: 1 addition & 1 deletion packages/swingset/src/components/PropTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ interface ExtraProp {
interface PropTableProps {
meta: StoryMeta;
extra?: ExtraProp[];
/** Set for a component that does not forward `className`/`style`. @default true */
/** Set false for a component that styles itself and does not want `className`/`style` advertised. */
styleProps?: boolean;
}

Expand Down
16 changes: 16 additions & 0 deletions packages/swingset/src/lib/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ import {
Primary as BadgePrimary,
WithIcon as BadgeWithIcon,
} from '../stories/badge.stories';
import {
Announced as BannerAnnounced,
Colors as BannerColors,
Default as BannerDefault,
LabelOnly as BannerLabelOnly,
meta as bannerMeta,
} from '../stories/banner.stories';
import { Disabled, meta as buttonMeta, Primary, Sizes } from '../stories/button.stories';
import { Default as CardDefault, meta as cardComponentMeta } from '../stories/card.component.stories';
import { meta as collapsibleMeta } from '../stories/collapsible.stories';
Expand Down Expand Up @@ -214,6 +221,14 @@ const badgeModule: StoryModule = {
WithIcon: BadgeWithIcon,
};

const bannerModule: StoryModule = {
meta: bannerMeta,
Default: BannerDefault,
Colors: BannerColors,
LabelOnly: BannerLabelOnly,
Announced: BannerAnnounced,
};

const buttonModule: StoryModule = { meta: buttonMeta, Primary, Sizes, Disabled };

const inputModule: StoryModule = { meta: inputMeta, Default, Sizes: InputSizes, Disabled: InputDisabled, Invalid };
Expand Down Expand Up @@ -407,6 +422,7 @@ export const registry: StoryModule[] = [
// Components
avatarModule,
badgeModule,
bannerModule,
buttonModule,
cardComponentModule,
inputModule,
Expand Down
89 changes: 89 additions & 0 deletions packages/swingset/src/stories/banner.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import * as BannerStories from './banner.stories';

# Banner

Banner annotates the surface it sits on with a status message. It is a compound component: `Banner.Root` sets the semantic `color` and renders the matching icon itself, and `Banner.Label` and `Banner.Description` carry the copy. It ships no ARIA role, so a banner that appears in response to something the user did needs `role='status'` (or `role='alert'` for an error) to be announced.

## Playground

<Preview
name='Default'
storyModule={BannerStories}
/>

## Props

<PropTable
meta={BannerStories.meta}
styleProps={false}
/>

## Usage

```tsx
import { Banner } from '@clerk/ui/mosaic/components/banner';

<Banner.Root color='negative'>
<Banner.Label>Error banner</Banner.Label>
<Banner.Description>Renew now to avoid service interruption.</Banner.Description>
</Banner.Root>;
Comment thread
alexcarpenter marked this conversation as resolved.
```

`color` lives on `Banner.Root` only; `Banner.Label` and `Banner.Description` read it from context, so they never need it passed again. The description is optional — a `Banner.Root` with just a label renders as a single line.

## Parts

| Part | Stable slot class | Description |
| -------------------- | ------------------------ | --------------------------------------------------------------- |
| `Banner.Root` | `.cl-banner-root` | Tinted `div` surface. Owns `color` and renders the icon for it. |
| `Banner.Label` | `.cl-banner-label` | The headline `span`, in the root's color at medium weight. |
| `Banner.Description` | `.cl-banner-description` | Supporting `p` beneath the label. |

`Banner.Root` also wraps its children in a `.cl-banner-content` column so the copy aligns past the icon. Every part accepts `render` for polymorphism and forwards its ref.

## Styling

Each part carries its stable slot class alongside the generated StyleX atoms and reflects the active color as `data-color`. Override a `.cl-banner-*` class from a CSS layer that wins over `@clerk/ui/styles.css`:

```css
@import '@clerk/ui/styles.css' layer(components);

@layer overrides {
.cl-banner-root[data-color='negative'] {
border-radius: 0;
}
}
```

| Part | `data-color` |
| -------------------- | ------------------------------------ |
| `Banner.Root` | `neutral` \| `warning` \| `negative` |
| `Banner.Label` | `neutral` \| `warning` \| `negative` |
| `Banner.Description` | `neutral` \| `warning` \| `negative` |

The fill is a 4% mix of the color's token rather than its `-faded` surface, so a banner tints whatever it sits on instead of painting over it, and it inverts with the token in dark mode. Retheme a color by overriding the token it reads — `--cl-color-negative`, `--cl-color-warning`, or `--cl-color-neutral` (plus `--cl-color-border`, which draws the neutral hairline) — and the fill, border, icon, and copy all move together.

---

## Examples

### Colors

<Story
name='Colors'
storyModule={BannerStories}
/>

### Label only

<Story
name='LabelOnly'
storyModule={BannerStories}
/>

### Announced

<Story
name='Announced'
storyModule={BannerStories}
/>
82 changes: 82 additions & 0 deletions packages/swingset/src/stories/banner.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import type { BannerRootProps } from '@clerk/ui/mosaic/components/banner';
import { Banner } from '@clerk/ui/mosaic/components/banner';

import type { StoryMeta } from '@/lib/types';

// Exposes this file's own source (via the `?raw` webpack rule) so each `<Story>` example
// renders a code footer with its function's source. See `StoryModule.__source`.
export { default as __source } from './banner.stories?raw';

// StyleX has no runtime recipe to derive knobs from, so the variant surface is described
// here to drive the playground + prop table. Keys mirror `BannerRootProps`.
export const meta: StoryMeta = {
group: 'Components',
title: 'Banner',
source: 'packages/ui/src/mosaic/components/banner/banner.tsx',
styles: {
_variants: {
color: { neutral: {}, warning: {}, negative: {} },
},
_defaultVariants: {
color: 'neutral',
},
},
};

// Story functions accept Record<string,unknown> (knob values) and cast to BannerRootProps.
// The cast is unavoidable: knobs are dynamically typed; Banner.Root has a strict prop interface.
function knobsAsProps(props: Record<string, unknown>) {
return props as unknown as BannerRootProps;
}

export function Default(props: Record<string, unknown>) {
return (
<Banner.Root {...knobsAsProps(props)}>
<Banner.Label>Info banner</Banner.Label>
<Banner.Description>Here is a tip for how this should work</Banner.Description>
</Banner.Root>
);
}

export function Colors() {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Banner.Root color='negative'>
<Banner.Label>Error banner</Banner.Label>
<Banner.Description>
Renew now to avoid service interruption or upgrade to a paid plan to continue using the service.
</Banner.Description>
</Banner.Root>
<Banner.Root color='warning'>
<Banner.Label>Warning banner</Banner.Label>
<Banner.Description>
Your payment could not be processed. Please check your payment method and try again.
</Banner.Description>
</Banner.Root>
<Banner.Root color='neutral'>
<Banner.Label>Info banner</Banner.Label>
<Banner.Description>Here is a tip for how this should work</Banner.Description>
</Banner.Root>
</div>
);
}

export function LabelOnly() {
return (
<Banner.Root color='warning'>
<Banner.Label>Your trial ends in 3 days</Banner.Label>
</Banner.Root>
);
}

export function Announced() {
return (
<Banner.Root
color='negative'
role='alert'
>
<Banner.Label>Payment failed</Banner.Label>
<Banner.Description>We could not charge your card. Update your payment method to continue.</Banner.Description>
</Banner.Root>
);
}
65 changes: 65 additions & 0 deletions packages/ui/src/mosaic/components/banner/banner.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import * as stylex from '@stylexjs/stylex';

import { colorVars, fontFamilyVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex';

const neutralFill = `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 4%, transparent)`;
const warningFill = `color-mix(in oklab, ${colorVars['--cl-color-warning']} 4%, transparent)`;
const negativeFill = `color-mix(in oklab, ${colorVars['--cl-color-negative']} 4%, transparent)`;
const warningBorder = `color-mix(in oklab, ${colorVars['--cl-color-warning']} 20%, transparent)`;
const negativeBorder = `color-mix(in oklab, ${colorVars['--cl-color-negative']} 20%, transparent)`;

export const styles = stylex.create({
root: {
borderRadius: radiusVars['--cl-radius-lg'],
borderStyle: 'solid',
borderWidth: '1px',
gap: space['1.5'],
paddingBlock: space['2'],
paddingInline: space['3'],
alignItems: 'flex-start',
display: 'flex',
fontFamily: fontFamilyVars['--cl-font-family-sans'],
fontSize: typeScaleVars['--cl-text-sm-size'],
lineHeight: typeScaleVars['--cl-text-sm-leading'],
},
icon: {
flexShrink: 0,
height: '1lh',
},
content: {
gap: space['1'],
display: 'flex',
flexDirection: 'column',
minWidth: 0,
},
label: {
fontWeight: fontWeightVars['--cl-font-medium'],
},
description: {
textWrap: 'pretty',
},
});

export const rootColors = stylex.create({
neutral: {
borderColor: colorVars['--cl-color-border'],
backgroundColor: neutralFill,
color: colorVars['--cl-color-neutral-foreground'],
},
warning: {
borderColor: warningBorder,
backgroundColor: warningFill,
color: colorVars['--cl-color-warning'],
},
negative: {
borderColor: negativeBorder,
backgroundColor: negativeFill,
color: colorVars['--cl-color-negative'],
},
});

export const descriptionColors = stylex.create({
neutral: { color: colorVars['--cl-color-neutral-faded'] },
warning: { color: colorVars['--cl-color-warning'] },
negative: { color: colorVars['--cl-color-negative'] },
});
Loading
Loading