Data Display

Status Dot

Presence indicator with online, offline, busy, and away variants that emits a single expanding ping ring on status change.

Install

npx shadcn@latest add @paragon/status-dot

status-dot.tsx

"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>
  );
}