Presence Stack

Overlapping live-presence avatars with status dots and a sane overflow chip. Install Presence Stack from the VLLNT UI registry with the shadcn CLI.

Report a bug

Overlapping live-presence avatars with status dots and a sane overflow chip. Part of the data family in VLLNT UI, it ships as a machine-readable registry entry — copy the source directly into your app with the shadcn CLI and own it, no runtime dependency on a component library.

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

Add Presence Stack to your project with the shadcn CLI. The source lands in your codebase, ready to adapt:

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/presence-stack.json

Source

"use client"; import type { ComponentPropsWithoutRef, ReactNode } from "react"; import { cn } from "../../lib/utils"; /** * Presence status — drives the corner dot color. * * @public */ export type PresenceStatus = "active" | "away" | "idle" | "offline"; const STATUS_DOT: Record<PresenceStatus, string> = { active: "bg-emerald-500", away: "bg-amber-500", idle: "bg-muted-foreground", offline: "bg-muted-foreground/40", }; /** * One user in the presence stack. * * @public */ export type PresenceUser = { /** Optional accent color (hex / oklch / var). Drives the avatar fill. */ color?: string; /** Stable identifier — used as the React key. */ id: string; /** Avatar initial / glyph. */ initial: ReactNode; /** Display name shown on hover (`title` attribute). */ name?: string; /** Optional status. Defaults to `"active"`. */ status?: PresenceStatus; }; /** * Localizable strings. * * @public */ export type PresenceStackLabels = { /** Suffix for the overflow chip. Defaults to `"more"`. */ overflowSuffix?: string; /** Aria-label override. Defaults to `"Live presence"`. */ region?: string; }; const DEFAULT_LABELS = { overflowSuffix: "more", region: "Live presence", } as const satisfies Required<PresenceStackLabels>; /** * Props for {@link PresenceStack}. * * @public */ export type PresenceStackProps = { /** Localizable strings. */ labels?: PresenceStackLabels; /** Cap the rendered users; the rest collapse into a `+N` chip. Defaults to `5`. */ max?: number; /** Optional click handler for the overflow chip. */ onOverflowActivate?: () => void; /** Users sorted in render order (most-relevant first). */ users: PresenceUser[]; } & ComponentPropsWithoutRef<"div">; const Avatar = (props: { user: PresenceUser }): React.ReactElement => { const { user } = props; const status = user.status ?? "active"; return ( <span className="relative -ml-2 inline-flex size-7 items-center justify-center rounded-full border-2 border-background text-[11px] font-semibold text-white shadow-sm first:ml-0" data-presence-stack-status={status} data-presence-stack-user={user.id} style={{ backgroundColor: user.color ?? "var(--foreground)" }} title={user.name} > {user.initial} <span aria-hidden="true" className={cn( "absolute -bottom-0.5 -right-0.5 size-2 rounded-full border border-background", STATUS_DOT[status], )} data-presence-stack-dot /> </span> ); }; /** * Overlapping live-presence avatars showing who is in the canvas right * now. Each avatar carries an accent color, an initial, and a status * dot. Distinct from `AvatarGroup` (a static participant grouping): * this primitive centers on live status, a sane overflow, and * interactive expansion. * * Pure presentation; the host owns the websocket roster + maps user * ids to colors. * * @example * ```tsx * <PresenceStack * max={4} * users={[ * { id: "1", initial: "B", color: "#5b8def", name: "Bea" }, * { id: "2", initial: "L", color: "#10b981", name: "Lior", status: "away" }, * { id: "3", initial: "S", color: "#f59e0b", name: "Sam", status: "idle" }, * ]} * onOverflowActivate={() => openRoster()} * /> * ``` * * @public */ export const PresenceStack = ({ ref, ...props }: PresenceStackProps & { ref?: React.Ref<HTMLDivElement> }) => { const { className, labels, max = 5, onOverflowActivate, users, ...rest } = props; const resolvedLabels = { ...DEFAULT_LABELS, ...labels }; const visible = max >= users.length ? users : users.slice(0, max); const hidden = users.length - visible.length; const handleOverflow = (): void => { onOverflowActivate?.(); }; return ( <div aria-label={resolvedLabels.region} className={cn("inline-flex items-center pl-2", className)} data-presence-stack ref={ref} role="group" {...rest} > {visible.map((user) => ( <Avatar key={user.id} user={user} /> ))} {hidden > 0 ? renderOverflow({ count: hidden, handleClick: handleOverflow, handlerProvided: Boolean(onOverflowActivate), labels: resolvedLabels, }) : null} </div> ); }; PresenceStack.displayName = "PresenceStack"; const renderOverflow = (input: { count: number; handleClick: () => void; handlerProvided: boolean; labels: Required<PresenceStackLabels>; }): React.ReactElement => { const text = `+${input.count}`; const aria = `${input.count} ${input.labels.overflowSuffix}`; const className = "relative -ml-2 inline-flex h-7 min-w-7 items-center justify-center rounded-full border-2 border-background bg-muted px-1.5 text-[10px] font-semibold text-muted-foreground shadow-sm"; if (input.handlerProvided) { return ( <button aria-label={aria} className={cn( className, "transition-colors hover:bg-muted/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", )} data-presence-stack-overflow onClick={input.handleClick} type="button" > {text} </button> ); } return ( <span aria-label={aria} className={className} data-presence-stack-overflow> {text} </span> ); };

Stories

Explore every variant and state in the interactive Storybook:

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Dependencies

  • @vllnt/ui@^0.3.0