Display

Status dot

A small coloured dot with a status in words, for service health, presence and live states.

Built on
<span>CSS animations
Client JavaScript
None
Works without React
Yes, as HTML and CSS
Source, gzipped
1.2 kB
Dependencies
React
  • APIOperational
  • WebhooksDegraded
  • BillingDown
Try its options

Loading its options.

Code
import { StatusDot } from "@/components/equip/status-dot";

const services = [
  { name: "API", status: "Operational", tone: "ok" },
  { name: "Webhooks", status: "Degraded", tone: "warn" },
  { name: "Billing", status: "Down", tone: "danger" },
] as const;

export default function Demo() {
  return (
    <ul style={{ display: "grid", gap: 10, width: 180, margin: 0, padding: 0, listStyle: "none", fontSize: 13 }}>
      {services.map((s) => (
        <li key={s.name} style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
          <span style={{ fontWeight: 650 }}>{s.name}</span>
          <StatusDot tone={s.tone} label={s.status} />
        </li>
      ))}
    </ul>
  );
}
Your versionchange its options in Look, This part
HTMLworks in any framework, with the part's CSS
<ul style="display:grid;gap:10px;width:180px;margin:0;padding:0;list-style:none;font-size:13px">
  <li style="display:flex;align-items:center;justify-content:space-between;gap:8px">
    <span style="font-weight:650">API</span>
    <span class="eq-status-dot" data-tone="ok">
      <span class="eq-status-dot-mark" aria-hidden="true"></span>
      <span class="eq-status-dot-label">Operational</span>
    </span>
  </li>
  <li style="display:flex;align-items:center;justify-content:space-between;gap:8px">
    <span style="font-weight:650">Webhooks</span>
    <span class="eq-status-dot" data-tone="warn">
      <span class="eq-status-dot-mark" aria-hidden="true"></span>
      <span class="eq-status-dot-label">Degraded</span>
    </span>
  </li>
  <li style="display:flex;align-items:center;justify-content:space-between;gap:8px">
    <span style="font-weight:650">Billing</span>
    <span class="eq-status-dot" data-tone="danger">
      <span class="eq-status-dot-mark" aria-hidden="true"></span>
      <span class="eq-status-dot-label">Down</span>
    </span>
  </li>
</ul>

Examples

Tones

Neutral is drawn as a hollow ring, so paused never looks like a faded version of healthy.

PausedHealthyRate limitedOffline
Code
import { StatusDot } from "@/components/equip/status-dot";

export default function Example() {
  return (
    <div style={{ display: "flex", flexWrap: "wrap", gap: 16 }}>
      <StatusDot label="Paused" />
      <StatusDot tone="ok" label="Healthy" />
      <StatusDot tone="warn" label="Rate limited" />
      <StatusDot tone="danger" label="Offline" />
    </div>
  );
}
HTMLworks in any framework, with the part's CSS
<div style="display:flex;flex-wrap:wrap;gap:16px">
  <span class="eq-status-dot" data-tone="neutral">
    <span class="eq-status-dot-mark" aria-hidden="true"></span>
    <span class="eq-status-dot-label">Paused</span>
  </span>
  <span class="eq-status-dot" data-tone="ok">
    <span class="eq-status-dot-mark" aria-hidden="true"></span>
    <span class="eq-status-dot-label">Healthy</span>
  </span>
  <span class="eq-status-dot" data-tone="warn">
    <span class="eq-status-dot-mark" aria-hidden="true"></span>
    <span class="eq-status-dot-label">Rate limited</span>
  </span>
  <span class="eq-status-dot" data-tone="danger">
    <span class="eq-status-dot-mark" aria-hidden="true"></span>
    <span class="eq-status-dot-label">Offline</span>
  </span>
</div>

Live and in progress

Keep pulse for something happening right now; with reduced motion the ring stays hidden and the dot is still.

LiveDeploying to eu-west-1Deployed 4 min ago
Code
import { StatusDot } from "@/components/equip/status-dot";

export default function Example() {
  return (
    <div style={{ display: "flex", flexWrap: "wrap", gap: 16 }}>
      <StatusDot tone="danger" pulse label="Live" />
      <StatusDot tone="warn" pulse label="Deploying to eu-west-1" />
      <StatusDot tone="ok" label="Deployed 4 min ago" />
    </div>
  );
}
HTMLworks in any framework, with the part's CSS
<div style="display:flex;flex-wrap:wrap;gap:16px">
  <span class="eq-status-dot" data-tone="danger" data-pulse="true">
    <span class="eq-status-dot-mark" aria-hidden="true"><span class="eq-status-dot-ring"></span></span>
    <span class="eq-status-dot-label">Live</span>
  </span>
  <span class="eq-status-dot" data-tone="warn" data-pulse="true">
    <span class="eq-status-dot-mark" aria-hidden="true"><span class="eq-status-dot-ring"></span></span>
    <span class="eq-status-dot-label">Deploying to eu-west-1</span>
  </span>
  <span class="eq-status-dot" data-tone="ok">
    <span class="eq-status-dot-mark" aria-hidden="true"></span>
    <span class="eq-status-dot-label">Deployed 4 min ago</span>
  </span>
</div>

Hidden label

Beside an avatar or a name the dot can stand alone; the hidden label still tells screen readers the status.

  • OnlineMaya Okafor
  • AwayDaniel Osei
  • OfflineLena Vogel
Code
import { StatusDot } from "@/components/equip/status-dot";

const people = [
  { name: "Maya Okafor", tone: "ok", status: "Online" },
  { name: "Daniel Osei", tone: "warn", status: "Away" },
  { name: "Lena Vogel", tone: "neutral", status: "Offline" },
] as const;

export default function Example() {
  return (
    <ul style={{ display: "grid", gap: 8, margin: 0, padding: 0, listStyle: "none", fontSize: 14 }}>
      {people.map((p) => (
        <li key={p.name} style={{ display: "flex", alignItems: "center", gap: 8 }}>
          <StatusDot tone={p.tone} label={p.status} hideLabel />
          {p.name}
        </li>
      ))}
    </ul>
  );
}
HTMLworks in any framework, with the part's CSS
<ul style="display:grid;gap:8px;margin:0;padding:0;list-style:none;font-size:14px">
  <li style="display:flex;align-items:center;gap:8px"><span class="eq-status-dot" data-tone="ok" data-hide-label="true"><span class="eq-status-dot-mark" aria-hidden="true"></span><span class="eq-status-dot-label">Online</span></span>Maya Okafor</li>
  <li style="display:flex;align-items:center;gap:8px"><span class="eq-status-dot" data-tone="warn" data-hide-label="true"><span class="eq-status-dot-mark" aria-hidden="true"></span><span class="eq-status-dot-label">Away</span></span>Daniel Osei</li>
  <li style="display:flex;align-items:center;gap:8px"><span class="eq-status-dot" data-tone="neutral" data-hide-label="true"><span class="eq-status-dot-mark" aria-hidden="true"></span><span class="eq-status-dot-label">Offline</span></span>Lena Vogel</li>
</ul>

Props

PropTypeDefaultWhat it does
labelReactNodenoneThe status in words. Required, even when hidden.
tone"neutral" | "ok" | "warn" | "danger""neutral"What the status means. Neutral draws a hollow ring.
hideLabelbooleanfalseHides the label visually; screen readers still read it.
pulsebooleanfalseA ring that grows from the dot and fades. Only for something live or in progress.
...restComponentProps<"span">noneEvery native span attribute.

Anatomy

root.eq-status-dotA span.
mark.eq-status-dot-markThe 8px dot. Hidden from assistive tech.
ring.eq-status-dot-ringOnly with pulse. Rests invisible, so with motion off the dot is still.
label.eq-status-dot-labelVisually hidden with hideLabel.

Notes

  • Colour alone does not reach everyone, so the label is required and must name the status: Down, not just a red dot.
  • Plain text with no role; if the status changes while the page is open, put it inside a role="status" region so the change is announced.
  • The pulse runs for four times --eq-dur-move, so it stops when the motion tokens go to zero under reduced motion.
  • Use pulse for one thing that is happening now, such as Live or Deploying; a page full of pulsing dots says nothing.
  • Renders on the server with no client JavaScript.
  • Distinct from badge, which frames the status in a filled label; a status dot is quieter and sits in running text or table cells.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/status-dot.tsx37 lines
import type { ComponentProps, ReactNode } from "react";
import "./status-dot.css";

export type StatusDotProps = ComponentProps<"span"> & {
  /** What the status means. The label has to say it too. */
  tone?: "neutral" | "ok" | "warn" | "danger";
  /** The status in words. Always required, even when hidden. */
  label: ReactNode;
  /** Hides the label visually. It is still read by screen readers. */
  hideLabel?: boolean;
  /** A ring that grows from the dot and fades, for a live or in-progress state only. */
  pulse?: boolean;
};

export function StatusDot({
  tone = "neutral",
  label,
  hideLabel = false,
  pulse = false,
  className,
  ...rest
}: StatusDotProps) {
  return (
    <span
      {...rest}
      className={["eq-status-dot", className].filter(Boolean).join(" ")}
      data-tone={tone}
      data-pulse={pulse || undefined}
      data-hide-label={hideLabel || undefined}
    >
      <span className="eq-status-dot-mark" aria-hidden="true">
        {pulse && <span className="eq-status-dot-ring" />}
      </span>
      <span className="eq-status-dot-label">{label}</span>
    </span>
  );
}
components/equip/status-dot.css82 lines
@layer equip {
  .eq-status-dot {
    --_tone: var(--eq-ink-2);

    display: inline-flex;
    align-items: center;
    gap: var(--eq-space-2);
    min-width: 0;
    color: var(--eq-ink);
    font: var(--eq-weight) var(--eq-text-s) / 1.25 var(--eq-font);
    vertical-align: middle;
  }

  .eq-status-dot[data-tone="ok"] {
    --_tone: var(--eq-ok);
  }

  .eq-status-dot[data-tone="warn"] {
    --_tone: var(--eq-warn);
  }

  .eq-status-dot[data-tone="danger"] {
    --_tone: var(--eq-danger);
  }

  .eq-status-dot-mark {
    position: relative;
    flex: none;
    width: 8px;
    height: 8px;
    border-radius: var(--eq-radius-full);
    background: var(--_tone);
  }

  /* Neutral is a hollow ring, so it never reads as a dimmed version of a coloured state. */
  .eq-status-dot[data-tone="neutral"] .eq-status-dot-mark {
    box-sizing: border-box;
    border: 1.5px solid var(--_tone);
    background: transparent;
  }

  /* The ring rests invisible, so with motion off (a zero duration ends the animation at once)
     the dot is simply still. The loop is tied to the motion tokens and stops with them. */
  .eq-status-dot-ring {
    position: absolute;
    inset: 0;
    border-radius: inherit;
    background: var(--_tone);
    opacity: 0;
    pointer-events: none;
    animation: eq-status-dot-pulse calc(var(--eq-dur-move) * 4) var(--eq-ease) infinite;
  }

  @keyframes eq-status-dot-pulse {
    from {
      opacity: 0.45;
      scale: 1;
    }

    to {
      opacity: 0;
      scale: 2.75;
    }
  }

  .eq-status-dot-label {
    min-width: 0;
    overflow-wrap: anywhere;
  }

  .eq-status-dot[data-hide-label] .eq-status-dot-label {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
    border: 0;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index