Web InnoventixFreeCode

Dot Badge

Original · free

dot status badges

byWeb InnoventixReact + Tailwind
badgedotbadges
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/badge-dot.json
badge-dot.tsx
"use client";

import {
  type KeyboardEvent as ReactKeyboardEvent,
  useId,
  useRef,
  useState,
} from "react";
import { motion, useReducedMotion } from "motion/react";

type Presence = "online" | "away" | "busy" | "offline";
type ServiceStatus = "operational" | "degraded" | "maintenance" | "down";

type PresenceMeta = {
  key: Presence;
  label: string;
  desc: string;
  dot: string;
  pill: string;
};

type StatusMeta = {
  label: string;
  dot: string;
  pill: string;
  pulse: boolean;
};

type ServiceItem = {
  id: string;
  name: string;
  region: string;
  status: ServiceStatus;
};

const PRESENCE: ReadonlyArray<PresenceMeta> = [
  {
    key: "online",
    label: "Online",
    desc: "Available for messages and calls",
    dot: "bg-emerald-500",
    pill: "bg-emerald-50 text-emerald-700 ring-emerald-600/20 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-400/25",
  },
  {
    key: "away",
    label: "Away",
    desc: "Idle for the last 12 minutes",
    dot: "bg-amber-500",
    pill: "bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-400/25",
  },
  {
    key: "busy",
    label: "Do not disturb",
    desc: "Notifications are muted",
    dot: "bg-rose-500",
    pill: "bg-rose-50 text-rose-700 ring-rose-600/20 dark:bg-rose-500/10 dark:text-rose-300 dark:ring-rose-400/25",
  },
  {
    key: "offline",
    label: "Offline",
    desc: "Last seen today at 09:41",
    dot: "bg-slate-400 dark:bg-slate-500",
    pill: "bg-slate-100 text-slate-600 ring-slate-500/20 dark:bg-slate-500/10 dark:text-slate-300 dark:ring-slate-400/20",
  },
];

const SERVICE_STATUS: Record<ServiceStatus, StatusMeta> = {
  operational: {
    label: "Operational",
    dot: "bg-emerald-500",
    pill: "bg-emerald-50 text-emerald-700 ring-emerald-600/20 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-400/25",
    pulse: false,
  },
  degraded: {
    label: "Degraded",
    dot: "bg-amber-500",
    pill: "bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-400/25",
    pulse: true,
  },
  maintenance: {
    label: "Maintenance",
    dot: "bg-sky-500",
    pill: "bg-sky-50 text-sky-700 ring-sky-600/20 dark:bg-sky-500/10 dark:text-sky-300 dark:ring-sky-400/25",
    pulse: false,
  },
  down: {
    label: "Major outage",
    dot: "bg-rose-500",
    pill: "bg-rose-50 text-rose-700 ring-rose-600/20 dark:bg-rose-500/10 dark:text-rose-300 dark:ring-rose-400/25",
    pulse: true,
  },
};

const SERVICE_CYCLE: ReadonlyArray<ServiceStatus> = [
  "operational",
  "degraded",
  "maintenance",
  "down",
];

const INITIAL_SERVICES: ReadonlyArray<ServiceItem> = [
  { id: "gateway", name: "API Gateway", region: "us-east-1", status: "operational" },
  { id: "realtime", name: "Realtime Sync", region: "eu-west-2", status: "degraded" },
  { id: "postgres", name: "Postgres Primary", region: "us-east-1", status: "operational" },
  { id: "storage", name: "Object Storage", region: "ap-south-1", status: "maintenance" },
  { id: "webhooks", name: "Webhook Delivery", region: "us-west-2", status: "down" },
];

function StatusDot({
  color,
  pulse,
  reduced,
  size = "sm",
}: {
  color: string;
  pulse: boolean;
  reduced: boolean;
  size?: "sm" | "lg";
}) {
  const box = size === "lg" ? "h-3.5 w-3.5" : "h-2 w-2";
  return (
    <span className={`relative flex ${box}`}>
      {pulse && !reduced ? (
        <span
          aria-hidden="true"
          className={`dotbadge-anim absolute inline-flex h-full w-full rounded-full opacity-70 ${color} [animation:dotbadge-ping_1.8s_cubic-bezier(0,0,0.2,1)_infinite]`}
        />
      ) : null}
      <span className={`relative inline-flex ${box} rounded-full ${color}`} />
    </span>
  );
}

function DotBadge({
  label,
  dot,
  pill,
  pulse,
  reduced,
}: {
  label: string;
  dot: string;
  pill: string;
  pulse: boolean;
  reduced: boolean;
}) {
  return (
    <span
      className={`inline-flex items-center gap-x-1.5 rounded-full px-2.5 py-1 text-xs font-medium ring-1 ring-inset ${pill}`}
    >
      <StatusDot color={dot} pulse={pulse} reduced={reduced} />
      {label}
    </span>
  );
}

function CheckIcon() {
  return (
    <svg
      viewBox="0 0 20 20"
      fill="currentColor"
      aria-hidden="true"
      className="h-4 w-4"
    >
      <path
        fillRule="evenodd"
        d="M16.7 5.3a1 1 0 0 1 0 1.4l-7.5 7.5a1 1 0 0 1-1.4 0L3.3 9.7a1 1 0 1 1 1.4-1.4l3.8 3.79 6.8-6.8a1 1 0 0 1 1.4 0Z"
        clipRule="evenodd"
      />
    </svg>
  );
}

export default function DotStatusBadges() {
  const reduced = useReducedMotion() ?? false;
  const labelId = useId();
  const [presence, setPresence] = useState<Presence>("online");
  const [services, setServices] = useState<ReadonlyArray<ServiceItem>>(INITIAL_SERVICES);
  const [announce, setAnnounce] = useState("");
  const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);

  const current = PRESENCE.find((p) => p.key === presence) ?? PRESENCE[0];
  const previewPulse = presence === "online" || presence === "busy";

  function selectPresence(key: Presence) {
    setPresence(key);
    const meta = PRESENCE.find((p) => p.key === key);
    if (meta) setAnnounce(`Presence set to ${meta.label}`);
  }

  function onOptionKeyDown(e: ReactKeyboardEvent<HTMLButtonElement>, index: number) {
    const last = PRESENCE.length - 1;
    let next = index;
    switch (e.key) {
      case "ArrowDown":
      case "ArrowRight":
        next = index === last ? 0 : index + 1;
        break;
      case "ArrowUp":
      case "ArrowLeft":
        next = index === 0 ? last : index - 1;
        break;
      case "Home":
        next = 0;
        break;
      case "End":
        next = last;
        break;
      default:
        return;
    }
    e.preventDefault();
    selectPresence(PRESENCE[next].key);
    optionRefs.current[next]?.focus();
  }

  function cycleService(id: string) {
    const svc = services.find((s) => s.id === id);
    if (!svc) return;
    const idx = SERVICE_CYCLE.indexOf(svc.status);
    const nextStatus = SERVICE_CYCLE[(idx + 1) % SERVICE_CYCLE.length];
    setServices((prev) =>
      prev.map((s) => (s.id === id ? { ...s, status: nextStatus } : s)),
    );
    setAnnounce(`${svc.name} is now ${SERVICE_STATUS[nextStatus].label}`);
  }

  const focusRing =
    "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900";

  return (
    <section className="relative w-full bg-slate-50 px-6 py-16 sm:py-20 dark:bg-slate-950">
      <div className="mx-auto max-w-3xl">
        <div className="mb-10 max-w-xl">
          <p className="text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
            Presence &amp; uptime
          </p>
          <h2 className="mt-2 text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-slate-50">
            Dot status badges
          </h2>
          <p className="mt-3 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
            A compact status system: presence for people, health for services. Pick a
            presence with the arrow keys, or activate a service row to cycle its state.
          </p>
        </div>

        {/* Presence card */}
        <div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6 dark:border-slate-800 dark:bg-slate-900">
          <div className="grid gap-6 md:grid-cols-[1fr_1.1fr]">
            {/* Live preview */}
            <div className="flex flex-col justify-between rounded-xl bg-slate-50 p-5 dark:bg-slate-800/40">
              <div className="flex items-center gap-4">
                <div className="relative shrink-0">
                  <div className="flex h-14 w-14 items-center justify-center rounded-full bg-gradient-to-br from-indigo-500 to-violet-500 text-base font-semibold text-white">
                    MO
                  </div>
                  <span className="absolute -bottom-0.5 -right-0.5 rounded-full p-[3px] bg-slate-50 dark:bg-slate-800">
                    <StatusDot
                      color={current.dot}
                      pulse={previewPulse}
                      reduced={reduced}
                      size="lg"
                    />
                  </span>
                </div>
                <div className="min-w-0">
                  <p className="truncate text-sm font-semibold text-slate-900 dark:text-slate-100">
                    Maya Okonkwo
                  </p>
                  <p className="truncate text-xs text-slate-500 dark:text-slate-400">
                    Product designer
                  </p>
                </div>
              </div>

              <motion.div
                key={presence}
                initial={reduced ? false : { opacity: 0, y: 6 }}
                animate={{ opacity: 1, y: 0 }}
                transition={reduced ? { duration: 0 } : { duration: 0.25, ease: "easeOut" }}
                className="mt-5"
              >
                <DotBadge
                  label={current.label}
                  dot={current.dot}
                  pill={current.pill}
                  pulse={previewPulse}
                  reduced={reduced}
                />
                <p className="mt-2 text-xs leading-relaxed text-slate-500 dark:text-slate-400">
                  {current.desc}
                </p>
              </motion.div>
            </div>

            {/* Radio group */}
            <div>
              <p
                id={labelId}
                className="mb-2 text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400"
              >
                Set your status
              </p>
              <div
                role="radiogroup"
                aria-labelledby={labelId}
                className="relative flex flex-col gap-1"
              >
                {PRESENCE.map((p, i) => {
                  const active = p.key === presence;
                  return (
                    <button
                      key={p.key}
                      ref={(el) => {
                        optionRefs.current[i] = el;
                      }}
                      type="button"
                      role="radio"
                      aria-checked={active}
                      tabIndex={active ? 0 : -1}
                      onClick={() => selectPresence(p.key)}
                      onKeyDown={(e) => onOptionKeyDown(e, i)}
                      className={`relative flex rounded-xl ${focusRing}`}
                    >
                      {active ? (
                        <motion.span
                          layoutId="dotbadge-active-presence"
                          aria-hidden="true"
                          className="absolute inset-0 rounded-xl bg-white shadow-sm ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700"
                          transition={
                            reduced
                              ? { duration: 0 }
                              : { type: "spring", stiffness: 520, damping: 42 }
                          }
                        />
                      ) : null}
                      <span className="relative z-10 flex w-full items-center gap-3 px-3 py-2.5 text-left">
                        <StatusDot
                          color={p.dot}
                          pulse={p.key === "online"}
                          reduced={reduced}
                        />
                        <span className="flex min-w-0 flex-col">
                          <span className="text-sm font-medium text-slate-900 dark:text-slate-100">
                            {p.label}
                          </span>
                          <span className="truncate text-xs text-slate-500 dark:text-slate-400">
                            {p.desc}
                          </span>
                        </span>
                        <span
                          className={`ml-auto text-indigo-600 transition-opacity dark:text-indigo-400 ${
                            active ? "opacity-100" : "opacity-0"
                          }`}
                        >
                          <CheckIcon />
                        </span>
                      </span>
                    </button>
                  );
                })}
              </div>
            </div>
          </div>
        </div>

        {/* Services card */}
        <div className="mt-6 rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
          <div className="flex items-center justify-between border-b border-slate-200 px-5 py-4 dark:border-slate-800">
            <div>
              <h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">
                System status
              </h3>
              <p className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">
                Activate a row to cycle its status
              </p>
            </div>
            <span className="hidden text-xs text-slate-400 sm:inline dark:text-slate-500">
              5 services
            </span>
          </div>

          <ul className="divide-y divide-slate-100 dark:divide-slate-800">
            {services.map((s) => {
              const meta = SERVICE_STATUS[s.status];
              return (
                <li key={s.id}>
                  <button
                    type="button"
                    onClick={() => cycleService(s.id)}
                    aria-label={`${s.name}, ${s.region}. Status: ${meta.label}. Activate to change status.`}
                    className={`flex w-full items-center justify-between gap-3 px-5 py-3.5 text-left transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/50 ${focusRing} focus-visible:ring-offset-0`}
                  >
                    <span className="flex min-w-0 items-center gap-3">
                      <StatusDot
                        color={meta.dot}
                        pulse={meta.pulse}
                        reduced={reduced}
                      />
                      <span className="flex min-w-0 flex-col">
                        <span className="truncate text-sm font-medium text-slate-900 dark:text-slate-100">
                          {s.name}
                        </span>
                        <span className="truncate text-xs text-slate-500 dark:text-slate-400">
                          {s.region}
                        </span>
                      </span>
                    </span>
                    <DotBadge
                      label={meta.label}
                      dot={meta.dot}
                      pill={meta.pill}
                      pulse={meta.pulse}
                      reduced={reduced}
                    />
                  </button>
                </li>
              );
            })}
          </ul>
        </div>

        <span aria-live="polite" className="sr-only">
          {announce}
        </span>
      </div>

      <style>{`
@keyframes dotbadge-ping {
  75%, 100% { transform: scale(2.2); opacity: 0; }
}
@keyframes dotbadge-pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.5; }
}
@media (prefers-reduced-motion: reduce) {
  .dotbadge-anim { animation: none !important; }
}
      `}</style>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →