From 6d70b30ca1054c32292b7e2a0750110699707296 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 17 Sep 2026 15:08:30 -0400 Subject: [PATCH] feat(ui): add inset outline to Mosaic Avatar with a bordered prop --- .changeset/mosaic-avatar-inset-outline.md | 5 +++ .../src/components/avatar/avatar.styles.ts | 7 +++ .../src/components/avatar/avatar.test.tsx | 43 +++++++++++++++++++ .../mosaic/src/components/avatar/avatar.tsx | 29 ++++++++++--- packages/swingset/src/stories/avatar.mdx | 2 +- .../swingset/src/stories/avatar.stories.tsx | 2 + 6 files changed, 80 insertions(+), 8 deletions(-) create mode 100644 .changeset/mosaic-avatar-inset-outline.md diff --git a/.changeset/mosaic-avatar-inset-outline.md b/.changeset/mosaic-avatar-inset-outline.md new file mode 100644 index 00000000000..43f690f2a4b --- /dev/null +++ b/.changeset/mosaic-avatar-inset-outline.md @@ -0,0 +1,5 @@ +--- +'@clerk/mosaic': minor +--- + +`Avatar` now draws a 1px inset outline over its image and fallback so it keeps an edge against a matching background. Pass `bordered={false}` to `Avatar.Root` to drop it. diff --git a/packages/mosaic/src/components/avatar/avatar.styles.ts b/packages/mosaic/src/components/avatar/avatar.styles.ts index cc61b1674ea..96e6a2c5306 100644 --- a/packages/mosaic/src/components/avatar/avatar.styles.ts +++ b/packages/mosaic/src/components/avatar/avatar.styles.ts @@ -34,6 +34,13 @@ export const styles = stylex.create({ }, }, + overlay: { + outlineColor: colorVars['--cl-color-neutral-alpha-100'], + outlineOffset: '-1px', + outlineStyle: 'solid', + outlineWidth: '1px', + }, + // Carries the root's radius rather than leaning on the clip alone, so a part that paints its own // fill rounds off cleanly instead of showing a corner. image: { diff --git a/packages/mosaic/src/components/avatar/avatar.test.tsx b/packages/mosaic/src/components/avatar/avatar.test.tsx index e654a254d83..9448d2fb9d1 100644 --- a/packages/mosaic/src/components/avatar/avatar.test.tsx +++ b/packages/mosaic/src/components/avatar/avatar.test.tsx @@ -7,6 +7,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { Icon } from '../icon'; import { Avatar } from './avatar'; +import { styles } from './avatar.styles'; // React reads this off the global object and ships no typing for it. declare global { @@ -62,6 +63,48 @@ describe('Mosaic Avatar', () => { expect(avatar).toHaveClass('cl-avatar'); expect(avatar).toHaveAttribute('data-shape', 'circle'); expect(avatar).toHaveAttribute('data-size', 'md'); + expect(avatar).toHaveAttribute('data-bordered', ''); + }); + + it('draws the inset outline on the image and fallback by default', async () => { + outcomes['https://example.com/a.png'] = 'load'; + const overlay = stylex.props(styles.overlay).className ?? ''; + const { rerender } = render( + + CN + , + ); + expect(screen.getByTestId('fallback')).toHaveClass(overlay); + rerender( + + + CN + , + ); + expect(await screen.findByRole('img', { name: 'Alex' })).toHaveClass(overlay); + }); + + it('drops the inset outline when bordered is false', async () => { + outcomes['https://example.com/a.png'] = 'load'; + const overlay = stylex.props(styles.overlay).className ?? ''; + render( + + + CN + , + ); + expect(screen.getByTestId('avatar')).not.toHaveAttribute('data-bordered'); + expect(screen.getByTestId('fallback')).not.toHaveClass(overlay); + expect(await screen.findByRole('img', { name: 'Alex' })).not.toHaveClass(overlay); }); it('reflects shape and size overrides', () => { diff --git a/packages/mosaic/src/components/avatar/avatar.tsx b/packages/mosaic/src/components/avatar/avatar.tsx index 4621c41e86d..5e3a4aa29c9 100644 --- a/packages/mosaic/src/components/avatar/avatar.tsx +++ b/packages/mosaic/src/components/avatar/avatar.tsx @@ -12,6 +12,7 @@ import { shapes, sizes, styles } from './avatar.styles'; type ImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error'; interface AvatarContextValue { + bordered: boolean; status: ImageLoadingStatus; onStatusChange: (status: ImageLoadingStatus) => void; } @@ -27,16 +28,20 @@ function useAvatarContext(part: string): AvatarContextValue { } export interface AvatarProps extends MosaicComponentProps<'span'> { + bordered?: boolean; shape?: 'circle' | 'square'; size?: 'xs' | 'sm' | 'md' | 'lg' | 'fit'; } const AvatarRoot = React.forwardRef(function MosaicAvatarRoot( - { shape = 'circle', size = 'md', render, xstyle, ...rest }, + { bordered = true, shape = 'circle', size = 'md', render, xstyle, ...rest }, ref, ) { const [status, setStatus] = React.useState('idle'); - const value = React.useMemo(() => ({ status, onStatusChange: setStatus }), [status]); + const value = React.useMemo( + () => ({ bordered, status, onStatusChange: setStatus }), + [bordered, status], + ); const interactive = Boolean(render); const element = useRender({ defaultTagName: 'span', @@ -44,7 +49,7 @@ const AvatarRoot = React.forwardRef(function Mosai ref, props: { ...mergeStyleProps( - themeProps('avatar', { shape, size }), + themeProps('avatar', { bordered, shape, size }), stylex.props( reset.base, styles.base, @@ -68,7 +73,7 @@ const AvatarImage = React.forwardRef(functio { src, alt = '', xstyle, ...rest }, ref, ) { - const { status, onStatusChange } = useAvatarContext('Avatar.Image'); + const { bordered, status, onStatusChange } = useAvatarContext('Avatar.Image'); // Preload `src` and report status to the root, so the fallback shows until the image resolves. // A layout effect, because it also has to catch the case below before anything is painted. @@ -111,7 +116,11 @@ const AvatarImage = React.forwardRef(functio // An avatar is an identity mark, not content to pull out of the page — dragging one // only ever produces a stray ghost image mid-interaction. draggable={false} - {...mergeStyleProps(themeProps('avatar-image'), stylex.props(reset.base, styles.image, xstyle), rest)} + {...mergeStyleProps( + themeProps('avatar-image'), + stylex.props(reset.base, styles.image, bordered && styles.overlay, xstyle), + rest, + )} /> ); }); @@ -125,7 +134,7 @@ const AvatarFallback = React.forwardRef(fu { delayMs, xstyle, children, ...rest }, ref, ) { - const { status } = useAvatarContext('Avatar.Fallback'); + const { bordered, status } = useAvatarContext('Avatar.Fallback'); const [canRender, setCanRender] = React.useState(delayMs === undefined); React.useEffect(() => { @@ -147,7 +156,13 @@ const AvatarFallback = React.forwardRef(fu ref={ref} {...mergeStyleProps( themeProps('avatar-fallback', { pending }), - stylex.props(reset.base, styles.fallback, pending && styles.fallbackPending, xstyle), + stylex.props( + reset.base, + styles.fallback, + bordered && styles.overlay, + pending && styles.fallbackPending, + xstyle, + ), rest, )} > diff --git a/packages/swingset/src/stories/avatar.mdx b/packages/swingset/src/stories/avatar.mdx index 4c9e162d53d..46d3d7af518 100644 --- a/packages/swingset/src/stories/avatar.mdx +++ b/packages/swingset/src/stories/avatar.mdx @@ -15,7 +15,7 @@ The fallback still takes children — initials, an icon — but never paints the ## Props -`shape` and `size` live on `Avatar.Root`: +`bordered`, `shape`, and `size` live on `Avatar.Root`. `bordered` draws a 1px inset outline over the image or fallback so the avatar keeps an edge against a matching background; pass `bordered={false}` to drop it. diff --git a/packages/swingset/src/stories/avatar.stories.tsx b/packages/swingset/src/stories/avatar.stories.tsx index f199941d3ce..84d02557be7 100644 --- a/packages/swingset/src/stories/avatar.stories.tsx +++ b/packages/swingset/src/stories/avatar.stories.tsx @@ -17,10 +17,12 @@ export const meta: StoryMeta = { source: 'packages/mosaic/src/components/avatar/avatar.tsx', styles: { _variants: { + bordered: { true: {}, false: {} }, shape: { circle: {}, square: {} }, size: { xs: {}, sm: {}, md: {}, lg: {} }, }, _defaultVariants: { + bordered: true, shape: 'circle', size: 'md', },