---
title: "Animated Avatar Group"
description: "A Nuxt UI avatar group that fans its members apart on hover, optionally overlapping them by a ratio of their size."
canonical_url: "https://nuxt-motion-block.pages.dev/components/text/animated-avatar-group"
---
# Animated Avatar Group

> A Nuxt UI avatar group that fans its members apart on hover, optionally overlapping them by a ratio of their size.

Wraps Nuxt UI's `UAvatarGroup` and adds two hover behaviours on top of it: the
avatars ease apart from under each other, and they scale up in a staggered
sequence starting from the right.

The overlap is expressed as a **ratio of the avatar's pixel size**, not as
pixels. `overlap: 0.4` on an `lg` avatar (36px) becomes `-14.4px` of negative
inline-end margin. That keeps the visual density identical across all nine
sizes — `overlap: 0.3` looks the same at `3xs` and at `3xl`, which is not true
if you pass pixels.

Two states change the hover behaviour, and they are not the same:

- **With overflow** (more avatars than `max`), the leading "+N" counter stays put
while every avatar behind it scales.
- **Without overflow**, all avatars scale, counter included.

Everything is CSS custom properties plus `data-` attributes on the root, so the
animation is entirely in the stylesheet rather than in a JS loop. `hoverScale`
and `staggerDelay` are just the values of `--m-avatar-hover-scale` and
`--m-avatar-stagger`.

## Usage

<code-preview label="Preview">
<div className="flex,justify-center,w-full,h-full">
<component-example name="animated-avatar-group-basic">



</component-example>
</div>

<template v-slot:code="">

```vue
<template>
  <MAnimatedAvatarGroup
    :avatars="avatars"
    :max="4"
    size="lg"
    :overlap="0.35"
  />
</template>

<script setup lang="ts">
const avatars = [
  { src: 'https://randomuser.me/api/portraits/women/44.jpg', alt: 'Ana Ruiz' },
  { src: 'https://randomuser.me/api/portraits/men/32.jpg', alt: 'Ben Osei' },
  { src: 'https://randomuser.me/api/portraits/women/68.jpg', alt: 'Cleo Park' },
  { src: 'https://randomuser.me/api/portraits/men/75.jpg', alt: 'Dmitri Ilves' },
  { src: 'https://randomuser.me/api/portraits/women/90.jpg', alt: 'Elin Haugen' },
]
</script>
```

</template>
</code-preview>

Pick one of two sources for the avatar image: `src` for a real image, or `icon` /
`text` for a glyph or initials. `alt` is what `UAvatar` uses for its own
accessible label, and it is also the component's fallback key, so two avatars
with different images must have different `alt` values or they will share a slot
identity and animate as one.

## Examples

### Avatar group in context

<code-preview label="Preview">
<div className="flex,justify-center,w-full,h-full">
<component-example name="animated-avatar-group-complex">



</component-example>
</div>

<template v-slot:code="">

```vue
<template>
  <div class="flex w-full max-w-md flex-col gap-5">
    <div class="flex items-center justify-between rounded-2xl border border-default bg-default p-5">
      <MAnimatedAvatarGroup
        :avatars="avatars"
        :max="max"
        :size="size"
        :overlap="overlap"
        :hover-scale="1.08"
      />
      <span class="text-xs text-muted">{{ avatars.length }} people</span>
    </div>

    <div class="grid grid-cols-2 gap-3">
      <USelect
        v-model="size"
        :items="['sm', 'md', 'lg', 'xl', '2xl']"
      />
      <USelect
        v-model="max"
        :items="maxItems"
      />
    </div>

    <USlider
      v-model="overlap"
      :min="0"
      :max="0.6"
      :step="0.05"
      label="Overlap"
      class="w-full"
    />
  </div>
</template>

<script setup lang="ts">
const size = ref<'sm' | 'md' | 'lg' | 'xl' | '2xl'>('lg')
const max = ref(4)
const overlap = ref(0.35)

const avatars = [
  { src: 'https://randomuser.me/api/portraits/women/44.jpg', alt: 'Ana Ruiz' },
  { src: 'https://randomuser.me/api/portraits/men/32.jpg', alt: 'Ben Osei' },
  { src: 'https://randomuser.me/api/portraits/women/68.jpg', alt: 'Cleo Park' },
  { src: 'https://randomuser.me/api/portraits/men/75.jpg', alt: 'Dmitri Ilves' },
  { src: 'https://randomuser.me/api/portraits/women/90.jpg', alt: 'Elin Haugen' },
  { src: 'https://randomuser.me/api/portraits/men/11.jpg', alt: 'Farid Nazari' },
]

const maxItems = [
  { label: 'Show 3', value: 3 },
  { label: 'Show 4', value: 4 },
  { label: 'Show all', value: 6 },
]
</script>
```

</template>
</code-preview>

## Customize

<code-preview label="Preview">
<div className="flex,justify-center,w-full,h-full">
<component-example name="animated-avatar-group-custom">



</component-example>
</div>

<template v-slot:code="">
<playground-code name="AnimatedAvatarGroup">



</playground-code>
</template>
</code-preview>

## Props

<component-props>
<component-prop name="avatars" type="AvatarData[]" default="[]">

The people to render. Each entry is passed straight to `UAvatar` — `src`,
`alt`, `icon` and `text` are the useful fields. A `href` field is stripped
and will not turn an avatar into a link; see the note below.

</component-prop>

<component-prop name="max" type="number" default="undefined">

How many avatars to show before collapsing into a counter. When set to a
positive number it wins over `maxVisible`. Setting `0` does **not** mean
"show all" — it falls back to `maxVisible`, because the check is `max > 0`.

</component-prop>

<component-prop name="maxVisible" type="number" default="4">

The limit used when `max` is not provided, or is `0`.

</component-prop>

<component-prop name="size" type="AvatarGroupSize" default="'md'">

One of `3xs`, `2xs`, `xs`, `sm`, `md`, `lg`, `xl`, `2xl`, `3xl`. Also decides
the pixel base used to convert `overlap` into a margin.

</component-prop>

<component-prop name="overlap" type="number" default="undefined">

Fraction of the avatar size to pull each avatar under the next. `0.3` on a 36px
avatar is `-10.8px`. Omit the prop entirely to keep Nuxt UI's own spacing —
the negative margin is only applied when the prop is defined.

</component-prop>

<component-prop name="expandOnHover" type="boolean" default="true">

Ease the avatars apart by 4px and scale them on hover. Set `false` for a static
group; nothing else in the component changes.

</component-prop>

<component-prop name="hoverScale" type="number" default="1.05">

Scale factor applied on hover, written to `--m-avatar-hover-scale`.

</component-prop>

<component-prop name="staggerDelay" type="number" default="0.03">

Seconds between each avatar's transition delay, written to
`--m-avatar-stagger`. Delays count from the leftmost avatar outwards, so the
leftmost reacts first and the rightmost last.

</component-prop>

<component-prop name="class" type="string" default="undefined">

Merged onto the group alongside `m-avatar-group`.

</component-prop>

<component-prop name="ui" type="object" default="undefined">

Per-slot classes merged over the theme defaults. See [UI](#ui) — note that this
prop is currently declared but not forwarded; see the warning under Performance.

</component-prop>
</component-props>

## Slots

<component-slots>
<component-slot-row name="default" type="{ avatar: AvatarData, index: number }">

Rendered **inside** each `UAvatar`, so it becomes the avatar's fallback
content rather than replacing the avatar itself. Omit it and `UAvatar`
applies its own `icon` / `text` / initial handling from the `avatars` entry.

</component-slot-row>
</component-slots>

## UI

This component's theme exposes the slots below. Override any of them per instance with the [`ui` prop](https://nuxt-motion-block.pages.dev/getting-started/theming), or app-wide under `ui.animatedAvatarGroup` in `app.config.ts`.

<component-props>
<component-prop name="ui" type="object">

Per-slot classes merged over the theme defaults.

</component-prop>
</component-props>

<component-slots>
<component-slot-row name="root">

`m-avatar-group`

</component-slot-row>
</component-slots>

## Accessibility

The root is a `role="group"` with `aria-label="Avatar group"` by default, which
is a poor label on its own — pass `aria-label="Reviewers"` (or any string) and it
replaces the default. Avatars are not links here, so the group is a static image
list, not an interactive widget; nothing is focusable and there is no keyboard
interaction to support.

The visuals are hover-only and the whole effect is gated behind
`@media (hover: hover) and (pointer: fine)`, so touch devices never see the fan
out — they get the overlapped layout permanently. That is a deliberate choice,
not a bug, but it means the two presentations differ enough that you should not
rely on the expanded spacing to convey meaning.

`prefers-reduced-motion: reduce` removes the transition entirely and forces
`scale: 1`, so hover does nothing. The avatars still overlap and the group is
still readable — the information is never carried by the animation alone.

The overflow "+N" counter is rendered by `UAvatarGroup` and inherits its own
labelling; since the visible avatars are truncated, `aria-label` on the group is
worth writing explicitly to say how many are in total.

## Performance

- There is no JavaScript animation. The component writes four CSS custom
properties and flips three `data-` attributes; the browser handles the rest.
Re-rendering is O(1) in the number of avatars.
- The transition list is only `margin-inline-end` and `scale`, both
compositor-friendly or cheap layout properties, over 250ms with a slightly
bouncy `cubic-bezier(0.34, 1.56, 0.64, 1)`.
- Stagger is implemented with ten `:nth-last-child()` rules plus an
`:nth-last-child(n + 11)` fallback, all capped at ten steps. Past eleven
avatars every remaining delay is identical, which is deliberate — a per-item
delay for fifty avatars would keep the last one animating for seconds.
- `--m-avatar-overflow-delay` is computed as `visibleCount * staggerDelay` so
the counter's delay matches the end of the stagger chain. Recomputing it is
the only value that depends on the avatar count.
- Animating `margin` is not free — it relayouts on every frame of the transition.
It is scoped to a handful of avatars and never applies above
`nth-last-child(n + 11)`, so the cost stays bounded.

<tip>

Because overlap is a ratio of `size`, the same value looks consistent across
sizes — but it is clamped by nothing. `overlap: 0.9` on a `3xs` (16px) avatar
pulls it 14.4px under its neighbour, which is nearly all of it. Keep the value
under about `0.6` unless you are deliberately masking the avatars.

</tip>

<tip>

Two ordering details of the underlying `UAvatarGroup` are worth knowing. Its root
is `flex-row-reverse` and it renders the avatar array reversed, so the **first**
entry in `avatars` appears **leftmost** and the `+N` counter sits at the right
end. And it already applies its own negative margin per size (`-me-0.5` up to
`-me-2`); the component's `data-overlap` rule is more specific, so setting
`overlap` replaces that default rather than adding to it.

</tip>

<warning>

`ui` is declared as a prop but never forwarded to `UAvatarGroup` or `UAvatar`, so
it currently does nothing. To theme the internals, put the `ui` object on your
own `UAvatarGroup` or use the class on the root, or wrap the component and style
the `[data-slot="base"]` children directly — that is the slot the component's own
stylesheet targets.

</warning>

<warning>

`href` on an avatar entry is removed before the props reach `UAvatar`, so avatar
links are not supported. If you need linked avatars, put the link inside your
default slot content — but note that content renders *inside* the avatar, so the
anchor will be nested within the image element rather than replacing it.

</warning>


## Sitemap

See the full [sitemap](https://nuxt-motion-block.pages.dev/sitemap.md) for all pages.
