A link that shows a small card (image, title, one line, domain) after a moment on hover or focus, above everything, flipping when there is no room.
Components / UI
Avatar Stack
A row of overlapping avatars with a count chip such as "+2 398". Lazy images or initials, overlapped by a negative margin with a ring in the surface colour. Hover the row and it spreads; hover one and it rises and its name appears above it in a CSS tooltip. Avatars are not tab stops unless they carry an href; the names live in the alt text and the chip has its own screen-reader line. The count is formatted with Intl at build. Zero JavaScript.
Live demo
- Noor Haddad
- Kai Nakamura
- and 2,398 others
hover the row · hover one for the name
Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.
Adjust · props are custom properties, set inline, so you edit the running instance
Measured
JavaScript of its own
0 B
CSS 6.7 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.
Browser support
every browser.
Install
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
import AvatarStack from '@moonarc/core/AvatarStack';
const people = [
{ name: 'Ari Santoso', src: '/avatars/ari.jpg' },
{ name: 'Mei Lin', src: '/avatars/mei.jpg' },
{ name: 'Noor Haddad' },
{ name: 'Tomás Reyes', href: '/team/tomas/' },
];
---
<AvatarStack people={people} max={4} count={2403} locale="en" />
<p>Joined by <AvatarStack people={people} max={3} size="1.5rem" /> and the rest of the list.</p>Copy it into your project instead (shadcn registry)
Owns the file, no dependency. The registry item also installs the base tokens.
npx shadcn@latest add https://moonarc.dev/r/avatar-stack.jsonThe CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.
Source
The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.
---
/**
* AvatarStack — overlapping avatars and a "+2 398", zero JS. A list of
* lazy images (or initials where there is no photo) overlapped through a
* negative margin, each with a ring in the surface colour so the overlap
* reads. Hover the list and the row spreads; hover one and it rises and
* its name appears above it — a CSS tooltip, no popover. Avatars are not
* focusable: six tab stops for decoration is a keyboard trap, and the
* names are in the alt text. With `href` an avatar becomes a link, is
* focusable, and shows its name on :focus-visible as well. Touch gets the
* still row. The count is formatted at build with Intl (`locale`).
*/
import type { HTMLAttributes } from 'astro/types';
interface Person {
name: string;
/** Photo URL; without one, the initials are drawn. */
src?: string;
href?: string;
}
interface Props extends HTMLAttributes<'ul'> {
people: Person[];
/** Avatars shown; the rest go into the chip. */
max?: number;
/** Total count; the chip shows count − max. Defaults to people.length. */
count?: number;
/** Avatar size, any CSS length. */
size?: string;
/** Intl locale for the chip's number. */
locale?: string;
/** Screen-reader text of the chip, with `{n}` for the formatted number. */
moreLabel?: string;
/** Ring colour around each avatar; default the panel token. */
ring?: string;
}
const { people, max = 5, count, size = '2.5rem', locale = 'en', moreLabel = 'and {n} others', ring, class: className, style, ...rest } = Astro.props;
const shown = people.slice(0, Math.max(1, max));
const total = count ?? people.length;
const more = Math.max(0, total - shown.length);
const moreText = new Intl.NumberFormat(locale).format(more);
const initials = (name: string) =>
name
.split(/\s+/)
.map((w) => w[0] ?? '')
.join('')
.slice(0, 2)
.toUpperCase();
const vars = [`--ma-avatars-size:${size}`, ring && `--ma-avatars-ring:${ring}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---
<ul class:list={['ma-avatars', className]} style={vars} {...rest}>
{
shown.map((p) => (
<li class="ma-avatars__item">
{p.href ? (
<a href={p.href} class="ma-avatars__face">
{p.src ? <img src={p.src} alt={p.name} loading="lazy" decoding="async" /> : <span class="ma-avatars__initials" aria-hidden="true">{initials(p.name)}</span>}
{!p.src && <span class="ma-avatars__sr">{p.name}</span>}
</a>
) : (
<span class="ma-avatars__face">
{p.src ? <img src={p.src} alt={p.name} loading="lazy" decoding="async" /> : <span class="ma-avatars__initials" aria-hidden="true">{initials(p.name)}</span>}
{!p.src && <span class="ma-avatars__sr">{p.name}</span>}
</span>
)}
<span class="ma-avatars__tip" aria-hidden="true">{p.name}</span>
</li>
))
}
{more > 0 && (
<li class="ma-avatars__item ma-avatars__more">
<span class="ma-avatars__face" aria-hidden="true">+{moreText}</span>
<span class="ma-avatars__sr">{moreLabel.replace('{n}', moreText)}</span>
</li>
)}
</ul>
<style is:global>
@layer components {
:where(.ma-avatars) {
--ma-avatars-overlap: calc(var(--ma-avatars-size) * -0.26);
display: flex;
align-items: center;
margin: 0;
padding: 0;
padding-inline-start: calc(var(--ma-avatars-overlap) * -1);
list-style: none;
isolation: isolate;
}
:where(.ma-avatars__item) {
position: relative;
margin-inline-start: var(--ma-avatars-overlap);
transition:
margin var(--ma-duration) var(--ma-ease),
translate var(--ma-duration) var(--ma-ease);
}
:where(.ma-avatars__face) {
display: grid;
place-items: center;
inline-size: var(--ma-avatars-size);
block-size: var(--ma-avatars-size);
border-radius: 50%;
box-shadow: 0 0 0 2px var(--ma-avatars-ring, var(--ma-panel));
background: color-mix(in srgb, var(--ma-ink) 10%, var(--ma-panel));
color: inherit;
overflow: clip;
text-decoration: none;
font-size: calc(var(--ma-avatars-size) * 0.3);
font-weight: 600;
letter-spacing: -0.02em;
}
:where(.ma-avatars__face > img) {
inline-size: 100%;
block-size: 100%;
object-fit: cover;
}
:where(.ma-avatars__more .ma-avatars__face) {
background: var(--ma-ink);
color: var(--ma-panel);
font-size: calc(var(--ma-avatars-size) * 0.3);
font-variant-numeric: tabular-nums;
/* a count wider than the circle grows into a pill instead of spilling out of it: +2,398. border-box, so a project
without a reset (content-box) still gets a circle for "+3" */
box-sizing: border-box;
inline-size: auto;
min-inline-size: var(--ma-avatars-size);
padding-inline: calc(var(--ma-avatars-size) * 0.2);
border-radius: calc(var(--ma-avatars-size) / 2);
}
/* centred on its avatar: left and -50% are the same in both directions, where inset-inline-start would be right: 50%
on a right-to-left page and push the name a whole width off */
:where(.ma-avatars__tip) {
position: absolute;
inset-block-end: calc(100% + 0.5em);
left: 50%;
padding: 0.3em 0.6em;
border-radius: 0.4em;
background: var(--ma-ink);
color: var(--ma-panel);
font-size: 0.75rem;
line-height: 1.2;
white-space: nowrap;
translate: -50% var(--ma-travel-hover);
opacity: 0;
pointer-events: none;
transition:
opacity var(--ma-duration-fast) var(--ma-ease-out),
translate var(--ma-duration-fast) var(--ma-ease-out);
}
/* a pointer that hovers: the row spreads; the one under it rises and names itself. Touch never sees this. */
@media (hover: hover) and (pointer: fine) {
:where(.ma-avatars:hover .ma-avatars__item) {
margin-inline-start: calc(var(--ma-avatars-size) * 0.08);
}
:where(.ma-avatars:hover .ma-avatars__item:first-child) {
margin-inline-start: var(--ma-avatars-overlap);
}
:where(.ma-avatars__item:hover) {
z-index: 1;
translate: 0 calc(var(--ma-travel-hover) * -1);
}
:where(.ma-avatars__item:hover .ma-avatars__tip) {
opacity: 1;
translate: -50% 0;
transition-duration: var(--ma-duration);
transition-timing-function: var(--ma-ease);
}
}
/* a linked avatar reached from the keyboard names itself the same way */
:where(.ma-avatars__item:has(.ma-avatars__face:focus-visible)) {
z-index: 1;
translate: 0 calc(var(--ma-travel-hover) * -1);
}
:where(.ma-avatars__item:has(.ma-avatars__face:focus-visible) .ma-avatars__tip) {
opacity: 1;
translate: -50% 0;
}
:where(.ma-avatars__face:focus-visible) {
outline: 2px solid var(--ma-ink);
outline-offset: 2px;
}
:where(.ma-avatars__sr) {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
:where(.ma-avatars__item),
:where(.ma-avatars__tip) {
transition: none;
}
:where(.ma-avatars__item:hover),
:where(.ma-avatars__item:has(.ma-avatars__face:focus-visible)) {
translate: 0 0;
}
}
}
</style>Props
| Prop | Type | Default | Description |
|---|---|---|---|
people | { name, src?, href? }[] | none | Who to show. src is a photo (lazy, async); without one the initials are drawn. href makes the avatar a link and a tab stop. |
max | number | 5 | Avatars shown; the rest are the chip. |
count | number | people.length | The total; the chip prints count − max. |
size | string | '2.5rem' | Avatar size; the overlap, the ring and the initials scale with it. |
locale | string | 'en' | Intl.NumberFormat locale for the chip. |
moreLabel | string | 'and {n} others' | What a screen reader gets for the chip; {n} is the formatted number. |
ring | string | none | Ring colour; default the panel token. Pass the surface the stack sits on. |
Reduced motion
The spread happens without a transition and nothing rises; the tooltip still appears.
With ClientRouter
Static; nothing to bind.
Why it is built this way
- The overlap is a negative inline margin and the ring is a box-shadow in the surface colour, so the stack reads as a stack on any background. Pass ring to match a card. On hover the margin goes positive and the row opens on the preset curve; the first item keeps its margin so the row does not jump sideways.
- The name is a tooltip inside the item (absolute, above, centred with translate), rising from the hover travel token to zero. It is aria-hidden: the accessible name of each avatar is its alt, and a decorative row is not six tab stops.
- With href an avatar is a real link and reaches the keyboard; :focus-visible then does what hover does, through :has() on the item, so the name shows for a keyboard reader as well.
- The whole hover block is behind (hover: hover) and (pointer: fine): on touch the row is still and no tooltip appears, because a tooltip that opens on tap has no way to close.
- The chip is formatted with Intl.NumberFormat at build time (locale), giving "2,398" or "2 398", and carries its own screen-reader sentence from moreLabel.
Replaces: AnimatedTooltip (Aceternity) · AvatarCircles (Magic UI) · AvatarGroup (shadcn examples). See the migration table.
Related
Tilt
PointerA card leans toward the cursor in 3D, lifts slightly, catches a glare, and settles back on the active preset's spring when the cursor leaves.
Team Grid
ProShows team members in a hairline grid: portraits that tilt toward the cursor with a glare, initials when there is no photo, names and roles revealed with a stagger..
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown