diff --git a/.changeset/avatar-button-block.md b/.changeset/avatar-button-block.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/avatar-button-block.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/.changeset/mosaic-avatar-interactive-styles.md b/.changeset/mosaic-avatar-interactive-styles.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/mosaic-avatar-interactive-styles.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts
index 6a69e33d0a8..e721dabd689 100644
--- a/packages/swingset/src/lib/registry.ts
+++ b/packages/swingset/src/lib/registry.ts
@@ -3,6 +3,7 @@ import { meta as accordionMeta } from '../stories/accordion.stories';
import { meta as autocompleteMeta } from '../stories/autocomplete.stories';
import {
Fallback as AvatarFallbackStory,
+ Interactive as AvatarInteractive,
meta as avatarMeta,
Primary as AvatarPrimary,
Shapes as AvatarShapes,
@@ -175,6 +176,7 @@ const cardComponentModule: StoryModule = { meta: cardComponentMeta, Default: Car
const avatarModule: StoryModule = {
meta: avatarMeta,
Primary: AvatarPrimary,
+ Interactive: AvatarInteractive,
Fallback: AvatarFallbackStory,
Sizes: AvatarSizes,
Shapes: AvatarShapes,
diff --git a/packages/swingset/src/stories/avatar.mdx b/packages/swingset/src/stories/avatar.mdx
index dce77c7f735..9deae63120c 100644
--- a/packages/swingset/src/stories/avatar.mdx
+++ b/packages/swingset/src/stories/avatar.mdx
@@ -2,7 +2,7 @@ import * as AvatarStories from './avatar.stories';
# Avatar
-Avatar represents a user or entity as an image, falling back to a blank placeholder when the image is missing or fails to load. It is a compound component: `Avatar.Root` clips and sizes the box, `Avatar.Image` renders the picture once it loads, and `Avatar.Fallback` holds the space until then.
+Avatar represents a user or entity as an image, falling back to a blank placeholder when the image is missing or fails to load. It is a compound component: `Avatar.Root` positions and sizes the box, `Avatar.Image` renders the picture once it loads, `Avatar.Fallback` holds the space until then, and `Avatar.Icon` adds an optional corner affordance.
The fallback still takes children — initials, an icon — but never paints them, so an entity without a picture reads as an absent image rather than as two letters. They stay in the DOM, held in an `avatar-fallback-content` slot that is `visibility: hidden`: out of the page, the accessibility tree, and the tab order together, and overridable by a consumer who wants them back.
@@ -37,10 +37,11 @@ import { Avatar } from '@clerk/ui/mosaic/components/avatar';
| Part | Slot (`.cl-*`) | Description |
| ----------------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
-| `Avatar.Root` | `cl-avatar` | Owns `shape` / `size`, clips its children to the shape. |
+| `Avatar.Root` | `cl-avatar` | Owns `shape` / `size`, positioning, and polymorphic rendering. |
| `Avatar.Image` | `cl-avatar-image` | Renders an `` once the source loads; renders nothing until then. |
| `Avatar.Fallback` | `cl-avatar-fallback` | Rendered while the image is pending or has failed. Carries `data-pending` while an image is still resolving. Optional `delayMs`. |
| — | `cl-avatar-fallback-content` | Wraps whatever `Avatar.Fallback` was handed, hidden. Override its `visibility` to paint initials or an icon. |
+| `Avatar.Icon` | `cl-avatar-icon` | Renders an optional icon surface over the avatar corner. |
---
@@ -55,6 +56,15 @@ While an image is still resolving, the fallback carries `data-pending` and pulse
storyModule={AvatarStories}
/>
+### Interactive
+
+Use `render` to compose the avatar onto a button or another trigger without introducing a separate wrapper component. The `lg` avatar and button are both 48×48.
+
+
+
### Fallback
Once the image fails, the pulse stops and the fallback settles into a plain fill. Nothing is on its way for an entity that has no picture, so its mark holds still rather than claiming to be loading one.
diff --git a/packages/swingset/src/stories/avatar.stories.tsx b/packages/swingset/src/stories/avatar.stories.tsx
index b14a0cea45e..bb5e9b39ff8 100644
--- a/packages/swingset/src/stories/avatar.stories.tsx
+++ b/packages/swingset/src/stories/avatar.stories.tsx
@@ -1,6 +1,7 @@
/** @jsxImportSource @emotion/react */
import type { AvatarProps } from '@clerk/ui/mosaic/components/avatar';
import { Avatar } from '@clerk/ui/mosaic/components/avatar';
+import { Icon } from '@clerk/ui/mosaic/components/icon';
import type { StoryMeta } from '@/lib/types';
@@ -33,6 +34,7 @@ function knobsAsProps(props: Record) {
}
const IMAGE_SRC = 'https://github.com/clerk.png';
+const COLIN_IMAGE_SRC = 'https://avatars.githubusercontent.com/u/51144033?v=4';
export function Primary(props: Record) {
return (
@@ -70,6 +72,30 @@ export function Fallback(props: Record) {
);
}
+export function Interactive(props: Record) {
+ return (
+
+ }
+ >
+
+ CL
+
+
+
+
+ );
+}
+
export function Sizes(props: Record) {
return (
diff --git a/packages/ui/src/mosaic/components/avatar/avatar.styles.ts b/packages/ui/src/mosaic/components/avatar/avatar.styles.ts
index c4961a0f156..787e69fda75 100644
--- a/packages/ui/src/mosaic/components/avatar/avatar.styles.ts
+++ b/packages/ui/src/mosaic/components/avatar/avatar.styles.ts
@@ -8,9 +8,8 @@ const pulse = stylex.keyframes({
});
export const styles = stylex.create({
- // root — clips its parts to the shape/size; fill comes from the image or fallback
+ // root — sizes and positions its parts; fill comes from the image or fallback
base: {
- overflow: 'hidden',
alignItems: 'center',
aspectRatio: '1 / 1',
display: 'inline-flex',
@@ -24,6 +23,22 @@ export const styles = stylex.create({
verticalAlign: 'middle',
},
+ interactive: {
+ background: 'transparent',
+ // An avatar used as a native button has no border shrinking the avatar inside it.
+ borderWidth: 0,
+ outline: {
+ default: 'none',
+ ':focus-visible': `2px solid ${colorVars['--cl-color-primary']}`,
+ },
+ appearance: 'none',
+ cursor: {
+ default: 'pointer',
+ ':is(:disabled, [aria-disabled="true"])': 'not-allowed',
+ },
+ outlineOffset: '2px',
+ },
+
// 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: {
@@ -63,6 +78,24 @@ export const styles = stylex.create({
},
animationTimingFunction: 'cubic-bezier(0.4, 0, 0.6, 1)',
},
+
+ icon: {
+ borderColor: colorVars['--cl-color-border'],
+ borderRadius: radiusVars['--cl-radius-full'],
+ borderStyle: 'solid',
+ borderWidth: '1px',
+ overflow: 'hidden',
+ alignItems: 'center',
+ backgroundColor: colorVars['--cl-color-card'],
+ boxSizing: 'border-box',
+ display: 'flex',
+ insetBlockEnd: `calc(${space['2']} * -1)`,
+ insetInlineStart: `calc(${space['1']} * -1)`,
+ justifyContent: 'center',
+ position: 'absolute',
+ height: space['6'],
+ width: space['6'],
+ },
});
// shape — square shares its radius with Button; circle rounds fully
diff --git a/packages/ui/src/mosaic/components/avatar/avatar.test.tsx b/packages/ui/src/mosaic/components/avatar/avatar.test.tsx
index 37d55b255d6..bdd628c92f0 100644
--- a/packages/ui/src/mosaic/components/avatar/avatar.test.tsx
+++ b/packages/ui/src/mosaic/components/avatar/avatar.test.tsx
@@ -4,6 +4,7 @@ import { flushSync } from 'react-dom';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
+import { Icon } from '../icon';
import { Avatar } from './avatar';
// React reads this off the global object and ships no typing for it.
@@ -280,9 +281,37 @@ describe('Mosaic Avatar', () => {
expect(avatar).toHaveStyle({ marginTop: '8px' });
});
+ it('composes its root onto another element and renders an icon affordance', () => {
+ const ref = React.createRef();
+ render(
+
+ }
+ >
+ CN
+
+
+
+ ,
+ );
+
+ const button = screen.getByRole('button', { name: 'Edit profile picture' });
+ expect(button).toHaveClass('cl-avatar');
+ expect(button).toHaveAttribute('data-size', 'lg');
+ expect(ref.current).toBe(button);
+ expect(button.querySelector('.cl-avatar-icon')).toHaveAttribute('aria-hidden', 'true');
+ });
+
it('throws when a part is rendered outside ', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(() => render(CN)).toThrow(/must be rendered inside /);
+ expect(() => render()).toThrow(/must be rendered inside /);
spy.mockRestore();
});
});
diff --git a/packages/ui/src/mosaic/components/avatar/avatar.tsx b/packages/ui/src/mosaic/components/avatar/avatar.tsx
index a679244d3d2..1a4d1c68d02 100644
--- a/packages/ui/src/mosaic/components/avatar/avatar.tsx
+++ b/packages/ui/src/mosaic/components/avatar/avatar.tsx
@@ -1,7 +1,9 @@
+import { useRender } from '@clerk/headless/utils';
import { useSafeLayoutEffect } from '@clerk/shared/react';
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 { shapes, sizes, styles } from './avatar.styles';
@@ -23,34 +25,34 @@ function useAvatarContext(part: string): AvatarContextValue {
return context;
}
-export interface AvatarProps extends React.ComponentPropsWithRef<'span'> {
+export interface AvatarProps extends MosaicComponentProps<'span'> {
shape?: 'circle' | 'square';
size?: 'fit' | 'lg' | 'md' | 'sm' | 'xs';
}
const AvatarRoot = React.forwardRef(function MosaicAvatarRoot(
- { shape = 'circle', size = 'md', className, style, children, ...rest },
+ { shape = 'circle', size = 'md', render, className, style, ...rest },
ref,
) {
const [status, setStatus] = React.useState('idle');
const value = React.useMemo(() => ({ status, onStatusChange: setStatus }), [status]);
+ const interactive = Boolean(render);
+ const element = useRender({
+ defaultTagName: 'span',
+ render,
+ ref,
+ props: {
+ ...mergeStyleProps(
+ themeProps('avatar', { shape, size }),
+ stylex.props(reset.base, styles.base, shapes[shape], sizes[size], interactive && styles.interactive),
+ className,
+ style,
+ ),
+ ...rest,
+ },
+ });
- return (
-
-
- {children}
-
-
- );
+ return {element};
});
export type AvatarImageProps = React.ComponentPropsWithRef<'img'>;
@@ -152,13 +154,33 @@ const AvatarFallback = React.forwardRef(fu
);
});
+export type AvatarIconProps = React.ComponentPropsWithRef<'span'>;
+
+const AvatarIcon = React.forwardRef(function MosaicAvatarIcon(
+ { className, style, ...rest },
+ ref,
+) {
+ useAvatarContext('Avatar.Icon');
+
+ return (
+
+ );
+});
+
/**
- * Compound avatar. `Avatar.Root` clips and sizes the box; `Avatar.Image` renders
+ * Compound avatar. `Avatar.Root` positions and sizes the box; `Avatar.Image` renders
* once its source loads; `Avatar.Fallback` holds the space until then, as a blank
- * placeholder that pulses only while an image is actually on its way.
+ * placeholder that pulses only while an image is actually on its way; `Avatar.Icon`
+ * adds an optional corner affordance.
*/
export const Avatar = {
Root: AvatarRoot,
Image: AvatarImage,
Fallback: AvatarFallback,
+ Icon: AvatarIcon,
};
diff --git a/packages/ui/src/mosaic/components/avatar/index.ts b/packages/ui/src/mosaic/components/avatar/index.ts
index 236ba032f57..363ec08e971 100644
--- a/packages/ui/src/mosaic/components/avatar/index.ts
+++ b/packages/ui/src/mosaic/components/avatar/index.ts
@@ -1,2 +1,2 @@
export { Avatar } from './avatar';
-export type { AvatarProps, AvatarImageProps, AvatarFallbackProps } from './avatar';
+export type { AvatarProps, AvatarImageProps, AvatarFallbackProps, AvatarIconProps } from './avatar';
diff --git a/packages/ui/src/mosaic/icons/registry.tsx b/packages/ui/src/mosaic/icons/registry.tsx
index 5dcc15f012c..26a28cda818 100644
--- a/packages/ui/src/mosaic/icons/registry.tsx
+++ b/packages/ui/src/mosaic/icons/registry.tsx
@@ -9,11 +9,11 @@ type IconComponent = React.ForwardRefExoticComponent<
* Builds a glyph from its inner `` markup. Glyphs omit `width`/`height` so the `Icon` recipe
* controls size, and use `currentColor` so they inherit text color. Grow the set on demand.
*/
-function glyph(children: React.ReactNode): IconComponent {
+function glyph(children: React.ReactNode, viewBox = '0 0 16 16'): IconComponent {
return React.forwardRef>((props, ref) => (
,
);
+const Pen = glyph(
+ ,
+);
+
const LogOut = glyph(