From 3e5e5792f1a7fa22019c2af438f0a98a5b4ae0ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Wed, 27 May 2026 13:21:05 -0500 Subject: [PATCH 01/16] feat: add AvatarGroup --- src/avatar/avatar.mdx | 16 ++++ src/avatar/avatar.module.css | 101 ++++++++++++++++++++++++ src/avatar/avatar.stories.tsx | 99 ++++++++++++++++++++++- src/avatar/avatar.test.tsx | 118 +++++++++++++++++++++++++++- src/avatar/avatar.tsx | 143 +++++++++++++++++++++++++++++++++- 5 files changed, 473 insertions(+), 4 deletions(-) diff --git a/src/avatar/avatar.mdx b/src/avatar/avatar.mdx index 7700841a..4df23d1c 100644 --- a/src/avatar/avatar.mdx +++ b/src/avatar/avatar.mdx @@ -25,6 +25,14 @@ and the deterministic meta color used when initials render. +## Avatar groups + +Use `AvatarGroup` when a compact surface represents several people. Pass the +same `size` to the group and its direct `Avatar` children. The optional `count` +is rendered as plain numeric text on top of the final avatar. + + + ## Migrating from the legacy API The previous Avatar API accepted `user`, `avatarUrl`, `colorList`, string or @@ -110,6 +118,7 @@ component appearance. The values shown below are the default values. + #### Avatar meta colors @@ -168,6 +177,13 @@ the component props instead of overriding them directly. --reactist-avatar-size: 36px; --reactist-avatar-rounded-radius: 5px; } + +.avatarGroup { + --reactist-avatar-group-size: 36px; + --reactist-avatar-group-overlap: 4px; + --reactist-avatar-group-mask: 2.5px; + --reactist-avatar-group-rounded-radius: 5px; +} ``` ## What the consumer owns diff --git a/src/avatar/avatar.module.css b/src/avatar/avatar.module.css index acaec40d..7df9a6ae 100644 --- a/src/avatar/avatar.module.css +++ b/src/avatar/avatar.module.css @@ -189,3 +189,104 @@ line-height: 1; user-select: none; } + +.avatarGroup { + --reactist-avatar-group-size: 36px; + --reactist-avatar-group-overlap: 4px; + --reactist-avatar-group-mask: 2.5px; + --reactist-avatar-group-rounded-radius: 5px; + --reactist-avatar-group-count-overlay: rgba(0, 0, 0, 0.6); + --reactist-avatar-group-previous-center-x: calc( + (var(--reactist-avatar-group-size) / 2) - var(--reactist-avatar-group-size) + + var(--reactist-avatar-group-overlap) + ); + + position: relative; +} + +.avatarGroup > * { + flex-shrink: 0; +} + +.avatarGroup > * + * { + margin-left: calc(-1 * var(--reactist-avatar-group-overlap)); +} + +.avatarGroupShape-circle > * + * { + -webkit-mask-image: radial-gradient( + circle calc((var(--reactist-avatar-group-size) / 2) + var(--reactist-avatar-group-mask)) at + var(--reactist-avatar-group-previous-center-x) 50%, + transparent 99%, + #000 100% + ); + mask-image: radial-gradient( + circle calc((var(--reactist-avatar-group-size) / 2) + var(--reactist-avatar-group-mask)) at + var(--reactist-avatar-group-previous-center-x) 50%, + transparent 99%, + #000 100% + ); +} + +.avatarGroupShape-rounded > * + * { + -webkit-mask-image: linear-gradient( + to right, + transparent 0 calc(var(--reactist-avatar-group-overlap) + var(--reactist-avatar-group-mask)), + #000 calc(var(--reactist-avatar-group-overlap) + var(--reactist-avatar-group-mask)) + ); + mask-image: linear-gradient( + to right, + transparent 0 calc(var(--reactist-avatar-group-overlap) + var(--reactist-avatar-group-mask)), + #000 calc(var(--reactist-avatar-group-overlap) + var(--reactist-avatar-group-mask)) + ); +} + +.avatarGroup[data-count]::after { + content: attr(data-count); + position: absolute; + top: 0; + right: 0; + display: flex; + align-items: center; + justify-content: center; + width: var(--reactist-avatar-group-size); + height: var(--reactist-avatar-group-size); + border-radius: 50%; + background: var(--reactist-avatar-group-count-overlay); + color: var(--reactist-avatar-initials-color); + font-size: calc(var(--reactist-avatar-group-size) / 2); + font-weight: var(--reactist-font-weight-medium); + line-height: 1; + pointer-events: none; + user-select: none; + -webkit-mask-image: radial-gradient( + circle calc((var(--reactist-avatar-group-size) / 2) + var(--reactist-avatar-group-mask)) at + var(--reactist-avatar-group-previous-center-x) 50%, + transparent 99%, + #000 100% + ); + mask-image: radial-gradient( + circle calc((var(--reactist-avatar-group-size) / 2) + var(--reactist-avatar-group-mask)) at + var(--reactist-avatar-group-previous-center-x) 50%, + transparent 99%, + #000 100% + ); +} + +.avatarGroupShape-rounded[data-count]::after { + border-radius: var(--reactist-avatar-group-rounded-radius); + -webkit-mask-image: linear-gradient( + to right, + transparent 0 calc(var(--reactist-avatar-group-overlap) + var(--reactist-avatar-group-mask)), + #000 calc(var(--reactist-avatar-group-overlap) + var(--reactist-avatar-group-mask)) + ); + mask-image: linear-gradient( + to right, + transparent 0 calc(var(--reactist-avatar-group-overlap) + var(--reactist-avatar-group-mask)), + #000 calc(var(--reactist-avatar-group-overlap) + var(--reactist-avatar-group-mask)) + ); +} + +.avatarGroup[data-count]:has(> :first-child:last-child)::after { + -webkit-mask-image: none; + mask-image: none; +} diff --git a/src/avatar/avatar.stories.tsx b/src/avatar/avatar.stories.tsx index 720595d1..2f38b790 100644 --- a/src/avatar/avatar.stories.tsx +++ b/src/avatar/avatar.stories.tsx @@ -1,6 +1,6 @@ import * as React from 'react' -import { Avatar, Box, Inline, Stack, Text } from '../index' +import { Avatar, AvatarGroup, Box, Inline, Stack, Text } from '../index' import { AVATAR_SIZES, getAvatarMetaColorIndex } from './utils' @@ -168,6 +168,18 @@ function WorkspaceAvatarExample(props: Omit) { return } +function AvatarGroupCustomOverlayStyle() { + return ( + + ) +} + function AvatarColorExample({ index, name }: { index: number; name: string }) { return ( @@ -218,6 +230,91 @@ export const Default = { ), } satisfies Story +export const AvatarGroups = { + render: () => ( + + + + + + + + {contributors.slice(1, 6).map((contributor) => ( + + ))} + + + + + {contributors.slice(2, 5).map((contributor) => ( + + ))} + + + + + {contributors.slice(3, 7).map((contributor) => ( + + ))} + + + + + + + + {([80, 62, 50, 36, 24, 18, 12] as const).map((size, index) => ( + + + {[0, 1, 2].map((offset) => { + const contributor = getContributor(index + offset)! + + return ( + + ) + })} + + + ))} + + + + ), +} satisfies Story + export const InitialsFallback = { render: () => ( diff --git a/src/avatar/avatar.test.tsx b/src/avatar/avatar.test.tsx index e78137b3..13886507 100644 --- a/src/avatar/avatar.test.tsx +++ b/src/avatar/avatar.test.tsx @@ -3,7 +3,7 @@ import * as React from 'react' import { fireEvent, render, screen } from '@testing-library/react' import { axe } from 'jest-axe' -import { Avatar } from './avatar' +import { Avatar, AvatarGroup } from './avatar' describe('Avatar', () => { function failCurrentAvatarImage(currentSrc: string) { @@ -303,3 +303,119 @@ describe('Avatar', () => { }) }) }) + +describe('AvatarGroup', () => { + it('renders direct Avatar children without wrappers', () => { + render( + + + + , + ) + + expect(screen.getByTestId('group')).toContainElement(screen.getByTestId('first')) + expect(screen.getByTestId('group')).toContainElement(screen.getByTestId('second')) + expect(screen.getByTestId('first').parentElement).toBe(screen.getByTestId('group')) + expect(screen.getByTestId('second').parentElement).toBe(screen.getByTestId('group')) + }) + + it('sets size-derived spacing variables', () => { + render( + + + + , + ) + + expect(screen.getByTestId('group')).toHaveStyle({ + '--reactist-avatar-group-size': '36px', + '--reactist-avatar-group-overlap': '4px', + '--reactist-avatar-group-mask': '2.5px', + }) + }) + + it('sets large size-derived spacing variables', () => { + render( + + + + , + ) + + expect(screen.getByTestId('group')).toHaveStyle({ + '--reactist-avatar-group-size': '80px', + '--reactist-avatar-group-overlap': '8px', + '--reactist-avatar-group-mask': '3px', + }) + }) + + it('exposes positive count through data-count', () => { + render( + + + + , + ) + + expect(screen.getByTestId('group')).toHaveAttribute('data-count', '3') + }) + + it('omits data-count when count is not positive', () => { + render( + + + + , + ) + + expect(screen.getByTestId('group')).not.toHaveAttribute('data-count') + }) + + it('omits data-count when count is not provided', () => { + render( + + + + , + ) + + expect(screen.getByTestId('group')).not.toHaveAttribute('data-count') + }) + + it('leaves the count overlay custom property available for CSS customization', () => { + render( + + + + , + ) + + expect( + screen + .getByTestId('group') + .style.getPropertyValue('--reactist-avatar-group-count-overlay'), + ).toBe('') + }) + + it('applies the group shape class', () => { + render( + + + + , + ) + + expect(screen.getByTestId('group')).toHaveClass('avatarGroupShape-rounded') + }) + + it('applies the escape hatch class name', () => { + render( + + + + , + ) + + expect(screen.getByTestId('group')).toHaveClass('custom-group') + }) +}) diff --git a/src/avatar/avatar.tsx b/src/avatar/avatar.tsx index 9704dbc7..54259d5b 100644 --- a/src/avatar/avatar.tsx +++ b/src/avatar/avatar.tsx @@ -26,6 +26,45 @@ type AvatarStyle = React.CSSProperties & { '--reactist-avatar-rounded-radius': string } +type AvatarGroupStyle = React.CSSProperties & { + '--reactist-avatar-group-size': string + '--reactist-avatar-group-overlap': string + '--reactist-avatar-group-mask': string + '--reactist-avatar-group-rounded-radius': string +} + +const AVATAR_GROUP_OVERLAP_BY_SIZE: Record = { + 80: '8px', + 72: '8px', + 62: '8px', + 50: '4px', + 40: '4px', + 36: '4px', + 30: '2px', + 28: '2px', + 24: '2px', + 20: '2px', + 18: '2px', + 16: '2px', + 12: '1px', +} + +const AVATAR_GROUP_MASK_BY_SIZE: Record = { + 80: '3px', + 72: '3px', + 62: '3px', + 50: '3px', + 40: '3px', + 36: '2.5px', + 30: '2.5px', + 28: '2px', + 24: '2px', + 20: '2px', + 18: '1.5px', + 16: '1.25px', + 12: '1px', +} + /** * Props for the `Avatar` component. */ @@ -84,6 +123,54 @@ type AvatarProps = PolymorphicC 'omitClassName' > +/** + * Props for the `AvatarGroup` component. + */ +type AvatarGroupOwnProps = ObfuscatedClassName & { + /** + * The rendered avatar size, in CSS pixels. + * + * Direct child Avatar components should use the same size. + */ + size: AvatarSize + + /** + * The grouped avatar shape. + * + * Direct child Avatar components should use the same shape. + * + * @default 'circle' + */ + shape?: AvatarShape + + /** + * The number of additional people represented by the final avatar. + */ + count?: number + + /** + * Grouped Avatar children. + */ + children: React.ReactNode + + /** + * Test identifier applied to the avatar group root element. + */ + 'data-testid'?: string + + /** + * AvatarGroup owns its root sizing styles. Use `exceptionallySetClassName` for the styling + * escape hatch. + */ + style?: never +} + +type AvatarGroupProps = PolymorphicComponentProps< + ComponentType, + AvatarGroupOwnProps, + 'omitClassName' +> + const AvatarContent = polymorphicComponent<'div', AvatarOwnProps, 'omitClassName'>( function AvatarContent( { @@ -190,6 +277,49 @@ const Avatar = polymorphicComponent<'div', AvatarOwnProps, 'omitClassName'>(func ) }) +/** + * Displays a row of overlapping Avatar children with an optional count overlay + * on the final avatar. + */ +const AvatarGroup = polymorphicComponent<'div', AvatarGroupOwnProps, 'omitClassName'>( + function AvatarGroup( + { + as, + size, + shape = 'circle', + count, + children, + exceptionallySetClassName, + 'data-testid': testId, + ...restProps + }, + ref, + ) { + const countAttribute = count != null && count > 0 ? String(count) : undefined + + return ( + + {children} + + ) + }, +) + function getAvatarStyle(size: AvatarSize): AvatarStyle { return { '--reactist-avatar-size': `${size}px`, @@ -197,6 +327,15 @@ function getAvatarStyle(size: AvatarSize): AvatarStyle { } } +function getAvatarGroupStyle(size: AvatarSize): AvatarGroupStyle { + return { + '--reactist-avatar-group-size': `${size}px`, + '--reactist-avatar-group-overlap': AVATAR_GROUP_OVERLAP_BY_SIZE[size], + '--reactist-avatar-group-mask': AVATAR_GROUP_MASK_BY_SIZE[size], + '--reactist-avatar-group-rounded-radius': ROUNDED_AVATAR_RADIUS_BY_SIZE[size], + } +} + function getAbsoluteImageSource(src: string, image: HTMLImageElement) { try { return new URL(src, image.ownerDocument.baseURI).href @@ -214,5 +353,5 @@ function getFailedImageSource(imageProps: ImageSources, image: HTMLImageElement) return matchingSource?.src ?? imageProps.src } -export { Avatar } -export type { AvatarImage, AvatarProps, AvatarShape, AvatarSize } +export { Avatar, AvatarGroup } +export type { AvatarGroupProps, AvatarImage, AvatarProps, AvatarShape, AvatarSize } From 1f1b1d54f118ac9c5159c28c670c05917c7de5c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Wed, 27 May 2026 13:59:39 -0500 Subject: [PATCH 02/16] fix: round avatar group overlap masks --- src/avatar/avatar.module.css | 90 +++++++++++++++++++++++++++--------- src/avatar/avatar.test.tsx | 2 + src/avatar/avatar.tsx | 9 +++- 3 files changed, 78 insertions(+), 23 deletions(-) diff --git a/src/avatar/avatar.module.css b/src/avatar/avatar.module.css index 7df9a6ae..d8198fe8 100644 --- a/src/avatar/avatar.module.css +++ b/src/avatar/avatar.module.css @@ -195,7 +195,14 @@ --reactist-avatar-group-overlap: 4px; --reactist-avatar-group-mask: 2.5px; --reactist-avatar-group-rounded-radius: 5px; + --reactist-avatar-group-rounded-mask-radius: 7.5px; --reactist-avatar-group-count-overlay: rgba(0, 0, 0, 0.6); + --reactist-avatar-group-rounded-mask-width: calc( + var(--reactist-avatar-group-overlap) + var(--reactist-avatar-group-mask) + ); + --reactist-avatar-group-rounded-mask-corner-x: calc( + var(--reactist-avatar-group-overlap) - var(--reactist-avatar-group-rounded-radius) + ); --reactist-avatar-group-previous-center-x: calc( (var(--reactist-avatar-group-size) / 2) - var(--reactist-avatar-group-size) + var(--reactist-avatar-group-overlap) @@ -227,17 +234,68 @@ ); } -.avatarGroupShape-rounded > * + * { - -webkit-mask-image: linear-gradient( - to right, - transparent 0 calc(var(--reactist-avatar-group-overlap) + var(--reactist-avatar-group-mask)), - #000 calc(var(--reactist-avatar-group-overlap) + var(--reactist-avatar-group-mask)) - ); - mask-image: linear-gradient( - to right, - transparent 0 calc(var(--reactist-avatar-group-overlap) + var(--reactist-avatar-group-mask)), - #000 calc(var(--reactist-avatar-group-overlap) + var(--reactist-avatar-group-mask)) - ); +.avatarGroupShape-rounded > * + *, +.avatarGroupShape-rounded[data-count]::after { + -webkit-mask-image: + linear-gradient(#000 0 0), + radial-gradient( + circle var(--reactist-avatar-group-rounded-mask-radius) at + var(--reactist-avatar-group-rounded-mask-corner-x) + var(--reactist-avatar-group-rounded-radius), + #000 99%, + transparent 100% + ), + radial-gradient( + circle var(--reactist-avatar-group-rounded-mask-radius) at + var(--reactist-avatar-group-rounded-mask-corner-x) + calc(100% - var(--reactist-avatar-group-rounded-radius)), + #000 99%, + transparent 100% + ), + linear-gradient(#000 0 0); + mask-image: + linear-gradient(#000 0 0), + radial-gradient( + circle var(--reactist-avatar-group-rounded-mask-radius) at + var(--reactist-avatar-group-rounded-mask-corner-x) + var(--reactist-avatar-group-rounded-radius), + #000 99%, + transparent 100% + ), + radial-gradient( + circle var(--reactist-avatar-group-rounded-mask-radius) at + var(--reactist-avatar-group-rounded-mask-corner-x) + calc(100% - var(--reactist-avatar-group-rounded-radius)), + #000 99%, + transparent 100% + ), + linear-gradient(#000 0 0); + -webkit-mask-position: + left center, + 0 0, + 0 0, + 0 0; + mask-position: + left center, + 0 0, + 0 0, + 0 0; + -webkit-mask-size: + var(--reactist-avatar-group-rounded-mask-width) + calc(100% - (2 * var(--reactist-avatar-group-rounded-radius))), + 100% 100%, + 100% 100%, + 100% 100%; + mask-size: + var(--reactist-avatar-group-rounded-mask-width) + calc(100% - (2 * var(--reactist-avatar-group-rounded-radius))), + 100% 100%, + 100% 100%, + 100% 100%; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-composite: destination-out, destination-out, destination-out; + mask-composite: subtract; } .avatarGroup[data-count]::after { @@ -274,16 +332,6 @@ .avatarGroupShape-rounded[data-count]::after { border-radius: var(--reactist-avatar-group-rounded-radius); - -webkit-mask-image: linear-gradient( - to right, - transparent 0 calc(var(--reactist-avatar-group-overlap) + var(--reactist-avatar-group-mask)), - #000 calc(var(--reactist-avatar-group-overlap) + var(--reactist-avatar-group-mask)) - ); - mask-image: linear-gradient( - to right, - transparent 0 calc(var(--reactist-avatar-group-overlap) + var(--reactist-avatar-group-mask)), - #000 calc(var(--reactist-avatar-group-overlap) + var(--reactist-avatar-group-mask)) - ); } .avatarGroup[data-count]:has(> :first-child:last-child)::after { diff --git a/src/avatar/avatar.test.tsx b/src/avatar/avatar.test.tsx index 13886507..05b2f8dc 100644 --- a/src/avatar/avatar.test.tsx +++ b/src/avatar/avatar.test.tsx @@ -331,6 +331,7 @@ describe('AvatarGroup', () => { '--reactist-avatar-group-size': '36px', '--reactist-avatar-group-overlap': '4px', '--reactist-avatar-group-mask': '2.5px', + '--reactist-avatar-group-rounded-mask-radius': 'calc(5px + 2.5px)', }) }) @@ -346,6 +347,7 @@ describe('AvatarGroup', () => { '--reactist-avatar-group-size': '80px', '--reactist-avatar-group-overlap': '8px', '--reactist-avatar-group-mask': '3px', + '--reactist-avatar-group-rounded-mask-radius': 'calc(10px + 3px)', }) }) diff --git a/src/avatar/avatar.tsx b/src/avatar/avatar.tsx index 54259d5b..72e67ad5 100644 --- a/src/avatar/avatar.tsx +++ b/src/avatar/avatar.tsx @@ -31,6 +31,7 @@ type AvatarGroupStyle = React.CSSProperties & { '--reactist-avatar-group-overlap': string '--reactist-avatar-group-mask': string '--reactist-avatar-group-rounded-radius': string + '--reactist-avatar-group-rounded-mask-radius': string } const AVATAR_GROUP_OVERLAP_BY_SIZE: Record = { @@ -328,11 +329,15 @@ function getAvatarStyle(size: AvatarSize): AvatarStyle { } function getAvatarGroupStyle(size: AvatarSize): AvatarGroupStyle { + const mask = AVATAR_GROUP_MASK_BY_SIZE[size] + const roundedRadius = ROUNDED_AVATAR_RADIUS_BY_SIZE[size] + return { '--reactist-avatar-group-size': `${size}px`, '--reactist-avatar-group-overlap': AVATAR_GROUP_OVERLAP_BY_SIZE[size], - '--reactist-avatar-group-mask': AVATAR_GROUP_MASK_BY_SIZE[size], - '--reactist-avatar-group-rounded-radius': ROUNDED_AVATAR_RADIUS_BY_SIZE[size], + '--reactist-avatar-group-mask': mask, + '--reactist-avatar-group-rounded-radius': roundedRadius, + '--reactist-avatar-group-rounded-mask-radius': `calc(${roundedRadius} + ${mask})`, } } From 565dda7851673503f4c68ab5154dd8f247617636 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Wed, 27 May 2026 14:00:00 -0500 Subject: [PATCH 03/16] feat: add AvatarPair --- src/avatar/avatar.mdx | 15 ++++ src/avatar/avatar.module.css | 89 +++++++++++++++++++++ src/avatar/avatar.stories.tsx | 77 +++++++++++++++++- src/avatar/avatar.test.tsx | 146 +++++++++++++++++++++++++++++++++- src/avatar/avatar.tsx | 122 +++++++++++++++++++++++++++- 5 files changed, 445 insertions(+), 4 deletions(-) diff --git a/src/avatar/avatar.mdx b/src/avatar/avatar.mdx index 4df23d1c..69b43151 100644 --- a/src/avatar/avatar.mdx +++ b/src/avatar/avatar.mdx @@ -33,6 +33,14 @@ is rendered as plain numeric text on top of the final avatar. +## Avatar pairs + +Use `AvatarPair` when a surface represents two related people or entities. Pass +the same `size` to the pair and its two direct `Avatar` children. The second +child is positioned diagonally above-left of the first child. + + + ## Migrating from the legacy API The previous Avatar API accepted `user`, `avatarUrl`, `colorList`, string or @@ -184,6 +192,13 @@ the component props instead of overriding them directly. --reactist-avatar-group-mask: 2.5px; --reactist-avatar-group-rounded-radius: 5px; } + +.avatarPair { + --reactist-avatar-pair-size: 28px; + --reactist-avatar-pair-spacing: 12px; + --reactist-avatar-pair-mask: 2px; + --reactist-avatar-pair-rounded-radius: 4px; +} ``` ## What the consumer owns diff --git a/src/avatar/avatar.module.css b/src/avatar/avatar.module.css index d8198fe8..9f84ff6e 100644 --- a/src/avatar/avatar.module.css +++ b/src/avatar/avatar.module.css @@ -338,3 +338,92 @@ -webkit-mask-image: none; mask-image: none; } + +.avatarPair { + --reactist-avatar-pair-size: 28px; + --reactist-avatar-pair-spacing: 12px; + --reactist-avatar-pair-mask: 2px; + --reactist-avatar-pair-rounded-radius: 5px; + --reactist-avatar-pair-rounded-mask-radius: 7px; + --reactist-avatar-pair-rounded-mask-start: calc( + var(--reactist-avatar-pair-spacing) - var(--reactist-avatar-pair-mask) + ); + --reactist-avatar-pair-first-center-x: calc( + (var(--reactist-avatar-pair-size) / 2) + var(--reactist-avatar-pair-spacing) + ); + --reactist-avatar-pair-first-center-y: calc( + (var(--reactist-avatar-pair-size) / 2) + var(--reactist-avatar-pair-spacing) + ); + + position: relative; + width: calc(var(--reactist-avatar-pair-size) + var(--reactist-avatar-pair-spacing)); + height: calc(var(--reactist-avatar-pair-size) + var(--reactist-avatar-pair-spacing)); +} + +.avatarPair > * { + position: absolute; +} + +.avatarPair > :first-child { + right: 0; + bottom: 0; +} + +.avatarPair > :last-child { + top: 0; + left: 0; +} + +.avatarPairShape-circle > :last-child { + -webkit-mask-image: radial-gradient( + circle calc((var(--reactist-avatar-pair-size) / 2) + var(--reactist-avatar-pair-mask)) at + var(--reactist-avatar-pair-first-center-x) var(--reactist-avatar-pair-first-center-y), + transparent 99%, + #000 100% + ); + mask-image: radial-gradient( + circle calc((var(--reactist-avatar-pair-size) / 2) + var(--reactist-avatar-pair-mask)) at + var(--reactist-avatar-pair-first-center-x) var(--reactist-avatar-pair-first-center-y), + transparent 99%, + #000 100% + ); +} + +.avatarPairShape-rounded > :last-child { + -webkit-mask-image: + linear-gradient(#000 0 0), linear-gradient(#000 0 0), + radial-gradient( + circle var(--reactist-avatar-pair-rounded-mask-radius) at 100% 100%, + transparent 99%, + #000 100% + ); + mask-image: + linear-gradient(#000 0 0), linear-gradient(#000 0 0), + radial-gradient( + circle var(--reactist-avatar-pair-rounded-mask-radius) at 100% 100%, + transparent 99%, + #000 100% + ); + -webkit-mask-position: + 0 0, + 0 0, + var(--reactist-avatar-pair-rounded-mask-start) + var(--reactist-avatar-pair-rounded-mask-start); + mask-position: + 0 0, + 0 0, + var(--reactist-avatar-pair-rounded-mask-start) + var(--reactist-avatar-pair-rounded-mask-start); + -webkit-mask-size: + 100% var(--reactist-avatar-pair-rounded-mask-start), + var(--reactist-avatar-pair-rounded-mask-start) 100%, + var(--reactist-avatar-pair-rounded-mask-radius) + var(--reactist-avatar-pair-rounded-mask-radius); + mask-size: + 100% var(--reactist-avatar-pair-rounded-mask-start), + var(--reactist-avatar-pair-rounded-mask-start) 100%, + var(--reactist-avatar-pair-rounded-mask-radius) + var(--reactist-avatar-pair-rounded-mask-radius); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; +} diff --git a/src/avatar/avatar.stories.tsx b/src/avatar/avatar.stories.tsx index 2f38b790..2000adf6 100644 --- a/src/avatar/avatar.stories.tsx +++ b/src/avatar/avatar.stories.tsx @@ -1,6 +1,6 @@ import * as React from 'react' -import { Avatar, AvatarGroup, Box, Inline, Stack, Text } from '../index' +import { Avatar, AvatarGroup, AvatarPair, Box, Inline, Stack, Text } from '../index' import { AVATAR_SIZES, getAvatarMetaColorIndex } from './utils' @@ -315,6 +315,81 @@ export const AvatarGroups = { ), } satisfies Story +export const AvatarPairs = { + render: () => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + {([80, 62, 50, 36, 28, 20, 16, 12] as const).map((size, index) => { + const firstContributor = getContributor(index)! + const secondContributor = getContributor(index + 1)! + + return ( + + + + + + + ) + })} + + + + ), +} satisfies Story + export const InitialsFallback = { render: () => ( diff --git a/src/avatar/avatar.test.tsx b/src/avatar/avatar.test.tsx index 05b2f8dc..07104d54 100644 --- a/src/avatar/avatar.test.tsx +++ b/src/avatar/avatar.test.tsx @@ -3,7 +3,7 @@ import * as React from 'react' import { fireEvent, render, screen } from '@testing-library/react' import { axe } from 'jest-axe' -import { Avatar, AvatarGroup } from './avatar' +import { Avatar, AvatarGroup, AvatarPair } from './avatar' describe('Avatar', () => { function failCurrentAvatarImage(currentSrc: string) { @@ -420,4 +420,148 @@ describe('AvatarGroup', () => { expect(screen.getByTestId('group')).toHaveClass('custom-group') }) + + it('can render as a button', () => { + render( + + + + , + ) + + expect(screen.getByRole('button', { name: 'Manage members' })).toBeVisible() + }) + + it('derives the root ref type from the element rendered with as', () => { + const anchorRef = React.createRef() + const buttonRef = React.createRef() + + render( + + + + , + ) + + expect(anchorRef.current).toBe(screen.getByTestId('group')) + + const invalidRefElement = ( + // @ts-expect-error refs must match the element selected with as + + + + + ) + expect(invalidRefElement).toBeTruthy() + }) +}) + +describe('AvatarPair', () => { + it('renders direct Avatar children without wrappers', () => { + render( + + + + , + ) + + expect(screen.getByTestId('pair')).toContainElement(screen.getByTestId('first')) + expect(screen.getByTestId('pair')).toContainElement(screen.getByTestId('second')) + expect(screen.getByTestId('first').parentElement).toBe(screen.getByTestId('pair')) + expect(screen.getByTestId('second').parentElement).toBe(screen.getByTestId('pair')) + }) + + it('sets size-derived pair variables', () => { + render( + + + + , + ) + + expect(screen.getByTestId('pair')).toHaveStyle({ + '--reactist-avatar-pair-size': '28px', + '--reactist-avatar-pair-spacing': '12px', + '--reactist-avatar-pair-mask': '2px', + '--reactist-avatar-pair-rounded-mask-radius': 'calc(5px + 2px)', + }) + }) + + it('sets large size-derived pair variables', () => { + render( + + + + , + ) + + expect(screen.getByTestId('pair')).toHaveStyle({ + '--reactist-avatar-pair-size': '80px', + '--reactist-avatar-pair-spacing': '36px', + '--reactist-avatar-pair-mask': '3px', + '--reactist-avatar-pair-rounded-mask-radius': 'calc(10px + 3px)', + }) + }) + + it('applies the pair shape class', () => { + render( + + + + , + ) + + expect(screen.getByTestId('pair')).toHaveClass('avatarPairShape-rounded') + }) + + it('applies the escape hatch class name', () => { + render( + + + + , + ) + + expect(screen.getByTestId('pair')).toHaveClass('custom-pair') + }) + + it('can render as a button', () => { + render( + + + + , + ) + + expect(screen.getByRole('button', { name: 'Open workspace pair' })).toBeVisible() + }) + + it('derives the root ref type from the element rendered with as', () => { + const anchorRef = React.createRef() + const buttonRef = React.createRef() + + render( + + + + , + ) + + expect(anchorRef.current).toBe(screen.getByTestId('pair')) + + const invalidRefElement = ( + // @ts-expect-error refs must match the element selected with as + + + + + ) + expect(invalidRefElement).toBeTruthy() + }) }) diff --git a/src/avatar/avatar.tsx b/src/avatar/avatar.tsx index 72e67ad5..5a667378 100644 --- a/src/avatar/avatar.tsx +++ b/src/avatar/avatar.tsx @@ -34,6 +34,14 @@ type AvatarGroupStyle = React.CSSProperties & { '--reactist-avatar-group-rounded-mask-radius': string } +type AvatarPairStyle = React.CSSProperties & { + '--reactist-avatar-pair-size': string + '--reactist-avatar-pair-spacing': string + '--reactist-avatar-pair-mask': string + '--reactist-avatar-pair-rounded-radius': string + '--reactist-avatar-pair-rounded-mask-radius': string +} + const AVATAR_GROUP_OVERLAP_BY_SIZE: Record = { 80: '8px', 72: '8px', @@ -66,6 +74,22 @@ const AVATAR_GROUP_MASK_BY_SIZE: Record = { 12: '1px', } +const AVATAR_PAIR_SPACING_BY_SIZE: Record = { + 80: '36px', + 72: '32px', + 62: '28px', + 50: '22px', + 40: '18px', + 36: '16px', + 30: '14px', + 28: '12px', + 24: '12px', + 20: '10px', + 18: '10px', + 16: '8px', + 12: '6px', +} + /** * Props for the `Avatar` component. */ @@ -172,6 +196,49 @@ type AvatarGroupProps = Polymor 'omitClassName' > +/** + * Props for the `AvatarPair` component. + */ +type AvatarPairOwnProps = ObfuscatedClassName & { + /** + * The rendered avatar size, in CSS pixels. + * + * Direct child Avatar components should use the same size. + */ + size: AvatarSize + + /** + * The paired avatar shape. + * + * Direct child Avatar components should use the same shape. + * + * @default 'circle' + */ + shape?: AvatarShape + + /** + * Paired Avatar children. + */ + children: React.ReactNode + + /** + * Test identifier applied to the avatar pair root element. + */ + 'data-testid'?: string + + /** + * AvatarPair owns its root sizing styles. Use `exceptionallySetClassName` for the styling + * escape hatch. + */ + style?: never +} + +type AvatarPairProps = PolymorphicComponentProps< + ComponentType, + AvatarPairOwnProps, + 'omitClassName' +> + const AvatarContent = polymorphicComponent<'div', AvatarOwnProps, 'omitClassName'>( function AvatarContent( { @@ -321,6 +388,44 @@ const AvatarGroup = polymorphicComponent<'div', AvatarGroupOwnProps, 'omitClassN }, ) +/** + * Displays two Avatar children with the second avatar positioned diagonally + * above-left of the first avatar. + */ +const AvatarPair = polymorphicComponent<'div', AvatarPairOwnProps, 'omitClassName'>( + function AvatarPair( + { + as, + size, + shape = 'circle', + children, + exceptionallySetClassName, + 'data-testid': testId, + ...restProps + }, + ref, + ) { + return ( + + {children} + + ) + }, +) + function getAvatarStyle(size: AvatarSize): AvatarStyle { return { '--reactist-avatar-size': `${size}px`, @@ -328,6 +433,19 @@ function getAvatarStyle(size: AvatarSize): AvatarStyle { } } +function getAvatarPairStyle(size: AvatarSize): AvatarPairStyle { + const mask = AVATAR_GROUP_MASK_BY_SIZE[size] + const roundedRadius = ROUNDED_AVATAR_RADIUS_BY_SIZE[size] + + return { + '--reactist-avatar-pair-size': `${size}px`, + '--reactist-avatar-pair-spacing': AVATAR_PAIR_SPACING_BY_SIZE[size], + '--reactist-avatar-pair-mask': mask, + '--reactist-avatar-pair-rounded-radius': roundedRadius, + '--reactist-avatar-pair-rounded-mask-radius': `calc(${roundedRadius} + ${mask})`, + } +} + function getAvatarGroupStyle(size: AvatarSize): AvatarGroupStyle { const mask = AVATAR_GROUP_MASK_BY_SIZE[size] const roundedRadius = ROUNDED_AVATAR_RADIUS_BY_SIZE[size] @@ -358,5 +476,5 @@ function getFailedImageSource(imageProps: ImageSources, image: HTMLImageElement) return matchingSource?.src ?? imageProps.src } -export { Avatar, AvatarGroup } -export type { AvatarGroupProps, AvatarImage, AvatarProps, AvatarShape, AvatarSize } +export { Avatar, AvatarGroup, AvatarPair } +export type { AvatarGroupProps, AvatarImage, AvatarPairProps, AvatarProps, AvatarShape, AvatarSize } From 1a26a042ebc0d61126a6aa4e648fc1c3a64805cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Wed, 27 May 2026 14:00:12 -0500 Subject: [PATCH 04/16] docs: add avatar collection stories --- src/avatar/avatar-group.stories.tsx | 309 ++++++++++++++++++++++++++++ src/avatar/avatar-pair.stories.tsx | 228 ++++++++++++++++++++ 2 files changed, 537 insertions(+) create mode 100644 src/avatar/avatar-group.stories.tsx create mode 100644 src/avatar/avatar-pair.stories.tsx diff --git a/src/avatar/avatar-group.stories.tsx b/src/avatar/avatar-group.stories.tsx new file mode 100644 index 00000000..edc4d145 --- /dev/null +++ b/src/avatar/avatar-group.stories.tsx @@ -0,0 +1,309 @@ +import * as React from 'react' + +import { Avatar, AvatarGroup, Box, Inline, Stack, Text } from '../index' + +import type { Meta, StoryObj } from '@storybook/react-vite' + +const contributors = [ + { name: 'pawel', githubUserId: '61894375' }, + { name: 'craig', githubUserId: '1305500' }, + { name: 'rui', githubUserId: '3165500' }, + { name: 'ricardo', githubUserId: '96476' }, + { name: 'scott', githubUserId: '25244878' }, + { name: 'francesca', githubUserId: '1509326' }, +] as const + +const workspaceNames = ['Reactist', 'Todoist', 'Twist', 'Doist'] as const + +function getContributor(index: number) { + return contributors[index % contributors.length] +} + +function getGithubAvatarUrl(githubUserId: string, width: number) { + return `https://avatars.githubusercontent.com/u/${githubUserId}?s=${width}` +} + +function getGithubSourceMap(githubUserId: string, width: number) { + return { + [width]: getGithubAvatarUrl(githubUserId, width), + [width * 2]: getGithubAvatarUrl(githubUserId, width * 2), + [width * 3]: getGithubAvatarUrl(githubUserId, width * 3), + } +} + +function StoryLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +function StorySection({ + title, + description, + children, +}: { + title: string + description?: string + children: React.ReactNode +}) { + return ( + + + {title} + {description ? ( + + {description} + + ) : null} + + {children} + + ) +} + +function AvatarExample({ label, children }: { label: string; children: React.ReactNode }) { + return ( + + + {children} + + {label} + + + + ) +} + +function UserAvatar({ + contributor, + size, +}: { + contributor: (typeof contributors)[number] + size: React.ComponentProps['size'] +}) { + return ( + + ) +} + +function WorkspaceAvatar({ + name, + size, +}: { + name: string + size: React.ComponentProps['size'] +}) { + return +} + +function CustomOverlayStyle() { + return ( + + ) +} + +const meta = { + title: 'Components/Avatar/AvatarGroup', + component: AvatarGroup, + parameters: { + badges: ['accessible'], + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const People = { + render: () => ( + + + + + + {contributors.slice(0, 5).map((contributor) => ( + + ))} + + + + + {contributors.slice(1, 4).map((contributor) => ( + + ))} + + + + + {contributors.slice(2, 5).map((contributor) => ( + + ))} + + + + + + ), +} satisfies Story + +export const Workspaces = { + render: () => ( + + + + + + {workspaceNames.map((name) => ( + + ))} + + + + + + + + + + + {workspaceNames.slice(0, 3).map((name) => ( + + ))} + + + + + + ), +} satisfies Story + +export const Sizes = { + render: () => ( + + + + {([80, 62, 50, 36, 24, 18, 12] as const).map((size, index) => ( + + + {[0, 1, 2].map((offset) => ( + + ))} + + + ))} + + + + + + {([80, 62, 50, 36, 24, 18, 12] as const).map((size, index) => ( + + + {[0, 1, 2].map((offset) => ( + + ))} + + + ))} + + + + ), +} satisfies Story + +export const CountOverlay = { + render: () => ( + + + + + + + + {contributors.slice(0, 4).map((contributor) => ( + + ))} + + + + + {contributors.slice(1, 5).map((contributor) => ( + + ))} + + + + + {workspaceNames.map((name) => ( + + ))} + + + + + + ), +} satisfies Story diff --git a/src/avatar/avatar-pair.stories.tsx b/src/avatar/avatar-pair.stories.tsx new file mode 100644 index 00000000..9c646381 --- /dev/null +++ b/src/avatar/avatar-pair.stories.tsx @@ -0,0 +1,228 @@ +import * as React from 'react' + +import { Avatar, AvatarPair, Box, Inline, Stack, Text } from '../index' + +import type { Meta, StoryObj } from '@storybook/react-vite' + +const contributors = [ + { name: 'pawel', githubUserId: '61894375' }, + { name: 'craig', githubUserId: '1305500' }, + { name: 'rui', githubUserId: '3165500' }, + { name: 'ricardo', githubUserId: '96476' }, + { name: 'scott', githubUserId: '25244878' }, + { name: 'francesca', githubUserId: '1509326' }, +] as const + +const workspaceNames = ['Reactist', 'Todoist', 'Twist', 'Doist'] as const + +function getContributor(index: number) { + return contributors[index % contributors.length] +} + +function getGithubAvatarUrl(githubUserId: string, width: number) { + return `https://avatars.githubusercontent.com/u/${githubUserId}?s=${width}` +} + +function getGithubSourceMap(githubUserId: string, width: number) { + return { + [width]: getGithubAvatarUrl(githubUserId, width), + [width * 2]: getGithubAvatarUrl(githubUserId, width * 2), + [width * 3]: getGithubAvatarUrl(githubUserId, width * 3), + } +} + +function StoryLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +function StorySection({ + title, + description, + children, +}: { + title: string + description?: string + children: React.ReactNode +}) { + return ( + + + {title} + {description ? ( + + {description} + + ) : null} + + {children} + + ) +} + +function AvatarExample({ label, children }: { label: string; children: React.ReactNode }) { + return ( + + + {children} + + {label} + + + + ) +} + +function UserAvatar({ + contributor, + size, +}: { + contributor: (typeof contributors)[number] + size: React.ComponentProps['size'] +}) { + return ( + + ) +} + +function WorkspaceAvatar({ + name, + size, +}: { + name: string + size: React.ComponentProps['size'] +}) { + return +} + +const meta = { + title: 'Components/Avatar/AvatarPair', + component: AvatarPair, + parameters: { + badges: ['accessible'], + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const People = { + render: () => ( + + + + + + + + + + + + + + + + + + + + + + + + + ), +} satisfies Story + +export const Workspaces = { + render: () => ( + + + + + + + + + + + + + + + + + + + + + + + + + ), +} satisfies Story + +export const Sizes = { + render: () => ( + + + + {([80, 62, 50, 36, 28, 20, 16, 12] as const).map((size, index) => ( + + + + + + + ))} + + + + + + {([80, 62, 50, 36, 28, 20, 16, 12] as const).map((size, index) => ( + + + + + + + ))} + + + + ), +} satisfies Story From ec93099aef94d0969556196acb234c584307f715 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Wed, 27 May 2026 14:02:50 -0500 Subject: [PATCH 05/16] fix: render rounded avatar group masks directly --- src/avatar/avatar.module.css | 60 +++++++++++++++--------------------- 1 file changed, 24 insertions(+), 36 deletions(-) diff --git a/src/avatar/avatar.module.css b/src/avatar/avatar.module.css index 9f84ff6e..05f5a938 100644 --- a/src/avatar/avatar.module.css +++ b/src/avatar/avatar.module.css @@ -242,60 +242,48 @@ circle var(--reactist-avatar-group-rounded-mask-radius) at var(--reactist-avatar-group-rounded-mask-corner-x) var(--reactist-avatar-group-rounded-radius), - #000 99%, - transparent 100% + transparent 99%, + #000 100% ), radial-gradient( circle var(--reactist-avatar-group-rounded-mask-radius) at - var(--reactist-avatar-group-rounded-mask-corner-x) - calc(100% - var(--reactist-avatar-group-rounded-radius)), - #000 99%, - transparent 100% - ), - linear-gradient(#000 0 0); + var(--reactist-avatar-group-rounded-mask-corner-x) 0, + transparent 99%, + #000 100% + ); mask-image: linear-gradient(#000 0 0), radial-gradient( circle var(--reactist-avatar-group-rounded-mask-radius) at var(--reactist-avatar-group-rounded-mask-corner-x) var(--reactist-avatar-group-rounded-radius), - #000 99%, - transparent 100% + transparent 99%, + #000 100% ), radial-gradient( circle var(--reactist-avatar-group-rounded-mask-radius) at - var(--reactist-avatar-group-rounded-mask-corner-x) - calc(100% - var(--reactist-avatar-group-rounded-radius)), - #000 99%, - transparent 100% - ), - linear-gradient(#000 0 0); + var(--reactist-avatar-group-rounded-mask-corner-x) 0, + transparent 99%, + #000 100% + ); -webkit-mask-position: - left center, - 0 0, - 0 0, - 0 0; + right top, + left top, + left bottom; mask-position: - left center, - 0 0, - 0 0, - 0 0; + right top, + left top, + left bottom; -webkit-mask-size: - var(--reactist-avatar-group-rounded-mask-width) - calc(100% - (2 * var(--reactist-avatar-group-rounded-radius))), - 100% 100%, - 100% 100%, - 100% 100%; + calc(100% - var(--reactist-avatar-group-rounded-mask-width)) 100%, + var(--reactist-avatar-group-rounded-mask-width) var(--reactist-avatar-group-rounded-radius), + var(--reactist-avatar-group-rounded-mask-width) var(--reactist-avatar-group-rounded-radius); mask-size: - var(--reactist-avatar-group-rounded-mask-width) - calc(100% - (2 * var(--reactist-avatar-group-rounded-radius))), - 100% 100%, - 100% 100%, - 100% 100%; + calc(100% - var(--reactist-avatar-group-rounded-mask-width)) 100%, + var(--reactist-avatar-group-rounded-mask-width) var(--reactist-avatar-group-rounded-radius), + var(--reactist-avatar-group-rounded-mask-width) var(--reactist-avatar-group-rounded-radius); -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat; - -webkit-mask-composite: destination-out, destination-out, destination-out; - mask-composite: subtract; } .avatarGroup[data-count]::after { From f6afa36f4ab316af599aee326878d1aa0a5f8de6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Wed, 27 May 2026 14:29:32 -0500 Subject: [PATCH 06/16] fix: Apply rounded mask corectly --- src/avatar/avatar.module.css | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/src/avatar/avatar.module.css b/src/avatar/avatar.module.css index 05f5a938..7168993f 100644 --- a/src/avatar/avatar.module.css +++ b/src/avatar/avatar.module.css @@ -219,7 +219,8 @@ margin-left: calc(-1 * var(--reactist-avatar-group-overlap)); } -.avatarGroupShape-circle > * + * { +.avatarGroupShape-circle > * + *, +.avatarGroupShape-circle[data-count]::after { -webkit-mask-image: radial-gradient( circle calc((var(--reactist-avatar-group-size) / 2) + var(--reactist-avatar-group-mask)) at var(--reactist-avatar-group-previous-center-x) 50%, @@ -304,18 +305,6 @@ line-height: 1; pointer-events: none; user-select: none; - -webkit-mask-image: radial-gradient( - circle calc((var(--reactist-avatar-group-size) / 2) + var(--reactist-avatar-group-mask)) at - var(--reactist-avatar-group-previous-center-x) 50%, - transparent 99%, - #000 100% - ); - mask-image: radial-gradient( - circle calc((var(--reactist-avatar-group-size) / 2) + var(--reactist-avatar-group-mask)) at - var(--reactist-avatar-group-previous-center-x) 50%, - transparent 99%, - #000 100% - ); } .avatarGroupShape-rounded[data-count]::after { From a0c7ff9373dc493d380a0665def0f3a6aa7e0915 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Wed, 27 May 2026 14:59:19 -0500 Subject: [PATCH 07/16] fix: render avatar group count as DOM element Replace ::after pseudo with a real span so the count is in the DOM (better SR support, queryable in tests). Drop data-count attribute; use class-based selectors. Visual now reads "+N" instead of bare N. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/avatar/avatar.module.css | 13 +++++-------- src/avatar/avatar.test.tsx | 23 +++++++++++++++++------ src/avatar/avatar.tsx | 6 ++++-- 3 files changed, 26 insertions(+), 16 deletions(-) diff --git a/src/avatar/avatar.module.css b/src/avatar/avatar.module.css index 7168993f..d51d15ec 100644 --- a/src/avatar/avatar.module.css +++ b/src/avatar/avatar.module.css @@ -219,8 +219,7 @@ margin-left: calc(-1 * var(--reactist-avatar-group-overlap)); } -.avatarGroupShape-circle > * + *, -.avatarGroupShape-circle[data-count]::after { +.avatarGroupShape-circle > * + * { -webkit-mask-image: radial-gradient( circle calc((var(--reactist-avatar-group-size) / 2) + var(--reactist-avatar-group-mask)) at var(--reactist-avatar-group-previous-center-x) 50%, @@ -235,8 +234,7 @@ ); } -.avatarGroupShape-rounded > * + *, -.avatarGroupShape-rounded[data-count]::after { +.avatarGroupShape-rounded > * + * { -webkit-mask-image: linear-gradient(#000 0 0), radial-gradient( @@ -287,8 +285,7 @@ mask-repeat: no-repeat; } -.avatarGroup[data-count]::after { - content: attr(data-count); +.avatarGroupCount { position: absolute; top: 0; right: 0; @@ -307,11 +304,11 @@ user-select: none; } -.avatarGroupShape-rounded[data-count]::after { +.avatarGroupShape-rounded > .avatarGroupCount { border-radius: var(--reactist-avatar-group-rounded-radius); } -.avatarGroup[data-count]:has(> :first-child:last-child)::after { +.avatarGroup > .avatarGroupCount:nth-child(2) { -webkit-mask-image: none; mask-image: none; } diff --git a/src/avatar/avatar.test.tsx b/src/avatar/avatar.test.tsx index 07104d54..4f93fe05 100644 --- a/src/avatar/avatar.test.tsx +++ b/src/avatar/avatar.test.tsx @@ -351,7 +351,7 @@ describe('AvatarGroup', () => { }) }) - it('exposes positive count through data-count', () => { + it('renders the count overlay when count is positive', () => { render( @@ -359,10 +359,10 @@ describe('AvatarGroup', () => { , ) - expect(screen.getByTestId('group')).toHaveAttribute('data-count', '3') + expect(screen.getByText('+3')).toBeInTheDocument() }) - it('omits data-count when count is not positive', () => { + it('omits the count overlay when count is not positive', () => { render( @@ -370,10 +370,10 @@ describe('AvatarGroup', () => { , ) - expect(screen.getByTestId('group')).not.toHaveAttribute('data-count') + expect(screen.queryByText(/^\+/)).not.toBeInTheDocument() }) - it('omits data-count when count is not provided', () => { + it('omits the count overlay when count is not provided', () => { render( @@ -381,7 +381,18 @@ describe('AvatarGroup', () => { , ) - expect(screen.getByTestId('group')).not.toHaveAttribute('data-count') + expect(screen.queryByText(/^\+/)).not.toBeInTheDocument() + }) + + it('renders the count overlay alongside a single avatar', () => { + render( + + + , + ) + + expect(screen.getByText('+4')).toBeInTheDocument() + expect(screen.getByRole('img', { name: 'Jane Doe' })).toBeInTheDocument() }) it('leaves the count overlay custom property available for CSS customization', () => { diff --git a/src/avatar/avatar.tsx b/src/avatar/avatar.tsx index 5a667378..8b05ae19 100644 --- a/src/avatar/avatar.tsx +++ b/src/avatar/avatar.tsx @@ -363,7 +363,7 @@ const AvatarGroup = polymorphicComponent<'div', AvatarGroupOwnProps, 'omitClassN }, ref, ) { - const countAttribute = count != null && count > 0 ? String(count) : undefined + const overflowCount = count != null && count > 0 ? count : null return ( {children} + {overflowCount !== null ? ( + {`+${overflowCount}`} + ) : null} ) }, From 1d37441136ea0b0e772a9093e75b72d5187b9b1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Wed, 27 May 2026 14:59:50 -0500 Subject: [PATCH 08/16] test: cover avatar group and pair with axe Adds describe('a11y') blocks for AvatarGroup and AvatarPair, mirroring Avatar's existing coverage as required by the per-component a11y convention. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/avatar/avatar.test.tsx | 52 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/src/avatar/avatar.test.tsx b/src/avatar/avatar.test.tsx index 4f93fe05..7ea68a06 100644 --- a/src/avatar/avatar.test.tsx +++ b/src/avatar/avatar.test.tsx @@ -465,6 +465,34 @@ describe('AvatarGroup', () => { ) expect(invalidRefElement).toBeTruthy() }) + + describe('a11y', () => { + it('renders with no a11y violations', async () => { + const { container } = render( + <> + + + + + + + + + + + + + + + + + , + ) + const results = await axe(container) + + expect(results).toHaveNoViolations() + }) + }) }) describe('AvatarPair', () => { @@ -575,4 +603,28 @@ describe('AvatarPair', () => { ) expect(invalidRefElement).toBeTruthy() }) + + describe('a11y', () => { + it('renders with no a11y violations', async () => { + const { container } = render( + <> + + + + + + + + + + + + + , + ) + const results = await axe(container) + + expect(results).toHaveNoViolations() + }) + }) }) From b4a72eeccef03aab2fd665de0257e1a2e8100a19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Wed, 27 May 2026 15:00:11 -0500 Subject: [PATCH 09/16] docs: clarify avatar group count and pair child contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AvatarPair JSDoc spells out "exactly two children" and the foreground vs. overlay roles - mdx mirrors that, notes the count renders as +N, and points consumers at aria-label on the group root when it's a labeled entity - corrects stale --reactist-avatar-pair-rounded-radius default (4px → 5px) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/avatar/avatar.mdx | 11 +++++++---- src/avatar/avatar.tsx | 4 +++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/avatar/avatar.mdx b/src/avatar/avatar.mdx index 69b43151..669f5f60 100644 --- a/src/avatar/avatar.mdx +++ b/src/avatar/avatar.mdx @@ -29,15 +29,18 @@ and the deterministic meta color used when initials render. Use `AvatarGroup` when a compact surface represents several people. Pass the same `size` to the group and its direct `Avatar` children. The optional `count` -is rendered as plain numeric text on top of the final avatar. +is rendered as `+N` text on top of the final avatar. When the group represents +a labeled entity (a button, link, or labeled region), supply an +`aria-label` that conveys the count to assistive tech. ## Avatar pairs Use `AvatarPair` when a surface represents two related people or entities. Pass -the same `size` to the pair and its two direct `Avatar` children. The second -child is positioned diagonally above-left of the first child. +the same `size` to the pair and its exactly two direct `Avatar` children. The +first child is the foreground avatar (positioned bottom-right); the second is +positioned diagonally above-left of the first child. @@ -197,7 +200,7 @@ the component props instead of overriding them directly. --reactist-avatar-pair-size: 28px; --reactist-avatar-pair-spacing: 12px; --reactist-avatar-pair-mask: 2px; - --reactist-avatar-pair-rounded-radius: 4px; + --reactist-avatar-pair-rounded-radius: 5px; } ``` diff --git a/src/avatar/avatar.tsx b/src/avatar/avatar.tsx index 8b05ae19..bea297c6 100644 --- a/src/avatar/avatar.tsx +++ b/src/avatar/avatar.tsx @@ -217,7 +217,9 @@ type AvatarPairOwnProps = ObfuscatedClassName & { shape?: AvatarShape /** - * Paired Avatar children. + * Exactly two paired Avatar children. The first child is the foreground + * avatar (positioned bottom-right); the second is the diagonal overlay + * (positioned top-left, masked where it overlaps the first). */ children: React.ReactNode From f42e9c25dc9103e27388fe46f7f74eb9e393ba44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Wed, 27 May 2026 15:00:24 -0500 Subject: [PATCH 10/16] refactor: drop non-null assertions in avatar collection stories Tighten getContributor / getWorkspaceName return types and extract the workspace helper so callers don't need ! everywhere. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/avatar/avatar-group.stories.tsx | 18 +++++++++--------- src/avatar/avatar-pair.stories.tsx | 22 ++++++++++------------ 2 files changed, 19 insertions(+), 21 deletions(-) diff --git a/src/avatar/avatar-group.stories.tsx b/src/avatar/avatar-group.stories.tsx index edc4d145..0247c474 100644 --- a/src/avatar/avatar-group.stories.tsx +++ b/src/avatar/avatar-group.stories.tsx @@ -15,8 +15,12 @@ const contributors = [ const workspaceNames = ['Reactist', 'Todoist', 'Twist', 'Doist'] as const -function getContributor(index: number) { - return contributors[index % contributors.length] +function getContributor(index: number): (typeof contributors)[number] { + return contributors[index % contributors.length]! +} + +function getWorkspaceName(index: number): (typeof workspaceNames)[number] { + return workspaceNames[index % workspaceNames.length]! } function getGithubAvatarUrl(githubUserId: string, width: number) { @@ -220,8 +224,8 @@ export const Sizes = { {[0, 1, 2].map((offset) => ( ))} @@ -243,11 +247,7 @@ export const Sizes = { ))} diff --git a/src/avatar/avatar-pair.stories.tsx b/src/avatar/avatar-pair.stories.tsx index 9c646381..b636471c 100644 --- a/src/avatar/avatar-pair.stories.tsx +++ b/src/avatar/avatar-pair.stories.tsx @@ -15,8 +15,12 @@ const contributors = [ const workspaceNames = ['Reactist', 'Todoist', 'Twist', 'Doist'] as const -function getContributor(index: number) { - return contributors[index % contributors.length] +function getContributor(index: number): (typeof contributors)[number] { + return contributors[index % contributors.length]! +} + +function getWorkspaceName(index: number): (typeof workspaceNames)[number] { + return workspaceNames[index % workspaceNames.length]! } function getGithubAvatarUrl(githubUserId: string, width: number) { @@ -194,8 +198,8 @@ export const Sizes = { {([80, 62, 50, 36, 28, 20, 16, 12] as const).map((size, index) => ( - - + + ))} @@ -210,14 +214,8 @@ export const Sizes = { {([80, 62, 50, 36, 28, 20, 16, 12] as const).map((size, index) => ( - - + + ))} From 8074d4bca82ed2b27fb9ee7cb86e6315dcd9b833 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Wed, 27 May 2026 19:04:30 -0500 Subject: [PATCH 11/16] fix: hide avatar group count overlay from assistive tech Count "+N" overlay is purely visual; mark aria-hidden so screen readers don't announce the literal "+3" after the named avatars. Group-level aria-label remains the way to convey count semantically. --- src/avatar/avatar.mdx | 7 ++++--- src/avatar/avatar.test.tsx | 11 +++++++++++ src/avatar/avatar.tsx | 4 +++- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/avatar/avatar.mdx b/src/avatar/avatar.mdx index 669f5f60..1903c052 100644 --- a/src/avatar/avatar.mdx +++ b/src/avatar/avatar.mdx @@ -29,9 +29,10 @@ and the deterministic meta color used when initials render. Use `AvatarGroup` when a compact surface represents several people. Pass the same `size` to the group and its direct `Avatar` children. The optional `count` -is rendered as `+N` text on top of the final avatar. When the group represents -a labeled entity (a button, link, or labeled region), supply an -`aria-label` that conveys the count to assistive tech. +is rendered as a decorative `+N` visual on top of the final avatar and is +hidden from assistive tech. When the group represents a labeled entity (a +button, link, or labeled region), supply an `aria-label` on the group that +conveys the count to assistive tech. diff --git a/src/avatar/avatar.test.tsx b/src/avatar/avatar.test.tsx index 7ea68a06..910156a3 100644 --- a/src/avatar/avatar.test.tsx +++ b/src/avatar/avatar.test.tsx @@ -362,6 +362,17 @@ describe('AvatarGroup', () => { expect(screen.getByText('+3')).toBeInTheDocument() }) + it('hides the count overlay from assistive tech', () => { + render( + + + + , + ) + + expect(screen.getByText('+3')).toHaveAttribute('aria-hidden', 'true') + }) + it('omits the count overlay when count is not positive', () => { render( diff --git a/src/avatar/avatar.tsx b/src/avatar/avatar.tsx index bea297c6..6f414208 100644 --- a/src/avatar/avatar.tsx +++ b/src/avatar/avatar.tsx @@ -385,7 +385,9 @@ const AvatarGroup = polymorphicComponent<'div', AvatarGroupOwnProps, 'omitClassN > {children} {overflowCount !== null ? ( - {`+${overflowCount}`} + ) : null} ) From 7f6e27edea1db423ffb70f6c0585659093586467 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Wed, 27 May 2026 19:07:10 -0500 Subject: [PATCH 12/16] fix: enforce exactly two AvatarPair children at the type level Tighten children type to readonly [ReactElement, ReactElement]. Catches both wrong-count and conditional-false cases that would otherwise break the :first-child/:last-child positioning rules. --- src/avatar/avatar.test.tsx | 10 ++++++++++ src/avatar/avatar.tsx | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/avatar/avatar.test.tsx b/src/avatar/avatar.test.tsx index 910156a3..9673ac51 100644 --- a/src/avatar/avatar.test.tsx +++ b/src/avatar/avatar.test.tsx @@ -564,6 +564,16 @@ describe('AvatarPair', () => { expect(screen.getByTestId('pair')).toHaveClass('avatarPairShape-rounded') }) + it('requires exactly two children at the type level', () => { + const invalidPair = ( + // @ts-expect-error AvatarPair children must be a tuple of two elements + + + + ) + expect(invalidPair).toBeTruthy() + }) + it('applies the escape hatch class name', () => { render( diff --git a/src/avatar/avatar.tsx b/src/avatar/avatar.tsx index 6f414208..da0ebd69 100644 --- a/src/avatar/avatar.tsx +++ b/src/avatar/avatar.tsx @@ -221,7 +221,7 @@ type AvatarPairOwnProps = ObfuscatedClassName & { * avatar (positioned bottom-right); the second is the diagonal overlay * (positioned top-left, masked where it overlaps the first). */ - children: React.ReactNode + children: readonly [React.ReactElement, React.ReactElement] /** * Test identifier applied to the avatar pair root element. From 7c6245c8cf5aa0ca2c867e52634514b39c9af08a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Wed, 27 May 2026 19:07:35 -0500 Subject: [PATCH 13/16] refactor: suppress avatar group count overlay mask unconditionally The overlay is a sibling of the avatars and inherits the sibling mask that visually notches each avatar onto the previous one. Suppress it on .avatarGroupCount directly instead of via :nth-child(2), so the rule holds regardless of how many avatars the group contains. --- src/avatar/avatar.module.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/avatar/avatar.module.css b/src/avatar/avatar.module.css index d51d15ec..d38b23d4 100644 --- a/src/avatar/avatar.module.css +++ b/src/avatar/avatar.module.css @@ -308,7 +308,7 @@ border-radius: var(--reactist-avatar-group-rounded-radius); } -.avatarGroup > .avatarGroupCount:nth-child(2) { +.avatarGroup > .avatarGroupCount { -webkit-mask-image: none; mask-image: none; } From 94660e9bef5d0689a61c106601a5c36235927d51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Wed, 27 May 2026 19:08:05 -0500 Subject: [PATCH 14/16] refactor: rename AVATAR_GROUP_MASK_BY_SIZE to AVATAR_MASK_BY_SIZE Used by both AvatarGroup and AvatarPair; the group-specific name was misleading. --- src/avatar/avatar.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/avatar/avatar.tsx b/src/avatar/avatar.tsx index da0ebd69..b11aacc2 100644 --- a/src/avatar/avatar.tsx +++ b/src/avatar/avatar.tsx @@ -58,7 +58,7 @@ const AVATAR_GROUP_OVERLAP_BY_SIZE: Record = { 12: '1px', } -const AVATAR_GROUP_MASK_BY_SIZE: Record = { +const AVATAR_MASK_BY_SIZE: Record = { 80: '3px', 72: '3px', 62: '3px', @@ -440,7 +440,7 @@ function getAvatarStyle(size: AvatarSize): AvatarStyle { } function getAvatarPairStyle(size: AvatarSize): AvatarPairStyle { - const mask = AVATAR_GROUP_MASK_BY_SIZE[size] + const mask = AVATAR_MASK_BY_SIZE[size] const roundedRadius = ROUNDED_AVATAR_RADIUS_BY_SIZE[size] return { @@ -453,7 +453,7 @@ function getAvatarPairStyle(size: AvatarSize): AvatarPairStyle { } function getAvatarGroupStyle(size: AvatarSize): AvatarGroupStyle { - const mask = AVATAR_GROUP_MASK_BY_SIZE[size] + const mask = AVATAR_MASK_BY_SIZE[size] const roundedRadius = ROUNDED_AVATAR_RADIUS_BY_SIZE[size] return { From 7f25731003b9272bf1a1ed258241feb994f29767 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Wed, 27 May 2026 19:08:37 -0500 Subject: [PATCH 15/16] refactor: simplify avatar group overflow count expression count is number | undefined; the explicit != null guard was redundant with the > 0 check. --- src/avatar/avatar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/avatar/avatar.tsx b/src/avatar/avatar.tsx index b11aacc2..161d1672 100644 --- a/src/avatar/avatar.tsx +++ b/src/avatar/avatar.tsx @@ -365,7 +365,7 @@ const AvatarGroup = polymorphicComponent<'div', AvatarGroupOwnProps, 'omitClassN }, ref, ) { - const overflowCount = count != null && count > 0 ? count : null + const overflowCount = count && count > 0 ? count : null return ( Date: Wed, 27 May 2026 19:08:55 -0500 Subject: [PATCH 16/16] docs: clarify AvatarGroup count is a decorative overlay The count is rendered as a separate +N sibling, not "represented by the final avatar" as the previous JSDoc suggested. Also notes that the overlay is aria-hidden. --- src/avatar/avatar.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/avatar/avatar.tsx b/src/avatar/avatar.tsx index 161d1672..945f8160 100644 --- a/src/avatar/avatar.tsx +++ b/src/avatar/avatar.tsx @@ -169,7 +169,9 @@ type AvatarGroupOwnProps = ObfuscatedClassName & { shape?: AvatarShape /** - * The number of additional people represented by the final avatar. + * Additional people not shown in the group. When positive, rendered as a + * decorative `+N` overlay on top of the final avatar; hidden from + * assistive tech. */ count?: number