Skip to content
  1. Records
  2. Status dot

Status dot

A small colored dot that gives a state at a glance: live or sandbox, healthy or disabled, something pending.

Active

import { StatusDot } from '@/components/status-dot'

export default function Example() {
  return (
    <p className="flex items-center gap-1.5 text-sm/6 text-zinc-500">
      <StatusDot color="green" />
      Active
    </p>
  )
}
<script setup lang="ts">
import StatusDot from '@/components/status-dot.vue'
</script>

<template>
  <p class="flex items-center gap-1.5 text-sm/6 text-zinc-500">
    <StatusDot color="green" />
    Active
  </p>
</template>

Installation

Copy these files into the folder where you keep your components. They import each other with relative paths, so the folder can live anywhere. The packages they rely on are listed on the Installation page.

import clsx from 'clsx'
import type React from 'react'

const colors = {
  green: 'bg-green-500',
  amber: 'bg-amber-500',
  red: 'bg-red-500',
  zinc: 'bg-zinc-400',
  blue: 'bg-blue-500',
}

export function StatusDot({
  color = 'zinc',
  size = 'sm',
  label,
  className,
  ...props
}: {
  color?: keyof typeof colors
  size?: 'sm' | 'md'
  label?: string
} & React.ComponentPropsWithoutRef<'span'>) {
  return (
    <span
      role={label ? 'img' : undefined}
      aria-label={label}
      aria-hidden={label ? undefined : 'true'}
      {...props}
      className={clsx(className, 'inline-block shrink-0 rounded-full align-middle forced-colors:bg-[CanvasText] forced-colors:forced-color-adjust-none', size === 'sm' ? 'size-1.5' : 'size-2', colors[color])}
    />
  )
}
<script setup lang="ts">
const colors = {
  green: 'bg-green-500',
  amber: 'bg-amber-500',
  red: 'bg-red-500',
  zinc: 'bg-zinc-400',
  blue: 'bg-blue-500',
}

withDefaults(
  defineProps<{
    color?: keyof typeof colors
    size?: 'sm' | 'md'
    label?: string
  }>(),
  { color: 'zinc', size: 'sm' }
)
</script>

<template>
  <span
    :role="label ? 'img' : undefined"
    :aria-label="label"
    :aria-hidden="label ? undefined : 'true'"
    class="inline-block shrink-0 rounded-full align-middle forced-colors:bg-[CanvasText] forced-colors:forced-color-adjust-none"
    :class="[size === 'sm' ? 'size-1.5' : 'size-2', colors[color]]"
  />
</template>

Component API

PropDefaultDescription
StatusDotrenders a <span> element
colorzincOne of `green`, `amber`, `red`, `zinc`, `blue`.
sizesm`sm` (6px) next to a label, `md` (8px) in a list column or a legend.
label-Announced meaning when no adjacent text carries it. Without it the dot is hidden from assistive tech, so pass it whenever the color alone tells states apart.
PropDefaultDescription
StatusDotrenders a <span> element
colorzincOne of `green`, `amber`, `red`, `zinc`, `blue`.
sizesm`sm` (6px) next to a label, `md` (8px) in a list column or a legend.
label-Announced meaning when no adjacent text carries it. Without it the dot is hidden from assistive tech.

Examples

Basic example

Next to a label the dot is decorative: the text carries the meaning, the dot is hidden from screen readers:

Active

import { StatusDot } from '@/components/status-dot'

export default function Example() {
  return (
    <p className="flex items-center gap-1.5 text-sm/6 text-zinc-500">
      <StatusDot color="green" />
      Active
    </p>
  )
}
<script setup lang="ts">
import StatusDot from '@/components/status-dot.vue'
</script>

<template>
  <p class="flex items-center gap-1.5 text-sm/6 text-zinc-500">
    <StatusDot color="green" />
    Active
  </p>
</template>

In a list

Use size="md" as the first column of a list row, where the dot has more room. Only the color tells the states apart here, so give each dot a label:

  • https://api.example.com/hooks/payments
  • https://staging.example.com/hooks
  • https://api.example.com/hooks/refunds
<ul role="list" className="divide-y divide-zinc-950/5">
  {endpoints.map((endpoint) => (
    <li key={endpoint.url} className="flex items-center gap-3 py-3">
      <StatusDot
        size="md"
        color={endpoint.enabled ? 'green' : 'zinc'}
        label={endpoint.enabled ? 'Enabled' : 'Disabled'}
      />
      <span className="font-mono text-sm/6">{endpoint.url}</span>
    </li>
  ))}
</ul>
<template>
  <ul role="list" class="divide-y divide-zinc-950/5">
    <li v-for="endpoint in endpoints" :key="endpoint.url" class="flex items-center gap-3 py-3">
      <StatusDot
        size="md"
        :color="endpoint.enabled ? 'green' : 'zinc'"
        :label="endpoint.enabled ? 'Enabled' : 'Disabled'"
      />
      <span class="font-mono text-sm/6">{{ endpoint.url }}</span>
    </li>
  </ul>
</template>

Inside a badge

A badge already spaces its children, so the dot slots in before the label:

Sandbox
<Badge color="amber">
  <StatusDot color="amber" />
  Sandbox
</Badge>
<template>
  <Badge color="amber">
    <StatusDot color="amber" />
    Sandbox
  </Badge>
</template>

On its own

When nothing next to it says what it means, pass a label: the dot becomes an image with an accessible name:

<a href="/settings/compliance" className="flex items-center justify-between">
  Compliance
  <StatusDot color="red" label="Action required" />
</a>
<template>
  <a href="/settings/compliance" class="flex items-center justify-between">
    Compliance
    <StatusDot color="red" label="Action required" />
  </a>
</template>