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',
},