Skip to content

Add variant & shape props to AvatarStack component #6420

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Aug 5, 2025
Merged
5 changes: 5 additions & 0 deletions .changeset/eleven-chefs-poke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': minor
---

Adds `variant` and `shape` props to `AvatarStack` component. The `variant` prop will allow the component to render in a cascade view (by default) or a new stacked view which will evenly space the avatars and remove opacity. The `shape` prop will allow the avatars to be rendered as circles (by default) or squares.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
103 changes: 92 additions & 11 deletions packages/react/src/AvatarStack/AvatarStack.module.css
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
/* stylelint-disable selector-max-specificity */
.AvatarStack {
--avatar-border-width: 1px;
--overlap-size: calc(var(--avatar-stack-size) * 0.55);
--overlap-size-avatar-three-plus: calc(var(--avatar-stack-size) * 0.85);
--mask-size: calc(100% + (var(--avatar-border-width) * 2));
--mask-start: -1;
--opacity-step: 15%;
Expand All @@ -13,6 +11,16 @@
height: var(--avatar-stack-size);
isolation: isolate;

&:where([data-variant='cascade']) {
--overlap-size: calc(var(--avatar-stack-size) * 0.55);
--overlap-size-avatar-three-plus: calc(var(--avatar-stack-size) * 0.85);
}

&:where([data-variant='stack']) {
--overlap-size: calc(var(--avatar-stack-size) * 0.55);
--overlap-size-avatar-three-plus: calc(var(--avatar-stack-size) * 0.55);
}

&:where([data-responsive]) {
@media screen and (--viewportRange-narrow) {
--avatar-stack-size: var(--stackSize-narrow);
Expand All @@ -27,13 +35,23 @@
}
}

&:where([data-avatar-count='1']) {
&:where([data-avatar-count='1'][data-shape='circle']) {
.AvatarItem {
/* stylelint-disable-next-line primer/box-shadow */
box-shadow: 0 0 0 var(--avatar-border-width) var(--avatar-borderColor);
}
}

&:where([data-avatar-count='1'][data-shape='square']) .AvatarItem {
/* stylelint-disable-next-line primer/box-shadow */
box-shadow: 1px 0 rgba(0, 0, 0, 1);
}

&:where([data-avatar-count='1'][data-shape='square'][data-align-right]) .AvatarItem {
/* stylelint-disable-next-line primer/box-shadow */
box-shadow: -1px 0 rgba(0, 0, 0, 1);
}

&:where([data-avatar-count='2']) {
/*
MIN-WIDTH CALC FORMULA EXPLAINED:
Expand All @@ -43,7 +61,7 @@
min-width: calc(var(--avatar-stack-size) + (var(--avatar-stack-size) - var(--overlap-size)));
}

&:where([data-avatar-count='3']) {
&:where([data-avatar-count='3'][data-variant='cascade']) {
/*
MIN-WIDTH CALC FORMULA EXPLAINED:
avatar size ➡️ var(--avatar-stack-size)
Expand All @@ -56,7 +74,16 @@
);
}

&:where([data-avatar-count='3+']) {
&:where([data-avatar-count='3'][data-variant='stack']) {
/*
MIN-WIDTH CALC FORMULA EXPLAINED:
avatar size ➡️ var(--avatar-stack-size)
plus the visible part of the 2nd avatar & 3rd avatar ➡️ var(--avatar-stack-size) - var(--overlap-size-avatar-three-plus) * 2
*/
min-width: calc(var(--avatar-stack-size) + (var(--avatar-stack-size) - var(--overlap-size-avatar-three-plus)) * 2);
}

&:where([data-avatar-count='3+'][data-variant='cascade']) {
/*
MIN-WIDTH CALC FORMULA EXPLAINED:
avatar size ➡️ var(--avatar-stack-size)
Expand All @@ -69,6 +96,17 @@
);
}

&:where([data-avatar-count='3+'][data-variant='stack']) {
/*
MIN-WIDTH CALC FORMULA EXPLAINED:
avatar size ➡️ var(--avatar-stack-size)
plus the visible part of the 2nd to 4th avatars ➡️ var(--avatar-stack-size) - var(--overlap-size-avatar-three-plus) * 3
*/
min-width: calc(var(--avatar-stack-size) + (var(--avatar-stack-size) - var(--overlap-size-avatar-three-plus)) * 3);

--overlap-size: var(--overlap-size-avatar-three-plus);
}

&:where([data-align-right]) {
--mask-start: 1;

Expand Down Expand Up @@ -100,20 +138,28 @@
mask-position 0.2s ease-in-out,
mask-size 0.2s ease-in-out;

&:is(img) {
.AvatarStack:where([data-shape='circle']) &:is(img) {
/* stylelint-disable-next-line primer/box-shadow */
box-shadow: 0 0 0 var(--avatar-border-width) transparent;
}

.AvatarStack:where([data-shape='square']) &:is(img) {
/* stylelint-disable-next-line primer/box-shadow */
box-shadow: 1px 0 rgba(255, 255, 255, 1);
}

.AvatarStack:where([data-shape='square'][data-align-right]) &:is(img) {
/* stylelint-disable-next-line primer/box-shadow */
box-shadow: -1px 0 rgba(255, 255, 255, 1);
}

&:first-child {
margin-inline-start: 0;
}

&:nth-child(n + 2) {
/* stylelint-disable-next-line primer/spacing */
margin-inline-start: calc(var(--overlap-size) * -1);
/* stylelint-disable-next-line declaration-property-value-no-unknown */
mask-image: radial-gradient(at 50% 50%, rgb(0, 0, 0) 70%, rgba(0, 0, 0, 0) 71%), linear-gradient(rgb(0, 0, 0) 0 0);
mask-repeat: no-repeat, no-repeat;
mask-size:
var(--mask-size) var(--mask-size),
Expand All @@ -135,23 +181,58 @@
padding: 0.1px;
}

&:nth-child(n + 3) {
/* Circular mask */
.AvatarStack:where([data-shape='circle']) &:nth-child(n + 2) {
/* stylelint-disable-next-line declaration-property-value-no-unknown */
mask-image: radial-gradient(at 50% 50%, rgb(0, 0, 0) 70%, rgba(0, 0, 0, 0) 71%), linear-gradient(rgb(0, 0, 0) 0 0);
}

/* Square mask */
.AvatarStack:where([data-shape='square']) &:nth-child(n + 2) {
/* stylelint-disable-next-line declaration-property-value-no-unknown */
mask-image: linear-gradient(at 50% 50%, rgb(0, 0, 0) 70%, rgba(0, 0, 0, 0) 71%), linear-gradient(rgb(0, 0, 0) 0 0);
}

/* Cascade variant override for nth-child(n + 3) */
.AvatarStack:where([data-variant='cascade']) &:nth-child(n + 3) {
--overlap-size: var(--overlap-size-avatar-three-plus);

/* stylelint-disable-next-line alpha-value-notation */
opacity: calc(100% - 2 * var(--opacity-step));
}

&:nth-child(n + 4) {
/* Cascade variant override for nth-child(n + 4) */
.AvatarStack:where([data-variant='cascade']) &:nth-child(n + 4) {
/* stylelint-disable-next-line alpha-value-notation */
opacity: calc(100% - 3 * var(--opacity-step));
}

&:nth-child(n + 5) {
/* Cascade variant override for nth-child(n + 5) */
.AvatarStack:where([data-variant='cascade']) &:nth-child(n + 5) {
/* stylelint-disable-next-line alpha-value-notation */
opacity: calc(100% - 4 * var(--opacity-step));
}

.AvatarStack:where([data-shape='square']) &:nth-child(1) {
z-index: 5;
}

.AvatarStack:where([data-shape='square']) &:nth-child(2) {
z-index: 4;
}

.AvatarStack:where([data-shape='square']) &:nth-child(3) {
z-index: 3;
}

.AvatarStack:where([data-shape='square']) &:nth-child(4) {
z-index: 2;
}

.AvatarStack:where([data-shape='square']) &:nth-child(5) {
z-index: 1;
}

&:nth-child(n + 6) {
visibility: hidden;
opacity: 0;
Expand Down
23 changes: 20 additions & 3 deletions packages/react/src/AvatarStack/AvatarStack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@ import classes from './AvatarStack.module.css'
import {hasInteractiveNodes} from '../internal/utils/hasInteractiveNodes'
import {BoxWithFallback} from '../internal/components/BoxWithFallback'

const transformChildren = (children: React.ReactNode) => {
const transformChildren = (children: React.ReactNode, shape: AvatarStackProps['shape']) => {
return React.Children.map(children, child => {
if (!React.isValidElement(child)) return child
return React.cloneElement(child, {
...child.props,
square: shape === 'square' ? true : undefined,
className: clsx(child.props.className, 'pc-AvatarItem', classes.AvatarItem),
})
})
Expand All @@ -23,6 +24,8 @@ const transformChildren = (children: React.ReactNode) => {
export type AvatarStackProps = {
alignRight?: boolean
disableExpand?: boolean
variant?: 'cascade' | 'stack'
shape?: 'circle' | 'square'
size?: number | ResponsiveValue<number>
className?: string
children: React.ReactNode
Expand Down Expand Up @@ -57,7 +60,17 @@ const AvatarStackBody = ({
)
}

const AvatarStack = ({children, alignRight, disableExpand, size, className, style, sx: sxProp}: AvatarStackProps) => {
const AvatarStack = ({
children,
variant = 'cascade',
shape = 'circle',
alignRight,
disableExpand,
size,
className,
style,
sx: sxProp,
}: AvatarStackProps) => {
const [hasInteractiveChildren, setHasInteractiveChildren] = useState<boolean | undefined>(false)
const stackContainer = useRef<HTMLDivElement>(null)

Expand Down Expand Up @@ -149,11 +162,15 @@ const AvatarStack = ({children, alignRight, disableExpand, size, className, styl
return (
<BoxWithFallback
as="span"
data-variant={variant}
data-shape={shape}
data-avatar-count={count > 3 ? '3+' : count}
data-align-right={alignRight ? '' : undefined}
data-responsive={!size || isResponsiveValue(size) ? '' : undefined}
className={clsx(
{
'pc-AvatarStack--variant': variant,
'pc-AvatarStack--shape': shape,
'pc-AvatarStack--two': count === 2,
'pc-AvatarStack--three': count === 3,
'pc-AvatarStack--three-plus': count > 3,
Expand All @@ -171,7 +188,7 @@ const AvatarStack = ({children, alignRight, disableExpand, size, className, styl
stackContainer={stackContainer}
>
{' '}
{transformChildren(children)}
{transformChildren(children, shape)}
</AvatarStackBody>
</BoxWithFallback>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

exports[`AvatarStack > respects alignRight props 1`] = `
<span
class="pc-AvatarStack--three-plus pc-AvatarStack--right prc-AvatarStack-AvatarStack-LcJ4R"
class="pc-AvatarStack--variant pc-AvatarStack--shape pc-AvatarStack--three-plus pc-AvatarStack--right prc-AvatarStack-AvatarStack-LcJ4R"
data-align-right=""
data-avatar-count="3+"
data-responsive=""
data-shape="circle"
data-variant="cascade"
style="--stackSize-narrow: 20px; --stackSize-regular: 20px; --stackSize-wide: 20px;"
>
<div
Expand Down

This file was deleted.

Loading
Loading