Semaphore
The uptime bar, alive — labeled status-tracker rows that stack into an ops wall with a shared time axis. Boots with a sub-second left-to-right ignition cascade on first view, pulses the newest bucket, slides new buckets in on a live interval (suspended offscreen), and raises a hover chip with bucket detail. Statuses are shape-and-brightness coded, not just hue: dim operational, notched degraded, saturated glowing incident. Pure CSS animation, zero dependencies, visually-hidden textual summary per row, static wall under reduced motion.
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
import { useVisibilityPause } from "@/registry/default/hooks/use-visibility-pause/use-visibility-pause";
export interface SemaphoreSegment {
/** Status key, resolved against the `colors` map (e.g. "operational"). */
status: string;
/** Short bucket label shown in the hover chip (e.g. "12 min ago"). */
label?: string;
/** Longer detail line for the hover chip (e.g. "p99 latency above threshold"). */
detail?: string;
}
export interface SemaphoreRow {
/** Row label rendered left of the track (e.g. a service name). */
label?: string;
/** Time buckets, oldest first — the last segment is "now" and carries the pulse. */
segments: SemaphoreSegment[];
}
export interface SemaphoreStatusStyle {
/** CSS color of segments with this status. */
color: string;
/** Resting opacity, `0`–`1` — the brightness half of the status coding. @default 1 */
opacity?: number;
/** Cut a hairline notch across the segment (the "degraded" shape cue). @default false */
notch?: boolean;
/** Saturated emphasis: soft glow + slightly taller (the "incident" treatment). @default false */
emphasis?: boolean;
}
export interface SemaphoreProps extends Omit<React.ComponentPropsWithoutRef<"div">, "children"> {
/**
* Single-row tracker: time buckets, oldest first. Ignored when `rows` is
* set. Omit both for a seeded sample row.
*/
segments?: SemaphoreSegment[];
/**
* Wall composition: multiple labeled tracker rows sharing one time axis.
* Takes precedence over `segments`.
*/
rows?: SemaphoreRow[];
/**
* Status → style map, merged over the built-in
* `operational` / `degraded` / `incident` / `empty` styles. A plain color
* string keeps that status's default shape/brightness coding.
*/
colors?: Record<string, string | SemaphoreStatusStyle>;
/**
* Live mode: advance every N milliseconds — the oldest bucket drops, a new
* one slides in from the right. `0` = static (no timers, zero JS work).
* Suspends offscreen and on hidden tabs. @default 0
*/
liveInterval?: number;
/**
* Produces the incoming bucket in live mode. Defaults to a deterministic
* mostly-operational feed.
*/
nextSegment?: (rowIndex: number, tick: number) => SemaphoreSegment;
/**
* Boot cascade on first scroll into view (once): segments ignite
* left-to-right like a self-test. @default true
*/
cascade?: boolean;
/** Cascade speed multiplier — `2` boots twice as fast. @default 1 */
cascadeSpeed?: number;
/** Breathing pulse on each row's newest segment. @default true */
pulse?: boolean;
/** Segment width in px. @default 6 */
segmentWidth?: number;
/** Segment height in px. @default 26 */
segmentHeight?: number;
/** Gap between segments in px. @default 3 */
gap?: number;
/** Segment corner radius in px. @default 2 */
radius?: number;
/** Shared time-axis labels rendered under the wall: `[oldest, newest]`. */
axis?: readonly [string, string];
/** Show the hover chip with bucket detail. @default true */
showChip?: boolean;
/** Custom hover-chip contents; the default chip shows status, label, and detail. */
renderChip?: (
segment: SemaphoreSegment,
context: { rowIndex: number; segmentIndex: number; rowLabel?: string }
) => React.ReactNode;
/** Freeze everything at the fully-populated wall (also stops live mode). @default false */
paused?: boolean;
}
/* Railway-semaphore discipline: statuses are shape-and-brightness coded, not
* just hue. Operational sits dim and calm, degraded carries a hairline notch,
* incident is the only saturated (and glowing, slightly taller) element. */
const DEFAULT_STATUS_STYLES: Record<string, Required<SemaphoreStatusStyle>> = {
operational: { color: "#8b9bb4", opacity: 0.34, notch: false, emphasis: false },
degraded: { color: "#c2913c", opacity: 0.85, notch: true, emphasis: false },
incident: { color: "#e0552e", opacity: 1, notch: false, emphasis: true },
empty: { color: "#57616f", opacity: 0.14, notch: false, emphasis: false },
};
const FALLBACK_STYLE = DEFAULT_STATUS_STYLES.operational;
/* Boot-cascade budget: full wall self-tests in under a second at speed 1. */
const SWEEP_S = 0.5; // left-to-right sweep across the widest row
const IGNITE_S = 0.35; // one segment's flare-and-settle
const ROW_OFFSET_S = 0.035; // slight per-row lag for a diagonal wavefront
const STYLE = `
[data-crucible="semaphore"] .sem-seg {
position: relative;
flex: none;
width: var(--sem-w);
height: var(--sem-h);
border-radius: var(--sem-r);
opacity: var(--seg-o, 1);
}
[data-crucible="semaphore"] .sem-notch {
position: absolute;
left: 0;
right: 0;
top: 30%;
height: 2px;
background: rgba(8, 10, 14, 0.9);
pointer-events: none;
}
@keyframes crucible-sem-ignite {
0% { opacity: 0; filter: brightness(0.4); }
45% { opacity: 1; filter: brightness(2.1); }
/* no 100% frame: settles to the element's own status opacity/brightness */
}
@keyframes crucible-sem-pulse {
0%, 100% { opacity: var(--seg-o, 1); filter: brightness(1); }
50% { opacity: calc(var(--seg-o, 1) + 0.45); filter: brightness(1.4); }
}
@keyframes crucible-sem-enter {
0% { opacity: 0; transform: translateX(calc(var(--sem-w) + 4px)); }
}
@keyframes crucible-sem-chip {
0% { opacity: 0; transform: translate(-50%, 3px); }
}
[data-crucible="semaphore"][data-boot="pending"] .sem-seg { opacity: 0; }
[data-crucible="semaphore"][data-boot="go"] .sem-seg {
animation: crucible-sem-ignite var(--sem-ignite) cubic-bezier(0.2, 0.7, 0.3, 1) var(--seg-delay, 0s) backwards;
}
[data-crucible="semaphore"][data-boot="go"][data-pulse="true"] .sem-latest {
animation:
crucible-sem-ignite var(--sem-ignite) cubic-bezier(0.2, 0.7, 0.3, 1) var(--seg-delay, 0s) backwards,
crucible-sem-pulse 2.4s ease-in-out calc(var(--seg-delay, 0s) + var(--sem-ignite)) infinite;
}
[data-crucible="semaphore"][data-boot="done"][data-pulse="true"] .sem-latest {
animation: crucible-sem-pulse 2.4s ease-in-out infinite;
}
[data-crucible="semaphore"][data-boot="done"] .sem-new {
animation: crucible-sem-enter 0.35s ease-out backwards;
}
[data-crucible="semaphore"][data-boot="done"][data-pulse="true"] .sem-latest.sem-new {
animation:
crucible-sem-enter 0.35s ease-out backwards,
crucible-sem-pulse 2.4s ease-in-out 0.35s infinite;
}
[data-crucible="semaphore"] .sem-chip {
position: absolute;
bottom: calc(100% + 8px);
transform: translateX(-50%);
z-index: 20;
pointer-events: none;
white-space: nowrap;
animation: crucible-sem-chip 0.16s ease-out;
}
/* Paused → the designed resting frame: fully-populated wall, no motion. */
[data-crucible="semaphore"][data-paused="true"] .sem-seg {
animation: none !important;
opacity: var(--seg-o, 1) !important;
}
/* Reduced motion → same static wall: no cascade, no pulse, no slide. */
@media (prefers-reduced-motion: reduce) {
[data-crucible="semaphore"] .sem-seg {
animation: none !important;
opacity: var(--seg-o, 1) !important;
}
[data-crucible="semaphore"] .sem-chip { animation: none !important; }
}
`;
/** Deterministic PRNG — no Math.random(), so SSR and hydration always agree. */
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seededSegments(seed: number, count: number): SemaphoreSegment[] {
const rand = mulberry32(seed);
return Array.from({ length: count }, (_, i) => {
const v = rand();
const status = v > 0.985 ? "incident" : v > 0.9 ? "degraded" : "operational";
return {
status,
label: i === count - 1 ? "Just now" : `${count - 1 - i} min ago`,
detail:
status === "incident"
? "Health check failed"
: status === "degraded"
? "Latency above threshold"
: "All checks passing",
};
});
}
const DEFAULT_ROWS: SemaphoreRow[] = [{ segments: seededSegments(42, 48) }];
function defaultNextSegment(rowIndex: number, tick: number): SemaphoreSegment {
const v = mulberry32(rowIndex * 92821 + tick * 68917)();
const status = v > 0.94 ? "degraded" : "operational";
return {
status,
label: "Just now",
detail: status === "degraded" ? "Latency above threshold" : "All checks passing",
};
}
function resolveStatusStyle(
status: string,
colors: SemaphoreProps["colors"]
): Required<SemaphoreStatusStyle> {
const base = DEFAULT_STATUS_STYLES[status] ?? FALLBACK_STYLE;
const user = colors?.[status];
if (typeof user === "string") return { ...base, color: user };
if (user) return { ...base, ...user };
return base;
}
function summarizeRow(row: SemaphoreRow, rowIndex: number): string {
const total = row.segments.length;
const counts = new Map<string, number>();
for (const seg of row.segments) counts.set(seg.status, (counts.get(seg.status) ?? 0) + 1);
const parts = [...counts].map(([status, n]) => `${n} ${status}`);
const latest = row.segments[total - 1];
return `${row.label ?? `Row ${rowIndex + 1}`}: ${total} buckets — ${parts.join(", ")}. Latest: ${
latest?.status ?? "none"
}.`;
}
interface InternalSegment extends SemaphoreSegment {
__id: string;
}
interface InternalRow {
label?: string;
segments: InternalSegment[];
}
/**
* Semaphore — the uptime bar, alive. Rows of thin time-bucket segments colored
* by status, stacking into a labeled status wall with a shared time axis. On
* first scroll into view the wall boots: segments ignite left-to-right in a
* sub-second self-test cascade. Each row's newest bucket carries a breathing
* pulse, and in live mode new buckets slide in from the right on an interval
* (suspended offscreen and on hidden tabs). Hovering a segment raises a chip
* with its bucket detail.
*
* Statuses follow railway-semaphore discipline — shape-and-brightness coded,
* not just hue: operational sits dim and calm, degraded carries a hairline
* notch, and incident is the only saturated element (glowing, slightly
* taller), so the wall reads without color vision. Segments are decorative
* (`aria-hidden`) behind a visually-hidden textual summary per row. Reduced
* motion: fully-populated wall, no cascade, no pulse. Pure CSS animation,
* zero dependencies.
*/
export function Semaphore({
segments,
rows,
colors,
liveInterval = 0,
nextSegment,
cascade = true,
cascadeSpeed = 1,
pulse = true,
segmentWidth = 6,
segmentHeight = 26,
gap = 3,
radius = 2,
axis,
showChip = true,
renderChip,
paused = false,
className,
style,
...rest
}: SemaphoreProps) {
const reducedMotion = useReducedMotion();
const speed = Math.max(cascadeSpeed, 0.05);
const live = liveInterval > 0;
const baseRows = React.useMemo<InternalRow[]>(() => {
const src = rows ?? (segments ? [{ segments }] : DEFAULT_ROWS);
return src.map((row, ri) => ({
label: row.label,
segments: row.segments.map((seg, i) => ({ ...seg, __id: `s${ri}-${i}` })),
}));
}, [rows, segments]);
// Live mode owns the data after its first tick; static mode never allocates.
const [liveState, setLiveState] = React.useState<{ rows: InternalRow[]; tick: number } | null>(
null
);
const shownRows = liveState?.rows ?? baseRows;
const baseRowsRef = React.useRef(baseRows);
const nextRef = React.useRef(nextSegment);
React.useEffect(() => {
baseRowsRef.current = baseRows;
nextRef.current = nextSegment;
});
const advance = React.useCallback(() => {
setLiveState((prev) => {
const tick = (prev?.tick ?? 0) + 1;
const current = prev?.rows ?? baseRowsRef.current;
return {
tick,
rows: current.map((row, ri) => {
const incoming = (nextRef.current ?? defaultNextSegment)(ri, tick);
return {
label: row.label,
segments: [...row.segments.slice(1), { ...incoming, __id: `l${tick}-${ri}` }],
};
}),
};
});
}, []);
// The visibility-paused rAF is the live scheduler: it suspends offscreen and
// on hidden tabs, never starts when not live, and tears down on unmount.
const intervalS = live ? liveInterval / 1000 : Infinity;
const lastAdvanceRef = React.useRef(0);
const hostRef = useVisibilityPause<HTMLDivElement>(
(elapsed) => {
// The hook's clock restarts when `paused` toggles; re-anchor if it did.
if (elapsed < lastAdvanceRef.current) lastAdvanceRef.current = elapsed;
if (elapsed - lastAdvanceRef.current < intervalS) return;
lastAdvanceRef.current = elapsed;
advance();
},
{ paused: paused || !live }
);
// Boot cascade — first in-view only.
const maxCols = React.useMemo(
() => Math.max(1, ...shownRows.map((r) => r.segments.length)),
[shownRows]
);
const colStepS = SWEEP_S / speed / Math.max(1, maxCols - 1);
const rowStepS = ROW_OFFSET_S / speed;
const igniteS = IGNITE_S / speed;
type Boot = "pending" | "go" | "done";
const [boot, setBoot] = React.useState<Boot>("pending");
const bootedRef = React.useRef(false);
// No cascade (or reduced motion) → the wall renders fully populated at once.
const effectiveBoot: Boot = !cascade || reducedMotion ? "done" : boot;
React.useEffect(() => {
if (bootedRef.current || !cascade || reducedMotion) return;
const el = hostRef.current;
if (!el) return;
let timeout = 0;
const io = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting) return;
io.disconnect();
bootedRef.current = true;
setBoot("go");
const totalMs =
((maxCols - 1) * colStepS + (shownRows.length - 1) * rowStepS + igniteS) * 1000 + 120;
timeout = window.setTimeout(() => setBoot("done"), totalMs);
},
{ threshold: 0.25 }
);
io.observe(el);
return () => {
io.disconnect();
window.clearTimeout(timeout);
};
// Cascade timing is captured at ignition; data churn must not re-arm it.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cascade, reducedMotion]);
const [hover, setHover] = React.useState<{ row: number; index: number } | null>(null);
const hasLabels = shownRows.some((row) => row.label != null && row.label !== "");
const vars = {
"--sem-w": `${segmentWidth}px`,
"--sem-h": `${segmentHeight}px`,
"--sem-r": `${Math.max(0, radius)}px`,
"--sem-ignite": `${igniteS.toFixed(3)}s`,
...style,
} as React.CSSProperties;
return (
<div
{...rest}
ref={hostRef}
data-crucible="semaphore"
data-boot={effectiveBoot}
data-pulse={pulse && !reducedMotion && !paused ? "true" : "false"}
data-paused={paused ? "true" : undefined}
className={cn(
"inline-grid items-center gap-x-3 gap-y-2.5",
hasLabels ? "grid-cols-[auto_1fr]" : "grid-cols-1",
className
)}
style={vars}
>
<style>{STYLE}</style>
{shownRows.map((row, ri) => {
const chipSegment =
showChip && hover?.row === ri ? row.segments[hover.index] : undefined;
return (
<React.Fragment key={ri}>
{hasLabels && (
<span className="text-xs font-medium leading-none text-neutral-400">
{row.label}
</span>
)}
<div className="relative">
{/* The real status surface for assistive tech. */}
<span className="sr-only">{summarizeRow(row, ri)}</span>
<div
aria-hidden
className="flex items-center"
style={{ gap: `${gap}px` }}
onMouseOver={
showChip
? (e) => {
const target = (e.target as HTMLElement).closest<HTMLElement>(
"[data-sem-idx]"
);
if (!target) return;
const index = Number(target.dataset.semIdx);
setHover((h) =>
h && h.row === ri && h.index === index ? h : { row: ri, index }
);
}
: undefined
}
onMouseLeave={showChip ? () => setHover(null) : undefined}
>
{row.segments.map((seg, i) => {
const st = resolveStatusStyle(seg.status, colors);
const isLatest = i === row.segments.length - 1;
const segStyle = {
backgroundColor: st.color,
"--seg-o": st.opacity,
"--seg-delay": `${(i * colStepS + ri * rowStepS).toFixed(3)}s`,
...(st.emphasis
? {
boxShadow: `0 0 9px 1px color-mix(in oklab, ${st.color} 55%, transparent)`,
transform: "scaleY(1.16)",
}
: null),
} as React.CSSProperties;
return (
<span
key={seg.__id}
data-sem-idx={i}
className={cn(
"sem-seg",
isLatest && "sem-latest",
isLatest && liveState !== null && "sem-new"
)}
style={segStyle}
>
{st.notch && <span className="sem-notch" />}
</span>
);
})}
</div>
{chipSegment && hover && (
<div
aria-hidden
className="sem-chip rounded-md border border-white/10 bg-neutral-900/95 px-2.5 py-1.5 text-[11px] leading-tight text-neutral-200 shadow-lg shadow-black/40"
style={{ left: hover.index * (segmentWidth + gap) + segmentWidth / 2 }}
>
{renderChip ? (
renderChip(chipSegment, {
rowIndex: ri,
segmentIndex: hover.index,
rowLabel: row.label,
})
) : (
<>
<span className="flex items-center gap-1.5">
<span
className="inline-block size-1.5 rounded-full"
style={{
backgroundColor: resolveStatusStyle(chipSegment.status, colors).color,
}}
/>
<span className="font-medium capitalize text-neutral-100">
{chipSegment.status}
</span>
{chipSegment.label && (
<span className="text-neutral-500">{chipSegment.label}</span>
)}
</span>
{chipSegment.detail && (
<span className="mt-0.5 block text-neutral-400">{chipSegment.detail}</span>
)}
</>
)}
</div>
)}
</div>
</React.Fragment>
);
})}
{axis && (
<>
{hasLabels && <span aria-hidden />}
<div
aria-hidden
className="flex justify-between text-[10px] uppercase tracking-[0.18em] text-neutral-600"
>
<span>{axis[0]}</span>
<span>{axis[1]}</span>
</div>
</>
)}
</div>
);
}
Installation
CLI
npx shadcn@latest add @crucible/semaphoreProps
| Prop | Type | Default | Description |
|---|---|---|---|
| segments | SemaphoreSegment[] | Single-row tracker: time buckets, oldest first. Ignored when rows is set. Omit both for a seeded sample row. | |
| rows | SemaphoreRow[] | Wall composition: multiple labeled tracker rows sharing one time axis. Takes precedence over segments. | |
| colors | Record<string, string | SemaphoreStatusStyle> | Status → style map, merged over the built-in operational / degraded / incident / empty styles. A plain color string keeps that status's default shape/brightness coding. | |
| liveInterval | number | 0 | Live mode: advance every N milliseconds — the oldest bucket drops, a new one slides in from the right. 0 = static (no timers, zero JS work). Suspends offscreen and on hidden tabs. |
| nextSegment | (rowIndex: number, tick: number) => SemaphoreSegment | a deterministic mostly-operational feed | Produces the incoming bucket in live mode. |
| cascade | boolean | true | Boot cascade on first scroll into view (once): segments ignite left-to-right like a self-test. |
| cascadeSpeed | number | 1 | Cascade speed multiplier — 2 boots twice as fast. |
| pulse | boolean | true | Breathing pulse on each row's newest segment. |
| segmentWidth | number | 6 | Segment width in px. |
| segmentHeight | number | 26 | Segment height in px. |
| gap | number | 3 | Gap between segments in px. |
| radius | number | 2 | Segment corner radius in px. |
| axis | readonly [string, string] | Shared time-axis labels rendered under the wall: [oldest, newest]. | |
| showChip | boolean | true | Show the hover chip with bucket detail. |
| renderChip | ( segment: SemaphoreSegment, context: { rowIndex: number; segmentIndex: number; rowLabel?: string } ) => React.ReactNode | Custom hover-chip contents; the default chip shows status, label, and detail. | |
| paused | boolean | false | Freeze everything at the fully-populated wall (also stops live mode). |
Also accepts all props of Omit<React.ComponentPropsWithoutRef<"div">, "children"> — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, and passes className through.