## Presence Avatars — Notification Join and leave as a layout change. Docs: https://www.interior.dev/docs/presence-avatars Reference: https://www.interior.dev/reference/presence-avatars License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT) ### Install Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project. `bunx shadcn@latest add https://www.interior.dev/r/presence-avatars.json` Or run `bun add motion` and copy the source below. ### Usage ```tsx "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([]); const [roster, setRoster] = useState(null); useEffect(() => room.subscribe("presence", setPeople), []); return (

{title}

setRoster(hidden)} /> {roster ? setRoster(null)} /> : null}
); } ``` ### Props - `people` (`PresencePerson[]`) 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. - `max` (`number`) — default: `5` Avatar slots drawn before the overflow chip takes over. - `size` (`number`) — default: `28` Edge of one square tile in pixels. Font size and the rail width are derived from it. - `overlap` (`number`) — default: `9` Pixels each tile hides of the one before it. The step between slots is size minus overlap. - `label` (`string`) — default: `"People here"` Accessible name for the group wrapping the rail, the roster list and the live region. - `announceAfter` (`number`) — default: `900` Quiet period in milliseconds before the roster summary reaches the live region. A burst of joins collapses into one announcement. - `onOverflowSelect` (`(hidden: PresencePerson[]) => void`) Given, 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` (`string`) — default: `""` Appended last to the outer group, so callers can override spacing and alignment. ### Behavior notes - The rail is exactly as wide as who is actually here, and it grows on the same spring the arriving avatar rides in on. A rail padded out to its widest reachable state leaves a hole where nobody is standing; one that snaps open around a person reads as two events instead of one. - First-seen order is held in a ref, so an arrival cannot reshuffle the people already present, and someone who reconnects returns to the slot they had. - Avatars are placed by transform inside a positioned rail, never by layout: a leaver exits on opacity and scale while the survivors spring into the freed slots. - The photo is laid over the initials and fades in when it decodes, so a slow avatar is a name rather than an empty square, and one that never arrives stays a name. - Each tile is two shells. The outer one is the hairline given 2px of thickness rather than one, so the rim around a face is a single colour instead of a border with a lighter band trapped inside it; outside that, 2px of the surface keeps neighbouring faces apart. A photo that runs straight into its own edge reads as a sticker. - The overflow chip is a tabular cell that caps at +99, so counting from +9 to +12 changes the digits and nothing else. - Screen readers get one debounced summary of the room instead of one announcement per arrival, and the roster behind the chip stays readable as a list rather than disappearing into a number. - Under prefers-reduced-motion the slots fill and empty instantly; no avatar and no count is withheld, only the trip between positions. ### Source (`components/interior/presence-avatars.tsx`) ```tsx "use client"; import { useEffect, useMemo, useRef, useState } from "react"; import { AnimatePresence, motion, useIsomorphicLayoutEffect, 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()); 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; }; type FaceStatus = "loading" | "ready" | "error"; function useFace(src?: string) { const ref = useRef(null); const [state, setState] = useState<{ status: FaceStatus; instant: boolean }>({ status: "loading", instant: false, }); useIsomorphicLayoutEffect(() => { const img = ref.current; const set = (status: FaceStatus, instant: boolean) => setState((prev) => prev.status === status && prev.instant === instant ? prev : { status, instant }, ); if (!img || !src) { set("loading", false); return; } const cached = img.complete && img.naturalWidth > 0; if (img.complete) { set(cached ? "ready" : "error", cached); return; } set("loading", false); let alive = true; const onLoad = () => { if (alive) set("ready", false); }; const onError = () => { if (alive) set("error", false); }; img.addEventListener("load", onLoad); img.addEventListener("error", onError); return () => { alive = false; img.removeEventListener("load", onLoad); img.removeEventListener("error", onError); }; }, [src]); return { ref, status: state.status, instant: state.instant }; } function PresenceTile({ person, index, step, size, zIndex, reduced }: TileProps) { const { ref, status, instant } = useFace(person.src); return ( {initials(person.name)} {person.src ? ( ) : null} ); } 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 (
{visible.map((person, i) => ( ))} {overflow > 0 && (onOverflowSelect ? ( 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} > {chipCount} ) : ( {chipCount} ))}
    {ordered.map((person) => (
  • {person.name}
  • ))}
{announcement}
); } ```