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.
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.jsonSource
"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.