From ceeea07bd0efa4f8f1b42551cc5d75ef149df4cc Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 14 Aug 2026 12:56:47 -0600 Subject: [PATCH 1/5] feat(ui): add Mosaic IconFrame --- .changeset/tidy-icons-frame.md | 2 + .../swingset/src/components/DocsViewer.tsx | 1 + packages/swingset/src/lib/registry.ts | 20 +++ packages/swingset/src/stories/icon-frame.mdx | 68 +++++++++ .../src/stories/icon-frame.stories.tsx | 133 ++++++++++++++++++ packages/swingset/src/stories/section.mdx | 11 ++ .../swingset/src/stories/section.stories.tsx | 28 +++- .../components/icon/icon-frame.styles.ts | 20 +++ .../components/icon/icon-frame.test.tsx | 69 +++++++++ .../src/mosaic/components/icon/icon-frame.tsx | 25 ++++ .../ui/src/mosaic/components/icon/index.ts | 2 + packages/ui/src/mosaic/styles/index.ts | 5 +- 12 files changed, 380 insertions(+), 4 deletions(-) create mode 100644 .changeset/tidy-icons-frame.md create mode 100644 packages/swingset/src/stories/icon-frame.mdx create mode 100644 packages/swingset/src/stories/icon-frame.stories.tsx create mode 100644 packages/ui/src/mosaic/components/icon/icon-frame.styles.ts create mode 100644 packages/ui/src/mosaic/components/icon/icon-frame.test.tsx create mode 100644 packages/ui/src/mosaic/components/icon/icon-frame.tsx diff --git a/.changeset/tidy-icons-frame.md b/.changeset/tidy-icons-frame.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/tidy-icons-frame.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index a336f3e4aa2..787a07e8880 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -52,6 +52,7 @@ const docModules: Record> = { 'alert-dialog': dynamic(() => import('../stories/alert-dialog.component.mdx')), heading: dynamic(() => import('../stories/heading.mdx')), icon: dynamic(() => import('../stories/icon.mdx')), + 'icon-frame': dynamic(() => import('../stories/icon-frame.mdx')), menu: dynamic(() => import('../stories/menu.component.mdx')), otp: dynamic(() => import('../stories/otp.component.mdx')), popover: dynamic(() => import('../stories/popover.component.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 068f9aecd95..586e90ad7ab 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -53,6 +53,14 @@ import { Override as IconOverride, Sizes as IconSizes, } from '../stories/icon.stories'; +import { + BrandIcons as IconFrameBrandIcons, + CustomSurface as IconFrameCustomSurface, + Default as IconFrameDefault, + IconSizes as IconFrameIconSizes, + meta as iconFrameMeta, + MultipleTreatments as IconFrameMultipleTreatments, +} from '../stories/icon-frame.stories'; import { Default, Disabled as InputDisabled, @@ -98,6 +106,7 @@ import { ConnectedAccounts as SectionConnectedAccounts, Default as SectionDefault, Destructive as SectionDestructive, + IconFrameMedia as SectionIconFrameMedia, meta as sectionMeta, MultipleEmailAndPhoneNumbers as SectionMultipleEmailAndPhoneNumbers, } from '../stories/section.stories'; @@ -193,6 +202,7 @@ const sectionModule: StoryModule = { Default: SectionDefault, MultipleEmailAndPhoneNumbers: SectionMultipleEmailAndPhoneNumbers, ConnectedAccounts: SectionConnectedAccounts, + IconFrameMedia: SectionIconFrameMedia, Destructive: SectionDestructive, }; const dialogComponentModule: StoryModule = { meta: dialogComponentMeta, Default: DialogDefault }; @@ -288,6 +298,15 @@ const iconModule: StoryModule = { Override: IconOverride, }; +const iconFrameModule: StoryModule = { + meta: iconFrameMeta, + Default: IconFrameDefault, + IconSizes: IconFrameIconSizes, + CustomSurface: IconFrameCustomSurface, + MultipleTreatments: IconFrameMultipleTreatments, + BrandIcons: IconFrameBrandIcons, +}; + // Headless primitives carry just `meta` (no story functions). Like every component // they're documented as a single overview page; their live demos come from `` / // `` embeds in the MDX, which import the stories module directly. @@ -431,6 +450,7 @@ export const registry: StoryModule[] = [ alertDialogComponentModule, headingModule, iconModule, + iconFrameModule, menuComponentModule, otpComponentModule, popoverComponentModule, diff --git a/packages/swingset/src/stories/icon-frame.mdx b/packages/swingset/src/stories/icon-frame.mdx new file mode 100644 index 00000000000..57137cf6e01 --- /dev/null +++ b/packages/swingset/src/stories/icon-frame.mdx @@ -0,0 +1,68 @@ +import * as IconFrameStories from './icon-frame.stories'; + +# IconFrame + +IconFrame places an icon or other visual in a square Mosaic surface. The child owns its size and artwork, while the frame adds padding, border, radius, centering, and a default background. Customize the surface through native `style` or `className` overrides. + +## Playground + + + +## Props + + ReactNode' }]} +/> + +## Usage + +```tsx +import { Icon, IconFrame } from '@clerk/ui/mosaic/components/icon'; +import { colorVars } from '@clerk/ui/mosaic/styles'; + + + + +``` + +--- + +## Examples + +### Icon sizes + + + +### Custom surface + + + +### Multiple treatments + + + +### Brand icons + + diff --git a/packages/swingset/src/stories/icon-frame.stories.tsx b/packages/swingset/src/stories/icon-frame.stories.tsx new file mode 100644 index 00000000000..4e571ff5d4a --- /dev/null +++ b/packages/swingset/src/stories/icon-frame.stories.tsx @@ -0,0 +1,133 @@ +import { Icon, IconFrame } from '@clerk/ui/mosaic/components/icon'; +import { colorVars } from '@clerk/ui/mosaic/styles'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './icon-frame.stories?raw'; + +const providerIconUrl = (provider: string) => `https://img.clerk.com/static/${provider}.svg`; + +function ProviderIcon({ provider }: { provider: string }) { + return ( + + ); +} + +export const meta: StoryMeta = { + group: 'Components', + title: 'IconFrame', + source: 'packages/ui/src/mosaic/components/icon/icon-frame.tsx', + styleEngine: 'stylex', +}; + +export function Default() { + return ( + + + + ); +} + +export function IconSizes() { + return ( +
+ + + + + + + + + +
+ ); +} + +export function CustomSurface() { + return ( + + + + ); +} + +export function MultipleTreatments() { + return ( +
+ + + + + + + + + + + + + + + +
+ ); +} + +export function BrandIcons() { + return ( +
+ + + + + + +
+ ); +} diff --git a/packages/swingset/src/stories/section.mdx b/packages/swingset/src/stories/section.mdx index c19ff9a638e..7061ecff1db 100644 --- a/packages/swingset/src/stories/section.mdx +++ b/packages/swingset/src/stories/section.mdx @@ -64,6 +64,17 @@ Use `Section.Items` for a nested value list beneath a row's header item. The row ]} /> +### Framed icon media + + + ### Destructive section + Team + + + + + + + + + + Engineering + 12 members + + + + + + ); +} + export function Destructive() { return ( diff --git a/packages/ui/src/mosaic/components/icon/icon-frame.styles.ts b/packages/ui/src/mosaic/components/icon/icon-frame.styles.ts new file mode 100644 index 00000000000..c6094584aea --- /dev/null +++ b/packages/ui/src/mosaic/components/icon/icon-frame.styles.ts @@ -0,0 +1,20 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, radiusVars, space } from '../../tokens.stylex'; + +export const styles = stylex.create({ + base: { + borderColor: colorVars['--cl-color-border'], + borderRadius: radiusVars['--cl-radius-md'], + borderStyle: 'solid', + borderWidth: '1px', + overflow: 'hidden', + alignItems: 'center', + aspectRatio: '1 / 1', + backgroundColor: colorVars['--cl-color-background'], + display: 'inline-flex', + flexShrink: 0, + justifyContent: 'center', + height: space['10'], + }, +}); diff --git a/packages/ui/src/mosaic/components/icon/icon-frame.test.tsx b/packages/ui/src/mosaic/components/icon/icon-frame.test.tsx new file mode 100644 index 00000000000..75fa583da58 --- /dev/null +++ b/packages/ui/src/mosaic/components/icon/icon-frame.test.tsx @@ -0,0 +1,69 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it } from 'vitest'; + +import { Icon } from './icon'; +import { IconFrame } from './icon-frame'; + +describe('Mosaic IconFrame', () => { + it('renders its children in a span by default', () => { + render(Framed); + const frame = screen.getByText('Framed'); + expect(frame.tagName).toBe('SPAN'); + expect(frame).toHaveClass('cl-icon-frame'); + }); + + it('composes with an Icon', () => { + const { container } = render( + + + , + ); + const frame = container.querySelector('.cl-icon-frame'); + expect(frame).toContainElement(container.querySelector('svg.cl-icon')); + }); + + it('forwards native span props and the ref', () => { + const ref = React.createRef(); + render( + , + ); + const frame = screen.getByTestId('frame'); + expect(ref.current).toBe(frame); + expect(frame).toHaveAttribute('aria-label', 'Status icon'); + }); + + it('lets the consumer className and style win', () => { + render( + , + ); + const frame = screen.getByTestId('frame'); + expect(frame).toHaveClass('cl-icon-frame', 'my-frame'); + expect(frame).toHaveStyle({ backgroundColor: 'rgb(255, 0, 0)' }); + }); + + it('renders a custom element via render, keeping styles and props', () => { + render( + } + > + Status + , + ); + const frame = screen.getByRole('link', { name: 'Status' }); + expect(frame.tagName).toBe('A'); + expect(frame).toHaveAttribute('href', '/status'); + expect(frame).toHaveAttribute('data-testid', 'frame'); + expect(frame).toHaveClass('cl-icon-frame'); + }); +}); diff --git a/packages/ui/src/mosaic/components/icon/icon-frame.tsx b/packages/ui/src/mosaic/components/icon/icon-frame.tsx new file mode 100644 index 00000000000..abb348a5c4a --- /dev/null +++ b/packages/ui/src/mosaic/components/icon/icon-frame.tsx @@ -0,0 +1,25 @@ +import { useRender } from '@clerk/headless/utils'; +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import type { MosaicComponentProps } from '../../props'; +import { mergeStyleProps, themeProps } from '../../props'; +import { reset } from '../reset.styles'; +import { styles } from './icon-frame.styles'; + +export type IconFrameProps = MosaicComponentProps<'span'>; + +export const IconFrame = React.forwardRef(function MosaicIconFrame( + { render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'span', + render, + ref, + props: { + ...mergeStyleProps(themeProps('icon-frame'), stylex.props(reset.base, styles.base), className, style), + ...rest, + }, + }); +}); diff --git a/packages/ui/src/mosaic/components/icon/index.ts b/packages/ui/src/mosaic/components/icon/index.ts index 77a5eccba3b..38ab61e44ad 100644 --- a/packages/ui/src/mosaic/components/icon/index.ts +++ b/packages/ui/src/mosaic/components/icon/index.ts @@ -1,2 +1,4 @@ export { Icon } from './icon'; export type { IconProps } from './icon'; +export { IconFrame } from './icon-frame'; +export type { IconFrameProps } from './icon-frame'; diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index 482ad30ea6e..7a5eee42b26 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -59,9 +59,8 @@ export { Field } from '../components/field'; export type { FieldDescriptionProps, FieldErrorProps, FieldLabelProps, FieldRootProps } from '../components/field'; export { Heading, HeadingContext } from '../components/heading'; export type { HeadingProps } from '../components/heading'; -export { Icon } from '../components/icon'; -export type { IconProps } from '../components/icon'; -export type { MosaicIconOverride, MosaicIconOverrides } from '../icons/overrides'; +export { Icon, IconFrame } from '../components/icon'; +export type { IconFrameProps, IconProps } from '../components/icon'; export { Input } from '../components/input'; export type { InputProps } from '../components/input'; export { Item } from '../components/item'; From e9e01ceb553f88ca5f9886843c99398827681454 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 18 Aug 2026 09:32:33 -0600 Subject: [PATCH 2/5] Use IconFrame --- .../src/stories/icon-frame.stories.tsx | 10 ++--- .../swingset/src/stories/section.stories.tsx | 40 ++++++------------- .../src/mosaic/components/icon/icon-frame.tsx | 2 +- .../user-profile-profile-panel.view.test.tsx | 14 +++++++ .../user-profile-profile-panel.styles.ts | 9 +---- .../user-profile-provider-icon.tsx | 36 ++++++++--------- 6 files changed, 50 insertions(+), 61 deletions(-) diff --git a/packages/swingset/src/stories/icon-frame.stories.tsx b/packages/swingset/src/stories/icon-frame.stories.tsx index 4e571ff5d4a..9f0943e43ec 100644 --- a/packages/swingset/src/stories/icon-frame.stories.tsx +++ b/packages/swingset/src/stories/icon-frame.stories.tsx @@ -1,5 +1,5 @@ import { Icon, IconFrame } from '@clerk/ui/mosaic/components/icon'; -import { colorVars } from '@clerk/ui/mosaic/styles'; +import { colorVars, space } from '@clerk/ui/mosaic/styles'; import type { StoryMeta } from '@/lib/types'; @@ -7,12 +7,12 @@ export { default as __source } from './icon-frame.stories?raw'; const providerIconUrl = (provider: string) => `https://img.clerk.com/static/${provider}.svg`; -function ProviderIcon({ provider }: { provider: string }) { +function ProviderLogo({ provider }: { provider: string }) { return ( ); } @@ -123,10 +123,10 @@ export function BrandIcons() { return (
- + - +
); diff --git a/packages/swingset/src/stories/section.stories.tsx b/packages/swingset/src/stories/section.stories.tsx index 0d660b8f596..b962636f0f0 100644 --- a/packages/swingset/src/stories/section.stories.tsx +++ b/packages/swingset/src/stories/section.stories.tsx @@ -3,7 +3,7 @@ import { Badge } from '@clerk/ui/mosaic/components/badge'; import { Button } from '@clerk/ui/mosaic/components/button'; import { Icon, IconFrame } from '@clerk/ui/mosaic/components/icon'; import { Section } from '@clerk/ui/mosaic/components/section'; -import * as stylex from '@stylexjs/stylex'; +import { space } from '@clerk/ui/mosaic/styles'; import type { StoryMeta } from '@/lib/types'; @@ -11,32 +11,16 @@ export { default as __source } from './section.stories?raw'; const providerIconUrl = (provider: string) => `https://img.clerk.com/static/${provider}.svg`; -const styles = stylex.create({ - providerMedia: { - backgroundColor: 'var(--cl-color-background)', - borderColor: 'light-dark(var(--cl-color-border-faded), var(--cl-color-background))', - borderRadius: 'var(--cl-radius-lg)', - borderStyle: 'solid', - borderWidth: '1px', - }, - providerIcon: { - display: 'block', - height: '20px', - width: '20px', - }, -}); - -function ProviderIcon({ provider }: { provider: string }) { +function ProviderMedia({ provider }: { provider: string }) { return ( - - + + + + ); } @@ -308,7 +292,7 @@ export function ConnectedAccounts() { - + Google test@google.com @@ -328,7 +312,7 @@ export function ConnectedAccounts() { - + Apple diff --git a/packages/ui/src/mosaic/components/icon/icon-frame.tsx b/packages/ui/src/mosaic/components/icon/icon-frame.tsx index abb348a5c4a..43227d21e95 100644 --- a/packages/ui/src/mosaic/components/icon/icon-frame.tsx +++ b/packages/ui/src/mosaic/components/icon/icon-frame.tsx @@ -4,7 +4,7 @@ import React from 'react'; import type { MosaicComponentProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; -import { reset } from '../reset.styles'; +import { reset } from '../../utils/reset.styles'; import { styles } from './icon-frame.styles'; export type IconFrameProps = MosaicComponentProps<'span'>; diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx index e6227392e30..b5e4bcd46e7 100644 --- a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx @@ -174,6 +174,20 @@ describe('UserProfileProfilePanelView', () => { expect(onDeleteAccount).toHaveBeenCalledOnce(); }); + it('renders provider images in icon frames', () => { + const { container } = renderView({ + connectedAccounts: [{ id: 'google', provider: 'Google', iconUrl: '/google.svg' }], + web3Wallets: [{ id: 'metamask', provider: 'MetaMask', iconUrl: '/metamask.svg' }], + }); + + const frames = container.querySelectorAll('.cl-icon-frame'); + const images = container.querySelectorAll('img'); + expect(frames).toHaveLength(2); + expect(frames[0]).toContainElement(images[0]); + expect(frames[1]).toContainElement(images[1]); + frames.forEach(frame => expect(frame.closest('.cl-section-media')).toHaveAttribute('data-size', 'lg')); + }); + it('renders Web3 wallets and forwards wallet actions', async () => { const onConnectWeb3Wallet = vi.fn(); const onSetPrimaryWeb3Wallet = vi.fn(); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts index 539b9ee02a8..8a3cc17a27c 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts +++ b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts @@ -1,6 +1,6 @@ import * as stylex from '@stylexjs/stylex'; -import { colorVars, radiusVars, space } from '../tokens.stylex'; +import { space } from '../tokens.stylex'; export const styles = stylex.create({ contactValue: { @@ -14,13 +14,6 @@ export const styles = stylex.create({ height: space['5'], width: space['5'], }, - providerMedia: { - borderColor: `light-dark(${colorVars['--cl-color-border-faded']}, ${colorVars['--cl-color-background']})`, - borderRadius: radiusVars['--cl-radius-lg'], - borderStyle: 'solid', - borderWidth: '1px', - backgroundColor: colorVars['--cl-color-background'], - }, root: { gap: space['4'], display: 'flex', diff --git a/packages/ui/src/mosaic/user-profile/user-profile-provider-icon.tsx b/packages/ui/src/mosaic/user-profile/user-profile-provider-icon.tsx index a200fe914b9..76a87718954 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-provider-icon.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-provider-icon.tsx @@ -1,32 +1,30 @@ import * as stylex from '@stylexjs/stylex'; -import { Icon } from '../components/icon'; +import { Icon, IconFrame } from '../components/icon'; import { Section } from '../components/section'; import type { IconName } from '../icons/registry'; import { styles } from './user-profile-profile-panel.styles'; type UserProfileProviderIconProps = { iconUrl: string; name?: never } | { iconUrl?: never; name: IconName }; -// TODO: Replace this temporary user-profile wrapper with IconFrame. export function UserProfileProviderIcon(props: UserProfileProviderIconProps) { return ( - - {'iconUrl' in props ? ( - - ) : ( - - )} + + + {'iconUrl' in props ? ( + + ) : ( + + )} + ); } From 34fad8b662a52ae91380afcc0f2ed15a49b9a7d8 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 28 Aug 2026 08:36:37 -0600 Subject: [PATCH 3/5] feat(ui): add IconFrame treatments and sizes --- .../components/icon/icon-frame.styles.ts | 20 ++++++++++++++++--- .../components/icon/icon-frame.test.tsx | 19 ++++++++++++++++++ .../src/mosaic/components/icon/icon-frame.tsx | 17 ++++++++++++---- 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/mosaic/components/icon/icon-frame.styles.ts b/packages/ui/src/mosaic/components/icon/icon-frame.styles.ts index c6094584aea..402b63f3a9b 100644 --- a/packages/ui/src/mosaic/components/icon/icon-frame.styles.ts +++ b/packages/ui/src/mosaic/components/icon/icon-frame.styles.ts @@ -2,19 +2,33 @@ import * as stylex from '@stylexjs/stylex'; import { colorVars, radiusVars, space } from '../../tokens.stylex'; +const neutralScrim = `color-mix(in oklab, light-dark(oklch(0 0 0), oklch(1 0 0)) 6%, transparent)`; + export const styles = stylex.create({ base: { - borderColor: colorVars['--cl-color-border'], + borderColor: 'transparent', borderRadius: radiusVars['--cl-radius-md'], borderStyle: 'solid', borderWidth: '1px', overflow: 'hidden', alignItems: 'center', aspectRatio: '1 / 1', - backgroundColor: colorVars['--cl-color-background'], + backgroundColor: 'transparent', display: 'inline-flex', flexShrink: 0, justifyContent: 'center', - height: space['10'], }, + bordered: { + borderColor: colorVars['--cl-color-border'], + }, + filled: { + backgroundColor: neutralScrim, + }, +}); + +export const sizes = stylex.create({ + sm: { height: space['7'] }, + md: { height: space['8'] }, + lg: { height: space['9'] }, + xl: { height: space['10'] }, }); diff --git a/packages/ui/src/mosaic/components/icon/icon-frame.test.tsx b/packages/ui/src/mosaic/components/icon/icon-frame.test.tsx index 75fa583da58..0735e3ab18d 100644 --- a/packages/ui/src/mosaic/components/icon/icon-frame.test.tsx +++ b/packages/ui/src/mosaic/components/icon/icon-frame.test.tsx @@ -11,6 +11,25 @@ describe('Mosaic IconFrame', () => { const frame = screen.getByText('Framed'); expect(frame.tagName).toBe('SPAN'); expect(frame).toHaveClass('cl-icon-frame'); + expect(frame).toHaveAttribute('data-bordered', ''); + expect(frame).not.toHaveAttribute('data-filled'); + expect(frame).toHaveAttribute('data-size', 'xl'); + }); + + it('reflects its treatment and size', () => { + render( + + Framed + , + ); + const frame = screen.getByText('Framed'); + expect(frame).not.toHaveAttribute('data-bordered'); + expect(frame).toHaveAttribute('data-filled', ''); + expect(frame).toHaveAttribute('data-size', 'sm'); }); it('composes with an Icon', () => { diff --git a/packages/ui/src/mosaic/components/icon/icon-frame.tsx b/packages/ui/src/mosaic/components/icon/icon-frame.tsx index 43227d21e95..3181cf78a2e 100644 --- a/packages/ui/src/mosaic/components/icon/icon-frame.tsx +++ b/packages/ui/src/mosaic/components/icon/icon-frame.tsx @@ -5,12 +5,16 @@ import React from 'react'; import type { MosaicComponentProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; import { reset } from '../../utils/reset.styles'; -import { styles } from './icon-frame.styles'; +import { sizes, styles } from './icon-frame.styles'; -export type IconFrameProps = MosaicComponentProps<'span'>; +export interface IconFrameProps extends MosaicComponentProps<'span'> { + bordered?: boolean; + filled?: boolean; + size?: 'sm' | 'md' | 'lg' | 'xl'; +} export const IconFrame = React.forwardRef(function MosaicIconFrame( - { render, className, style, ...rest }, + { bordered = true, filled = false, size = 'xl', render, className, style, ...rest }, ref, ) { return useRender({ @@ -18,7 +22,12 @@ export const IconFrame = React.forwardRef(funct render, ref, props: { - ...mergeStyleProps(themeProps('icon-frame'), stylex.props(reset.base, styles.base), className, style), + ...mergeStyleProps( + themeProps('icon-frame', { bordered, filled, size }), + stylex.props(reset.base, styles.base, sizes[size], bordered && styles.bordered, filled && styles.filled), + className, + style, + ), ...rest, }, }); From 39f36cee33bfa07278dcfd9bf32674237dcac28c Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 28 Aug 2026 08:36:52 -0600 Subject: [PATCH 4/5] docs(swingset): update IconFrame examples --- packages/swingset/src/lib/registry.ts | 8 +- packages/swingset/src/stories/icon-frame.mdx | 28 +++-- .../src/stories/icon-frame.stories.tsx | 115 +++++++++--------- 3 files changed, 79 insertions(+), 72 deletions(-) diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 586e90ad7ab..99cb2133574 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -57,9 +57,9 @@ import { BrandIcons as IconFrameBrandIcons, CustomSurface as IconFrameCustomSurface, Default as IconFrameDefault, - IconSizes as IconFrameIconSizes, meta as iconFrameMeta, - MultipleTreatments as IconFrameMultipleTreatments, + Sizes as IconFrameSizes, + Treatments as IconFrameTreatments, } from '../stories/icon-frame.stories'; import { Default, @@ -301,9 +301,9 @@ const iconModule: StoryModule = { const iconFrameModule: StoryModule = { meta: iconFrameMeta, Default: IconFrameDefault, - IconSizes: IconFrameIconSizes, + Sizes: IconFrameSizes, + Treatments: IconFrameTreatments, CustomSurface: IconFrameCustomSurface, - MultipleTreatments: IconFrameMultipleTreatments, BrandIcons: IconFrameBrandIcons, }; diff --git a/packages/swingset/src/stories/icon-frame.mdx b/packages/swingset/src/stories/icon-frame.mdx index 57137cf6e01..ceb01280993 100644 --- a/packages/swingset/src/stories/icon-frame.mdx +++ b/packages/swingset/src/stories/icon-frame.mdx @@ -2,7 +2,7 @@ import * as IconFrameStories from './icon-frame.stories'; # IconFrame -IconFrame places an icon or other visual in a square Mosaic surface. The child owns its size and artwork, while the frame adds padding, border, radius, centering, and a default background. Customize the surface through native `style` or `className` overrides. +IconFrame centers an icon or other visual in a square Mosaic surface. `bordered` and `filled` are independent treatments, while `size` controls the frame dimensions. The default is a bordered 40px frame; the filled treatment uses a 6% black scrim in light mode and a 6% white scrim in dark mode. ## Playground @@ -22,15 +22,13 @@ IconFrame places an icon or other visual in a square Mosaic surface. The child o ```tsx import { Icon, IconFrame } from '@clerk/ui/mosaic/components/icon'; -import { colorVars } from '@clerk/ui/mosaic/styles'; - + ``` @@ -38,25 +36,29 @@ import { colorVars } from '@clerk/ui/mosaic/styles'; ## Examples -### Icon sizes +### Sizes -### Custom surface +`sm`, `md`, `lg`, and `xl` map to 28px, 32px, 36px, and 40px respectively. + +### Treatments -### Multiple treatments +`bordered` and `filled` can be used separately, together, or both disabled for a centered bare icon. + +### Custom surface diff --git a/packages/swingset/src/stories/icon-frame.stories.tsx b/packages/swingset/src/stories/icon-frame.stories.tsx index 9f0943e43ec..5e6cbe781f3 100644 --- a/packages/swingset/src/stories/icon-frame.stories.tsx +++ b/packages/swingset/src/stories/icon-frame.stories.tsx @@ -1,3 +1,4 @@ +import type { IconFrameProps } from '@clerk/ui/mosaic/components/icon'; import { Icon, IconFrame } from '@clerk/ui/mosaic/components/icon'; import { colorVars, space } from '@clerk/ui/mosaic/styles'; @@ -21,33 +22,54 @@ export const meta: StoryMeta = { group: 'Components', title: 'IconFrame', source: 'packages/ui/src/mosaic/components/icon/icon-frame.tsx', - styleEngine: 'stylex', + styles: { + _variants: { + bordered: { true: {}, false: {} }, + filled: { true: {}, false: {} }, + size: { sm: {}, md: {}, lg: {}, xl: {} }, + }, + _defaultVariants: { + bordered: true, + filled: false, + size: 'xl', + }, + }, }; -export function Default() { +function knobsAsProps(props: Record) { + return props as unknown as IconFrameProps; +} + +export function Default(props: Record) { return ( - + ); } -export function IconSizes() { +export function Sizes() { return (
- + - + + + + - + - - - ); -} - -export function MultipleTreatments() { +export function Treatments() { return (
- - - - + - + - +
); } +export function CustomSurface() { + return ( + + + + ); +} + export function BrandIcons() { return (
- - + + - - + +
); From efb1d43816939e25fc1d8e5db0380ef67eeae576 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 28 Aug 2026 08:55:14 -0600 Subject: [PATCH 5/5] Update BG color --- packages/swingset/src/stories/icon-frame.mdx | 2 +- packages/ui/src/mosaic/components/icon/icon-frame.styles.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/swingset/src/stories/icon-frame.mdx b/packages/swingset/src/stories/icon-frame.mdx index ceb01280993..ec98733d3b8 100644 --- a/packages/swingset/src/stories/icon-frame.mdx +++ b/packages/swingset/src/stories/icon-frame.mdx @@ -2,7 +2,7 @@ import * as IconFrameStories from './icon-frame.stories'; # IconFrame -IconFrame centers an icon or other visual in a square Mosaic surface. `bordered` and `filled` are independent treatments, while `size` controls the frame dimensions. The default is a bordered 40px frame; the filled treatment uses a 6% black scrim in light mode and a 6% white scrim in dark mode. +IconFrame centers an icon or other visual in a square Mosaic surface. `bordered` and `filled` are independent treatments, while `size` controls the frame dimensions. The default is a bordered 40px frame; the filled treatment uses `oklch(0.9702 0 0)` in light mode and `oklch(0.2393 0 0)` in dark mode. ## Playground diff --git a/packages/ui/src/mosaic/components/icon/icon-frame.styles.ts b/packages/ui/src/mosaic/components/icon/icon-frame.styles.ts index 402b63f3a9b..8a1b94eb7c5 100644 --- a/packages/ui/src/mosaic/components/icon/icon-frame.styles.ts +++ b/packages/ui/src/mosaic/components/icon/icon-frame.styles.ts @@ -2,7 +2,8 @@ import * as stylex from '@stylexjs/stylex'; import { colorVars, radiusVars, space } from '../../tokens.stylex'; -const neutralScrim = `color-mix(in oklab, light-dark(oklch(0 0 0), oklch(1 0 0)) 6%, transparent)`; +// Figma uses the 'page faded' token +const filledBackground = 'light-dark(oklch(0.9702 0 0), oklch(0.2393 0 0))'; export const styles = stylex.create({ base: { @@ -22,7 +23,7 @@ export const styles = stylex.create({ borderColor: colorVars['--cl-color-border'], }, filled: { - backgroundColor: neutralScrim, + backgroundColor: filledBackground, }, });