Notification·04.3
Presence Avatars
Join and leave as a layout change.
- Ana Ruiz
- Ivo Bergman
- Noor Haddad
Install
One dependency. The component is copied into your project, so the file is yours after that.
bun add motionUsage
"use client";
import { useEffect, useState } from "react";
import {
PresenceAvatars,
type PresencePerson,
} from "@/components/interior/presence-avatars";
import { room } from "@/lib/room";
export function BoardHeader({ title }: { title: string }) {
const [people, setPeople] = useState<PresencePerson[]>([]);
const [roster, setRoster] = useState<PresencePerson[] | null>(null);
useEffect(() => room.subscribe("presence", setPeople), []);
return (
<header className="flex items-center justify-between gap-3 px-4 h-12">
<h1 className="truncate text-[15px] font-medium">{title}</h1>
<PresenceAvatars
people={people}
max={4}
label="On this board"
onOverflowSelect={(hidden) => setRoster(hidden)}
/>
{roster ? <RosterSheet people={roster} onClose={() => setRoster(null)} /> : null}
</header>
);
}Source
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
const SLOT = { type: "spring", stiffness: 520, damping: 34, mass: 0.45 } as const;
const FADE = { duration: 0.24, ease: [0.23, 1, 0.32, 1] } as const;
const INSTANT = { duration: 0 } as const;
export type PresencePerson = {
id: string;
name: string;
src?: string;
};
export type UsePresenceOptions = {
people: PresencePerson[];
max?: number;
announceAfter?: number;
};
export type UsePresenceResult = {
ordered: PresencePerson[];
visible: PresencePerson[];
hidden: PresencePerson[];
overflow: number;
total: number;
summary: string;
announcement: string;
};
function initials(name: string): string {
const words = name.trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return "?";
const first = Array.from(words[0])[0] ?? "";
const last = words.length > 1 ? (Array.from(words[words.length - 1])[0] ?? "") : "";
return (first + last).toUpperCase();
}
function describe(names: string[]): string {
if (names.length === 0) return "Nobody here";
if (names.length === 1) return `${names[0]} is here`;
if (names.length === 2) return `${names[0]} and ${names[1]} are here`;
const rest = names.length - 2;
return `${names[0]}, ${names[1]} and ${rest} ${rest === 1 ? "other" : "others"} are here`;
}
export function usePresence({
people,
max = 5,
announceAfter = 900,
}: UsePresenceOptions): UsePresenceResult {
const seen = useRef(new Map<string, number>());
const next = useRef(0);
const ordered = useMemo(() => {
const order = seen.current;
for (const person of people) {
if (!order.has(person.id)) {
order.set(person.id, next.current);
next.current += 1;
}
}
return people
.slice()
.toSorted((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0));
}, [people]);
const slots = Math.max(1, max);
const visible = ordered.slice(0, slots);
const hidden = ordered.slice(slots);
const summary = describe(ordered.map((person) => person.name));
const [announcement, setAnnouncement] = useState(summary);
useEffect(() => {
const timer = setTimeout(() => setAnnouncement(summary), announceAfter);
return () => clearTimeout(timer);
}, [summary, announceAfter]);
return {
ordered,
visible,
hidden,
overflow: hidden.length,
total: ordered.length,
summary,
announcement,
};
}
const TILE =
"absolute left-0 top-0 select-none rounded-[10px] bg-stone-200 p-[3px] dark:bg-stone-700";
const WELL =
"relative grid size-full place-items-center overflow-hidden rounded-[7px] bg-stone-100 font-medium leading-none text-stone-500 dark:bg-white/10 dark:text-stone-300";
type TileProps = {
person: PresencePerson;
index: number;
step: number;
size: number;
zIndex: number;
reduced: boolean;
};
function PresenceTile({ person, index, step, size, zIndex, reduced }: TileProps) {
const [loaded, setLoaded] = useState(false);
const [failed, setFailed] = useState(false);
return (
<motion.span
aria-hidden
initial={{ opacity: 0, scale: 0.86, x: index * step }}
animate={{ opacity: 1, scale: 1, x: index * step }}
exit={{ opacity: 0, scale: 0.86 }}
transition={reduced ? INSTANT : SLOT}
style={{ width: size, height: size, zIndex, fontSize: Math.round(size * 0.34) }}
className={TILE}
>
<span className={WELL}>
{initials(person.name)}
{person.src && !failed ? (
<motion.img
src={person.src}
alt=""
width={size}
height={size}
decoding="async"
onLoad={() => setLoaded(true)}
onError={() => setFailed(true)}
initial={false}
animate={{ opacity: loaded ? 1 : 0 }}
transition={reduced ? INSTANT : FADE}
className="absolute inset-0 size-full object-cover"
/>
) : null}
</span>
</motion.span>
);
}
export type PresenceAvatarsProps = {
people: PresencePerson[];
max?: number;
size?: number;
overlap?: number;
label?: string;
announceAfter?: number;
onOverflowSelect?: (hidden: PresencePerson[]) => void;
className?: string;
};
export function PresenceAvatars({
people,
max = 5,
size = 28,
overlap = 9,
label = "People here",
announceAfter,
onOverflowSelect,
className = "",
}: PresenceAvatarsProps) {
const reduced = useReducedMotion();
const { ordered, visible, hidden, overflow, announcement } = usePresence({
people,
max,
announceAfter,
});
const slots = Math.max(1, max);
const step = size - overlap;
const chip = size + 8;
const rail =
visible.length === 0
? 0
: overflow > 0
? visible.length * step + chip
: (visible.length - 1) * step + size;
const chipCount = `+${Math.min(overflow, 99)}`;
const chipMotion = {
initial: { opacity: 0, scale: 0.86 },
animate: { opacity: 1, scale: 1, x: visible.length * step },
exit: { opacity: 0, scale: 0.86 },
transition: reduced ? INSTANT : SLOT,
};
const chipClass =
"absolute left-0 top-0 grid place-items-center rounded-[9px] border border-stone-200 bg-white font-mono text-[10.5px] leading-none tabular-nums text-stone-500 outline-none ring-2 ring-white dark:border-white/[0.16] dark:bg-[#1D1D1A] dark:text-stone-400 dark:ring-stone-900";
return (
<div role="group" aria-label={label} className={`inline-flex items-center ${className}`}>
<motion.div
className="relative shrink-0"
style={{ height: size }}
initial={false}
animate={{ width: rail }}
transition={reduced ? INSTANT : SLOT}
>
<AnimatePresence initial={false}>
{visible.map((person, i) => (
<PresenceTile
key={person.id}
person={person}
index={i}
step={step}
size={size}
zIndex={slots - i}
reduced={Boolean(reduced)}
/>
))}
{overflow > 0 &&
(onOverflowSelect ? (
<motion.button
key="overflow"
type="button"
onClick={() => onOverflowSelect(hidden)}
aria-label={`Show ${overflow} more`}
style={{ width: chip, height: size, zIndex: 0 }}
className={`${chipClass} focus-visible:border-[#4568FF] dark:focus-visible:border-[#93B0FF]`}
{...chipMotion}
>
<span aria-hidden>{chipCount}</span>
</motion.button>
) : (
<motion.span
key="overflow"
aria-hidden
style={{ width: chip, height: size, zIndex: 0 }}
className={chipClass}
{...chipMotion}
>
{chipCount}
</motion.span>
))}
</AnimatePresence>
</motion.div>
<ul className="sr-only">
{ordered.map((person) => (
<li key={person.id}>{person.name}</li>
))}
</ul>
<span role="status" aria-live="polite" aria-atomic="true" className="sr-only">
{announcement}
</span>
</div>
);
}Props
peoplePresencePerson[]The room, in any order. Each entry is { id, name, src? }; id is the identity that survives a re-render, name supplies the initials and the spoken roster, src is the photo laid over them.
max5numberAvatar slots drawn before the overflow chip takes over.
size28numberEdge of one square tile in pixels. Font size and the rail width are derived from it.
overlap9numberPixels each tile hides of the one before it. The step between slots is size minus overlap.
label"People here"stringAccessible name for the group wrapping the rail, the roster list and the live region.
announceAfter900numberQuiet period in milliseconds before the roster summary reaches the live region. A burst of joins collapses into one announcement.
onOverflowSelect(hidden: PresencePerson[]) => voidGiven, the overflow chip becomes a real button with a focus ring and receives the people it is standing in for. Omitted, the chip is decorative and hidden from assistive tech.
className""stringAppended last to the outer group, so callers can override spacing and alignment.