diff --git a/apps/storybook/stories/Avatar.stories.tsx b/apps/storybook/stories/Avatar.stories.tsx
index 2c4768ef28cf..7bbc1168138e 100644
--- a/apps/storybook/stories/Avatar.stories.tsx
+++ b/apps/storybook/stories/Avatar.stories.tsx
@@ -4,6 +4,7 @@ import type {Meta, StoryObj} from '@storybook/react';
import * as stylex from '@stylexjs/stylex';
import {Avatar} from '@astryxdesign/core/Avatar';
import {AvatarStatusDot} from '@astryxdesign/core/Avatar';
+import {Theme, defineTheme} from '@astryxdesign/core/theme';
import {
spacingVars,
typographyVars,
@@ -555,3 +556,55 @@ export const NumericSizes: Story = {
),
};
+
+// A theme can re-scope the fallback initials' typography per size tier via the
+// Avatar-scoped derived vars — a smaller-per-size type scale, regular weight,
+// and a muted secondary-text color on an accent wash fill — without forking the
+// component. The default row is unchanged (size × 0.4, medium weight, neutral
+// fill); only the themed row opts in.
+const fallbackScaleTheme = defineTheme({
+ name: 'avatar-fallback-scale',
+ components: {
+ avatar: {
+ base: {
+ fontWeight: 'var(--font-weight-normal)',
+ color: 'var(--color-text-secondary)',
+ backgroundColor: 'var(--color-accent-muted)',
+ },
+ 'size:xsm': {fontSize: '8px'},
+ 'size:sm': {fontSize: '9px'},
+ 'size:md': {fontSize: '13px'},
+ 'size:lg': {fontSize: '16px'},
+ 'size:xl': {fontSize: '40px'},
+ },
+ },
+});
+
+export const ThemedFallbackScale: Story = {
+ name: 'Themed Fallback Type Scale',
+ render: () => (
+
+
Default fallback (size × 0.4)
+
+
+
+ Themed fallback (per-size scale, regular weight, wash fill)
+
+
+
+
+
+ ),
+};
diff --git a/packages/core/src/Avatar/Avatar.doc.mjs b/packages/core/src/Avatar/Avatar.doc.mjs
index 89f0d2a73d4d..d39577e124f6 100644
--- a/packages/core/src/Avatar/Avatar.doc.mjs
+++ b/packages/core/src/Avatar/Avatar.doc.mjs
@@ -30,6 +30,18 @@ export const docs = {
{className: 'astryx-avatar', visualProps: ['size']},
{className: 'astryx-avatar-status-dot', visualProps: ['variant']},
],
+ vars: [
+ {name: '--_avatar-fallback-font-size', description: 'Initials font size; default is proportional to the avatar size (size × 0.4). Override per size tier (e.g. size:sm) for a custom type scale.', default: 'calc(avatar-size × 0.4)', private: true},
+ {name: '--_avatar-fallback-font-weight', description: 'Initials font weight', default: 'var(--font-weight-medium)', private: true},
+ {name: '--_avatar-fallback-color', description: 'Fallback text and default-icon color', default: 'var(--color-text-secondary)', private: true},
+ {name: '--_avatar-fallback-background', description: 'Fallback wash background fill', default: 'var(--color-neutral)', private: true},
+ ],
+ derived: [
+ {property: 'fontSize', vars: ['--_avatar-fallback-font-size']},
+ {property: 'fontWeight', vars: ['--_avatar-fallback-font-weight']},
+ {property: 'color', vars: ['--_avatar-fallback-color']},
+ {property: 'backgroundColor', vars: ['--_avatar-fallback-background']},
+ ],
},
description: 'Displays a user avatar with image, initials fallback, and optional status indicator.',
props: [
diff --git a/packages/core/src/Avatar/Avatar.test.tsx b/packages/core/src/Avatar/Avatar.test.tsx
index aced7382915d..513c600603e5 100644
--- a/packages/core/src/Avatar/Avatar.test.tsx
+++ b/packages/core/src/Avatar/Avatar.test.tsx
@@ -35,6 +35,19 @@ describe('Avatar', () => {
expect(innerImg).toHaveAttribute('alt', '');
});
+ it('renders fallback initials through the themeable font-size var, not a bare px literal', () => {
+ render();
+ const initials = screen.getByText('AL');
+ // The seam: the dynamic font size resolves to the Avatar-scoped var (with
+ // the proportional `size × 0.4` default baked in as the fallback), so a
+ // theme can re-scope it per size. A regression to a bare px literal would
+ // break theming.
+ const style = initials.getAttribute('style') ?? '';
+ expect(style).toContain('var(--_avatar-fallback-font-size,');
+ // Default still reproduces the proportional scale (sm = 24 × 0.4 = 9.6px).
+ expect(style).toMatch(/var\(--_avatar-fallback-font-size,\s*9\.6\d*px\)/);
+ });
+
it('retries a new src after a previous src failed to load', () => {
const {rerender} = render(
,
diff --git a/packages/core/src/Avatar/Avatar.tsx b/packages/core/src/Avatar/Avatar.tsx
index 2db1ef9493a8..d58f15e060fd 100644
--- a/packages/core/src/Avatar/Avatar.tsx
+++ b/packages/core/src/Avatar/Avatar.tsx
@@ -104,6 +104,11 @@ const styles = stylex.create({
position: 'relative',
display: 'inline-flex',
flexShrink: 0,
+ // The wrapper is not clipped (so the status dot can overflow), so it must be
+ // rounded itself: a themed fallback background lands on `.astryx-avatar` (the
+ // class-bearing wrapper) as well as the internal var, and an unrounded
+ // wrapper would show that fill as square corners behind the circular content.
+ borderRadius: radiusVars['--radius-full'],
},
content: {
display: 'flex',
@@ -124,10 +129,14 @@ const styles = stylex.create({
justifyContent: 'center',
width: '100%',
height: '100%',
- backgroundColor: colorVars['--color-neutral'],
- color: colorVars['--color-text-secondary'],
+ // Fallback surface (initials + default icon). Each property reads an
+ // Avatar-scoped internal var so a theme can re-scope the fallback wash and
+ // initials weight/color without forking; the defaults reproduce today's
+ // exact output. See derivedVarRegistry (avatar) + Avatar.doc.mjs theming.
+ backgroundColor: `var(--_avatar-fallback-background, ${colorVars['--color-neutral']})`,
+ color: `var(--_avatar-fallback-color, ${colorVars['--color-text-secondary']})`,
fontFamily: typographyVars['--font-family-body'],
- fontWeight: fontWeightVars['--font-weight-medium'],
+ fontWeight: `var(--_avatar-fallback-font-weight, ${fontWeightVars['--font-weight-medium']})`,
textTransform: 'uppercase',
},
status: {
@@ -143,8 +152,13 @@ const dynamicStyles = stylex.create({
width: size,
height: size,
}),
+ // Initials font size defaults to the proportional `size × ratio` scale but is
+ // reachable via the `--_avatar-fallback-font-size` derived var, so a theme can
+ // set a per-size type scale (e.g. `components.avatar['size:sm']`).
fontSize: (size: number) => ({
- fontSize: size * INITIALS_FONT_SIZE_RATIO,
+ fontSize: `var(--_avatar-fallback-font-size, ${
+ size * INITIALS_FONT_SIZE_RATIO
+ }px)`,
}),
statusPosition: (size: number) => ({
bottom: size * CIRCLE_EDGE_OFFSET_RATIO,
diff --git a/packages/core/src/theme/derivedVarRegistry.test.ts b/packages/core/src/theme/derivedVarRegistry.test.ts
index 94b52e14a301..61d3a3d99299 100644
--- a/packages/core/src/theme/derivedVarRegistry.test.ts
+++ b/packages/core/src/theme/derivedVarRegistry.test.ts
@@ -186,6 +186,7 @@ function discoverComponents(): ComponentInfo[] {
// ---------------------------------------------------------------------------
const DIR_TO_REGISTRY_KEY: Record = {
+ Avatar: 'avatar',
Banner: 'banner',
Button: 'button',
Card: 'card',
diff --git a/packages/core/src/theme/derivedVarRegistry.ts b/packages/core/src/theme/derivedVarRegistry.ts
index 3a037c2f4a03..88449e8ee613 100644
--- a/packages/core/src/theme/derivedVarRegistry.ts
+++ b/packages/core/src/theme/derivedVarRegistry.ts
@@ -35,6 +35,12 @@ export interface DerivedVarEntry {
* entries share the same property.
*/
export const derivedVarRegistry: Record = {
+ avatar: [
+ {property: 'fontSize', vars: ['--_avatar-fallback-font-size']},
+ {property: 'fontWeight', vars: ['--_avatar-fallback-font-weight']},
+ {property: 'color', vars: ['--_avatar-fallback-color']},
+ {property: 'backgroundColor', vars: ['--_avatar-fallback-background']},
+ ],
banner: [{property: 'borderRadius', vars: ['--_banner-radius']}],
button: [{property: 'borderRadius', vars: ['--_button-radius']}],
card: [
diff --git a/packages/core/src/theme/generateThemeRules.test.ts b/packages/core/src/theme/generateThemeRules.test.ts
index b77d671143df..df61188b1875 100644
--- a/packages/core/src/theme/generateThemeRules.test.ts
+++ b/packages/core/src/theme/generateThemeRules.test.ts
@@ -385,6 +385,48 @@ describe('derived var expansion', () => {
expect(rule).toContain('--_button-radius: 8px');
});
+ it('emits internal vars for avatar fallback typography (base)', () => {
+ const theme = defineTheme({
+ name: 'test-derived-avatar',
+ components: {
+ avatar: {
+ base: {
+ fontWeight: 'var(--font-weight-normal)',
+ color: 'var(--color-text-secondary)',
+ backgroundColor: 'var(--color-accent-muted)',
+ },
+ },
+ },
+ });
+ const rules = generateThemeRules(theme);
+ const rule = rules.find(r => r.includes('.astryx-avatar'));
+ expect(rule).toBeDefined();
+ expect(rule).toContain(
+ '--_avatar-fallback-font-weight: var(--font-weight-normal)',
+ );
+ expect(rule).toContain(
+ '--_avatar-fallback-color: var(--color-text-secondary)',
+ );
+ expect(rule).toContain(
+ '--_avatar-fallback-background: var(--color-accent-muted)',
+ );
+ });
+
+ it('emits a per-size fallback font-size for avatar (size:sm)', () => {
+ const theme = defineTheme({
+ name: 'test-derived-avatar-size',
+ components: {
+ avatar: {
+ 'size:sm': {fontSize: '9px'},
+ },
+ },
+ });
+ const rules = generateThemeRules(theme);
+ const rule = rules.find(r => r.includes('.astryx-avatar.sm'));
+ expect(rule).toBeDefined();
+ expect(rule).toContain('--_avatar-fallback-font-size: 9px');
+ });
+
it('does not emit derived vars for components without registry entries', () => {
const theme = defineTheme({
name: 'test-no-derived',