Presence indicator with online, offline, busy, and away variants that emits a single expanding ping ring on status change.
npx shadcn@latest add @paragon/status-dot"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
type Status = "online" | "offline" | "busy" | "away";
const statusColor: Record<Status, string> = {
online: "bg-emerald-500",
offline: "bg-muted-foreground/40",
busy: "bg-red-500",
away: "bg-amber-500",
};
const statusLabel: Record<Status, string> = {
online: "Online",
offline: "Offline",
busy: "Busy",
away: "Away",
};
export interface StatusDotProps extends React.ComponentProps<"span"> {
status?: Status;
/** Visible text label. Without it, the status is announced via sr-only text. */
label?: string;
/** Dot diameter in px. */
size?: number;
}
/**
* Presence indicator. A status change emits ONE expanding ping ring —
* state indication, not ambient noise — so it never loops. Reduced motion
* drops the ping and keeps the color change.
*/
export function StatusDot({
status = "online",
label,
size = 8,
className,
...props
}: StatusDotProps) {
const prev = React.useRef(status);
const [pingKey, setPingKey] = React.useState(0);
React.useEffect(() => {
if (prev.current !== status) {
prev.current = status;
setPingKey((k) => k + 1);
}
}, [status]);
return (
<span
className={cn("inline-flex items-center gap-2", className)}
{...props}
>
<style href="paragon-status-dot" precedence="paragon">{`
@keyframes pg-status-ping {
from { transform: scale(1); opacity: 0.6; }
to { transform: scale(2.75); opacity: 0; }
}
[data-status-ping] {
animation: pg-status-ping 500ms var(--ease-out) forwards;
}
@media (prefers-reduced-motion: reduce) {
[data-status-ping] { animation: none; opacity: 0; }
}
`}</style>
<span
className="relative inline-flex shrink-0"
style={{ width: size, height: size }}
>
{pingKey > 0 && (
<span
key={pingKey}
data-status-ping=""
aria-hidden
className={cn(
"pointer-events-none absolute inset-0 rounded-full",
statusColor[status],
)}
/>
)}
<span
className={cn(
"relative inline-flex h-full w-full rounded-full transition-colors duration-150",
statusColor[status],
)}
/>
</span>
{label ? (
<span className="text-[13px] leading-none text-muted-foreground">
{label}
</span>
) : (
<span className="sr-only">{statusLabel[status]}</span>
)}
</span>
);
}