diff --git a/src/avatar/avatar-pair.module.css b/src/avatar/avatar-pair.module.css
new file mode 100644
index 00000000..bb64c245
--- /dev/null
+++ b/src/avatar/avatar-pair.module.css
@@ -0,0 +1,229 @@
+/*
+ * AvatarPair renders two stacked Avatars:
+ * - First child (back): anchored bottom-right.
+ * - Last child (front): anchored top-left, painted on top.
+ *
+ * To make the back avatar read as a distinct element on *any* background, we
+ * cut a transparent hole through the front avatar wherever the back avatar
+ * peeks through, with a small `--mask-thickness` gap around it. Masks (not
+ * borders or box-shadow) are used because the parent background is unknown —
+ * masks make the region truly transparent, so whatever sits behind the pair
+ * shows through.
+ *
+ * Sizing is driven entirely by CSS variables. The `.avatarPairSize-{N}`
+ * classes (applied from JS) override the defaults per avatar size.
+ */
+
+.avatarPair {
+ /* Inputs (overridden per size by .avatarPairSize-{N}) */
+ --reactist-avatar-pair-size: 28px;
+ --reactist-avatar-pair-spacing: 12px;
+ --reactist-avatar-pair-mask-thickness: 2px;
+ --reactist-avatar-pair-rounded-radius: 5px;
+
+ /* Derived (CSS-internal — not part of the JS contract) */
+ --reactist-avatar-pair-rounded-mask-radius: calc(
+ var(--reactist-avatar-pair-rounded-radius) + var(--reactist-avatar-pair-mask-thickness)
+ );
+ --reactist-avatar-pair-rounded-mask-start: calc(
+ var(--reactist-avatar-pair-spacing) - var(--reactist-avatar-pair-mask-thickness)
+ );
+ --reactist-avatar-pair-first-center: 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));
+}
+
+.avatarPairSize-80 {
+ --reactist-avatar-pair-size: 80px;
+ --reactist-avatar-pair-spacing: 36px;
+ --reactist-avatar-pair-mask-thickness: 3px;
+ --reactist-avatar-pair-rounded-radius: 10px;
+}
+
+.avatarPairSize-72 {
+ --reactist-avatar-pair-size: 72px;
+ --reactist-avatar-pair-spacing: 32px;
+ --reactist-avatar-pair-mask-thickness: 3px;
+ --reactist-avatar-pair-rounded-radius: 10px;
+}
+
+.avatarPairSize-62 {
+ --reactist-avatar-pair-size: 62px;
+ --reactist-avatar-pair-spacing: 28px;
+ --reactist-avatar-pair-mask-thickness: 3px;
+ --reactist-avatar-pair-rounded-radius: 8.5px;
+}
+
+.avatarPairSize-50 {
+ --reactist-avatar-pair-size: 50px;
+ --reactist-avatar-pair-spacing: 22px;
+ --reactist-avatar-pair-mask-thickness: 3px;
+ --reactist-avatar-pair-rounded-radius: 7px;
+}
+
+.avatarPairSize-40 {
+ --reactist-avatar-pair-size: 40px;
+ --reactist-avatar-pair-spacing: 18px;
+ --reactist-avatar-pair-mask-thickness: 3px;
+ --reactist-avatar-pair-rounded-radius: 5.5px;
+}
+
+.avatarPairSize-36 {
+ --reactist-avatar-pair-size: 36px;
+ --reactist-avatar-pair-spacing: 16px;
+ --reactist-avatar-pair-mask-thickness: 2.5px;
+ --reactist-avatar-pair-rounded-radius: 5px;
+}
+
+.avatarPairSize-30 {
+ --reactist-avatar-pair-size: 30px;
+ --reactist-avatar-pair-spacing: 14px;
+ --reactist-avatar-pair-mask-thickness: 2.5px;
+ --reactist-avatar-pair-rounded-radius: 5px;
+}
+
+.avatarPairSize-28 {
+ --reactist-avatar-pair-size: 28px;
+ --reactist-avatar-pair-spacing: 12px;
+ --reactist-avatar-pair-mask-thickness: 2px;
+ --reactist-avatar-pair-rounded-radius: 5px;
+}
+
+.avatarPairSize-24 {
+ --reactist-avatar-pair-size: 24px;
+ --reactist-avatar-pair-spacing: 12px;
+ --reactist-avatar-pair-mask-thickness: 2px;
+ --reactist-avatar-pair-rounded-radius: 3.2px;
+}
+
+.avatarPairSize-20 {
+ --reactist-avatar-pair-size: 20px;
+ --reactist-avatar-pair-spacing: 10px;
+ --reactist-avatar-pair-mask-thickness: 2px;
+ --reactist-avatar-pair-rounded-radius: 3px;
+}
+
+.avatarPairSize-18 {
+ --reactist-avatar-pair-size: 18px;
+ --reactist-avatar-pair-spacing: 10px;
+ --reactist-avatar-pair-mask-thickness: 1.5px;
+ --reactist-avatar-pair-rounded-radius: 3px;
+}
+
+.avatarPairSize-16 {
+ --reactist-avatar-pair-size: 16px;
+ --reactist-avatar-pair-spacing: 8px;
+ --reactist-avatar-pair-mask-thickness: 1.25px;
+ --reactist-avatar-pair-rounded-radius: 2px;
+}
+
+.avatarPairSize-12 {
+ --reactist-avatar-pair-size: 12px;
+ --reactist-avatar-pair-spacing: 6px;
+ --reactist-avatar-pair-mask-thickness: 1px;
+ --reactist-avatar-pair-rounded-radius: 1.6px;
+}
+
+.avatarPair > * {
+ position: absolute;
+}
+
+/* Back avatar: anchored bottom-right, painted underneath. */
+.avatarPair > :first-child {
+ right: 0;
+ bottom: 0;
+}
+
+/* Front avatar: anchored top-left, painted on top — receives the mask cutout. */
+.avatarPair > :last-child {
+ top: 0;
+ left: 0;
+}
+
+/*
+ * Circle shape: a single radial-gradient cuts a circular hole centered on
+ * the back avatar, sized to leave a `--mask-thickness` gap around it.
+ */
+.avatarPairShape-circle > :last-child {
+ -webkit-mask-image: radial-gradient(
+ circle
+ calc(
+ (var(--reactist-avatar-pair-size) / 2) + var(--reactist-avatar-pair-mask-thickness)
+ )
+ at var(--reactist-avatar-pair-first-center) var(--reactist-avatar-pair-first-center),
+ transparent 99%,
+ #000 100%
+ );
+ mask-image: radial-gradient(
+ circle
+ calc(
+ (var(--reactist-avatar-pair-size) / 2) + var(--reactist-avatar-pair-mask-thickness)
+ )
+ at var(--reactist-avatar-pair-first-center) var(--reactist-avatar-pair-first-center),
+ transparent 99%,
+ #000 100%
+ );
+}
+
+/*
+ * Rounded shape: CSS has no built-in "rounded-rectangle cutout" mask, so we
+ * compose the *visible* (un-cut) area of the front avatar from three mask
+ * layers. What's left over — the bottom-right rectangle with a rounded
+ * top-left corner — is the hole that reveals the back avatar.
+ *
+ * strip = spacing − mask-thickness (thickness of the L-shaped frame)
+ * corner = rounded-mask-radius (radius of the L's inner corner)
+ *
+ * Layer 1 — top strip of the L: 100% wide, strip tall, at (0, 0).
+ * Layer 2 — left strip of the L: strip wide, 100% tall, at (0, 0).
+ * Layer 3 — rounded inner corner fill: a corner × corner tile at
+ * (strip, strip), filled by a radial-gradient that's opaque
+ * outside the corner radius and transparent inside, so it rounds
+ * the L's inner ┘ corner where the strips meet.
+ */
+.avatarPairShape-rounded > :last-child {
+ /* The three mask layers below correspond 1:1 to the L-shape construction
+ * described above (layer 1 = top strip, layer 2 = left strip, layer 3 =
+ * rounded inner corner). The same ordering applies to mask-position and
+ * mask-size. */
+ -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-pair.stories.tsx b/src/avatar/avatar-pair.stories.tsx
new file mode 100644
index 00000000..b636471c
--- /dev/null
+++ b/src/avatar/avatar-pair.stories.tsx
@@ -0,0 +1,226 @@
+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): (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) {
+ 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
diff --git a/src/avatar/avatar-pair.test.tsx b/src/avatar/avatar-pair.test.tsx
new file mode 100644
index 00000000..35aadc09
--- /dev/null
+++ b/src/avatar/avatar-pair.test.tsx
@@ -0,0 +1,108 @@
+import * as React from 'react'
+
+import { render, screen } from '@testing-library/react'
+import { axe } from 'jest-axe'
+
+import { Avatar } from './avatar'
+import { AvatarPair } from './avatar-pair'
+
+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('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(
+
+
+
+ ,
+ )
+
+ 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()
+ })
+
+ describe('a11y', () => {
+ it('renders with no a11y violations', async () => {
+ const { container } = render(
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+ >,
+ )
+ const results = await axe(container)
+
+ expect(results).toHaveNoViolations()
+ })
+ })
+})
diff --git a/src/avatar/avatar-pair.tsx b/src/avatar/avatar-pair.tsx
new file mode 100644
index 00000000..3fd1d7cf
--- /dev/null
+++ b/src/avatar/avatar-pair.tsx
@@ -0,0 +1,98 @@
+import * as React from 'react'
+
+import classNames from 'classnames'
+
+import { Box } from '../box'
+import { polymorphicComponent } from '../utils/polymorphism'
+
+import styles from './avatar-pair.module.css'
+
+import type { ObfuscatedClassName } from '../utils/common-types'
+import type { PolymorphicComponentProps } from '../utils/polymorphism'
+import type { AvatarShape, AvatarSize } from './utils'
+
+/**
+ * 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
+
+ /**
+ * 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: readonly [React.ReactElement, React.ReactElement]
+
+ /**
+ * 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'
+>
+
+/**
+ * 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}
+
+ )
+ },
+)
+
+export { AvatarPair }
+export type { AvatarPairProps }
diff --git a/src/avatar/avatar.mdx b/src/avatar/avatar.mdx
index 3bf4998f..6550f737 100644
--- a/src/avatar/avatar.mdx
+++ b/src/avatar/avatar.mdx
@@ -11,6 +11,7 @@ import {
import * as AvatarStories from './avatar.stories'
import * as AvatarGroupStories from './avatar-group.stories'
+import * as AvatarPairStories from './avatar-pair.stories'
@@ -37,6 +38,15 @@ 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 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.
+
+
+
## Migrating from the legacy API
The previous Avatar API accepted `user`, `avatarUrl`, `colorList`, string or
@@ -191,6 +201,13 @@ the component props instead of overriding them directly.
--reactist-avatar-group-mask-thickness: 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: 5px;
+}
```
## What the consumer owns
diff --git a/src/avatar/index.ts b/src/avatar/index.ts
index c57e70d1..1af786c8 100644
--- a/src/avatar/index.ts
+++ b/src/avatar/index.ts
@@ -1,2 +1,3 @@
export * from './avatar'
export * from './avatar-group'
+export * from './avatar-pair'