Status
Status dots and pills: online breathes with a pulse ring; offline, delayed and idle each get a semantic color.
Site monitor Live
- 42 ms99.98%OnlineMain sitemio.example
- 1280 ms99.41%DelayedDocsdocs.mio.example
- —87.20%OfflineLegacy APIapi-v1.mio.example
- 96 ms—IdleStagingstaging.mio.example
'use client';
import { Card } from '@/components/ui/card';
import { StatusBadge, StatusDot, type Status } from '@/components/ui/status';
const sites: { name: string; host: string; status: Status; label: string; ms: number | null; uptime: string }[] = [
{ name: 'Main site', host: 'mio.example', status: 'online', label: 'Online', ms: 42, uptime: '99.98%' },
{ name: 'Docs', host: 'docs.mio.example', status: 'delay', label: 'Delayed', ms: 1280, uptime: '99.41%' },
{ name: 'Legacy API', host: 'api-v1.mio.example', status: 'offline', label: 'Offline', ms: null, uptime: '87.20%' },
{ name: 'Staging', host: 'staging.mio.example', status: 'idle', label: 'Idle', ms: 96, uptime: '—' },
];
export default function StatusDemo() {
return (
<Card className="w-full max-w-[460px] overflow-hidden">
<div className="flex items-center justify-between px-4 pb-2 pt-3.5">
<span className="text-sm font-medium text-text-primary">Site monitor</span>
<span className="inline-flex items-center gap-1.5 text-[12px] text-text-muted">
<StatusDot status="online" size="sm" /> Live
</span>
</div>
<ul className="divide-y divide-hairline">
{sites.map((s) => (
<li key={s.host} className="flex items-center gap-3 px-4 py-2.5">
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-text-primary">{s.name}</div>
<div className="truncate font-mono text-[12px] text-text-muted">{s.host}</div>
</div>
<span className="w-16 text-right text-[13px] text-text-secondary tabular-nums">{s.ms == null ? '—' : `${s.ms} ms`}</span>
<span className="hidden w-14 text-right text-[13px] text-text-muted tabular-nums sm:inline">{s.uptime}</span>
<StatusBadge status={s.status} className="w-[72px] justify-center">{s.label}</StatusBadge>
</li>
))}
</ul>
</Card>
);
}
Installation
'use client';
import { motion } from 'motion/react';
import type { HTMLAttributes, ReactNode } from 'react';
import { twMerge } from 'tailwind-merge';
import { useReducedMotionPref } from '@/lib/use-reduced-motion';
export type Status = 'online' | 'offline' | 'degraded' | 'delay' | 'idle';
const dotColor: Record<Status, string> = {
online: 'bg-success',
offline: 'bg-danger',
degraded: 'bg-warning',
delay: 'bg-warning',
idle: 'bg-text-muted',
};
const badgeTone: Record<Status, string> = {
online: 'bg-success/12 text-success',
offline: 'bg-danger/12 text-danger',
degraded: 'bg-warning/14 text-warning',
delay: 'bg-warning/14 text-warning',
idle: 'bg-ghost-selected text-text-secondary',
};
const defaultLabel: Record<Status, string> = {
online: 'Online',
offline: 'Offline',
degraded: 'Degraded',
delay: 'Delayed',
idle: 'Idle',
};
const dotSize = { sm: 'size-1.5', md: 'size-2', lg: 'size-2.5' };
export interface StatusDotProps {
status: Status;
size?: keyof typeof dotSize;
/** 呼吸脉冲环;默认仅 online 开启。减弱动效时始终关闭 */
pulse?: boolean;
/** 提供后作为读屏文本(role="img"),否则 aria-hidden */
label?: string;
className?: string;
}
export function StatusDot({ status, size = 'md', pulse, label, className }: StatusDotProps) {
const reduce = useReducedMotionPref();
const showPulse = !reduce && (pulse ?? status === 'online');
return (
<span
role={label ? 'img' : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
className={twMerge('relative inline-flex shrink-0', dotSize[size], className)}
>
{showPulse && (
<motion.span
className={twMerge('absolute inset-0 rounded-full', dotColor[status])}
initial={{ scale: 1, opacity: 0.55 }}
animate={{ scale: 2.6, opacity: 0 }}
transition={{ duration: 2.4, ease: [0.4, 0, 0.6, 1], repeat: Infinity }}
/>
)}
<span className={twMerge('relative inline-flex size-full rounded-full', dotColor[status])} />
</span>
);
}
export interface StatusBadgeProps extends HTMLAttributes<HTMLSpanElement> {
status: Status;
/** 文本;省略时使用英文默认值(Online / Offline / Degraded / Delayed / Idle) */
children?: ReactNode;
pulse?: boolean;
}
/** 胶囊:圆点 + 文本 */
export function StatusBadge({ status, children, pulse, className, ...p }: StatusBadgeProps) {
return (
<span
className={twMerge(
'inline-flex h-6 items-center gap-1.5 rounded-full px-2.5 text-[12px] font-medium whitespace-nowrap',
badgeTone[status],
className,
)}
{...p}
>
<StatusDot status={status} size="sm" pulse={pulse} />
{children ?? defaultLabel[status]}
</span>
);
}
Usage
import { StatusDot, StatusBadge } from "@/components/ui/status";
<StatusDot status="online" label="Online" />
<StatusBadge status="delay">Delayed</StatusBadge>| Prop | Type | Default | Notes |
|---|---|---|---|
status | online offline degraded delay idle | — | Semantic state |
size | sm md lg | md | Dot size |
pulse | boolean | online only | Pulse ring; off with reduced motion |
label | string | — | Screen-reader text |