Status
A dot and a word, no pill. Changing state kicks the dot through a springy dip while its fill cross-fades, rolls the word vertically and springs the width to fit.
// Status: a dot and a word. Changing state kicks the dot through ~0.6 scale on a loose spring
// while its fill cross-fades, rolls the word vertically and springs the width to the new word.
// Live sends one accent ring out of the dot every 2.4s, and only while on screen.
import { useEffect, useLayoutEffect, useRef } from 'react';
import { SPRING, Spring, createLoop } from '../shared/spring';
import { useOnScreen, useReducedMotion } from '../shared/chart-kit';
import { resolveAccent, type Accent } from '../shared/accent';
import { easeOutCubic } from '../status-kit/kit';
import '../shared/ember.css';
import './status.css';
export type StatusState = 'idle' | 'live' | 'ok' | 'warn' | 'error';
export interface StatusProps {
state?: StatusState;
label?: string;
size?: 'sm' | 'md';
accent?: Accent;
className?: string;
}
const STATES: StatusState[] = ['idle', 'live', 'ok', 'warn', 'error'];
const LABELS: Record<StatusState, string> = { idle: 'Idle', live: 'Live', ok: 'OK', warn: 'Warning', error: 'Error' };
const RING_EVERY = 2400;
const RING_MS = 700;
const ROW = 16;
// An impulse that carries a loose spring at rest (1) down to about 0.6 and back with a small overshoot.
const KICK = -10;
function norm(s: unknown): StatusState {
return STATES.includes(s as StatusState) ? (s as StatusState) : 'idle';
}
export function Status({ state: stateProp = 'idle', label, size = 'md', accent: accentProp, className }: StatusProps) {
const accent = resolveAccent(accentProp);
const state = norm(stateProp);
const text = label || LABELS[state];
const reduced = useReducedMotion();
const rootRef = useRef<HTMLSpanElement>(null);
const dotRef = useRef<HTMLSpanElement>(null);
const boxRef = useRef<HTMLSpanElement>(null);
const prevRef = useRef<HTMLSpanElement>(null);
const curRef = useRef<HTMLSpanElement>(null);
const measureRef = useRef<HTMLSpanElement>(null);
const ringRef = useRef<SVGCircleElement>(null);
const onScreen = useOnScreen(rootRef);
const sm = size === 'sm';
const m = useRef<{
scale: Spring;
roll: Spring;
width: Spring;
loop: ReturnType<typeof createLoop>;
prev: { text: string; state: StatusState } | null;
shown: { text: string; state: StatusState };
} | null>(null);
if (!m.current) {
m.current = {
scale: new Spring(1, SPRING.loose),
roll: new Spring(1, SPRING.snappy),
width: new Spring(0, SPRING.soft),
prev: null,
shown: { text, state },
loop: createLoop((dt) => {
const s = m.current!;
let moving = false;
for (const sp of [s.scale, s.roll, s.width]) if (sp.step(dt)) moving = true;
paint();
return moving;
}),
};
}
const paint = () => {
const s = m.current!;
if (dotRef.current) dotRef.current.style.transform = s.scale.value === 1 ? '' : `scale(${s.scale.value})`;
if (boxRef.current && s.width.value > 0) boxRef.current.style.width = `${Math.round(s.width.value * 100) / 100}px`;
const p = s.roll.value;
if (curRef.current) curRef.current.style.transform = p === 1 ? '' : `translateY(${(1 - p) * ROW}px)`;
if (prevRef.current) {
prevRef.current.style.transform = `translateY(${-p * ROW}px)`;
prevRef.current.style.visibility = p >= 1 ? 'hidden' : '';
}
};
// State or label change: kick the dot, roll the word, spring the width.
useLayoutEffect(() => {
const s = m.current!;
const measure = measureRef.current;
const w = measure ? measure.getBoundingClientRect().width : 0;
const changed = s.shown.text !== text || s.shown.state !== state;
if (!changed || s.width.value === 0 || reduced) {
if (changed) s.prev = null;
s.shown = { text, state };
s.width.set(w);
s.scale.set(1);
s.roll.set(1);
if (prevRef.current) prevRef.current.textContent = '';
s.loop.stop();
paint();
return;
}
// Keep the outgoing word exactly where it is: continue the roll from p − 1.
if (prevRef.current) {
prevRef.current.textContent = s.shown.text;
prevRef.current.dataset.state = s.shown.state;
}
s.prev = s.shown;
s.shown = { text, state };
s.roll.value = s.roll.value - 1;
s.roll.target = 1;
if (s.shown.state !== s.prev.state) s.scale.velocity = KICK;
s.width.target = w;
paint();
s.loop.wake();
}, [text, state, reduced, size]);
// Late font loads change the word's width: follow it without animating.
useLayoutEffect(() => {
const el = measureRef.current;
if (!el) return;
const ro = new ResizeObserver(() => {
const s = m.current!;
const w = el.getBoundingClientRect().width;
if (s.width.value === s.width.target) {
s.width.set(w);
paint();
} else s.width.target = w;
});
ro.observe(el);
return () => ro.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => () => m.current?.loop.stop(), []);
// The live ring: 700ms out of every 2.4s; nothing runs between rings.
useEffect(() => {
const ring = ringRef.current;
if (!ring) return;
ring.style.opacity = '0';
if (state !== 'live' || reduced || !onScreen) return;
const r0 = sm ? 3 : 4;
const r1 = sm ? 7.5 : 10;
let raf = 0;
let timer = 0;
let start = 0;
const frame = (now: number) => {
const t = (now - start) / RING_MS;
if (t >= 1) {
ring.style.opacity = '0';
timer = window.setTimeout(begin, RING_EVERY - RING_MS);
return;
}
const e = easeOutCubic(t);
ring.setAttribute('r', String(r0 + (r1 - r0) * e));
ring.style.opacity = String(0.9 * (1 - t));
raf = requestAnimationFrame(frame);
};
const begin = () => {
start = performance.now();
raf = requestAnimationFrame(frame);
};
timer = window.setTimeout(begin, 240);
return () => {
cancelAnimationFrame(raf);
window.clearTimeout(timer);
ring.style.opacity = '0';
};
}, [state, reduced, onScreen, sm]);
return (
<span
ref={rootRef}
className={className ? `ember est ${className}` : 'ember est'}
data-accent={accent}
data-size={sm ? 'sm' : 'md'}
data-state={state}
role="status"
aria-live="polite"
>
<span className="est-dotbox" aria-hidden="true">
<svg className="est-ring" viewBox="-12 -12 24 24" width="24" height="24">
<circle ref={ringRef} r="4" style={{ opacity: 0 }} />
</svg>
<span ref={dotRef} className="est-dot">
{STATES.map((st) => (
<span key={st} className={`est-layer est-${st}`} data-on={st === state || undefined} />
))}
</span>
</span>
<span ref={boxRef} className="est-label" aria-hidden="true">
<span ref={prevRef} className="est-word" style={{ visibility: 'hidden' }} />
<span ref={curRef} className="est-word" data-state={state}>
{text}
</span>
</span>
<span ref={measureRef} className="est-measure" aria-hidden="true">
{text}
</span>
<span className="em-sr">{text}</span>
</span>
);
}
export default Status;
Customize
sm
md
4s.
Install
npx shadcn@latest add https://obsydeui.com/r/status.jsonUsage
Import it from where the CLI puts it, or copy the files below by hand.
import { Status } from '@/components/obsyde/ember/status/Status';
export function Example() {
return (
<Status state="live" />
);
}
Files
Plain React, TypeScript and CSS. No dependencies. The download keeps these paths.
ember/status/Status.tsxcomponentember/status/status.cssstylesember/status/StatusDemo.tsxdemoember/status-kit/kit.tssharedember/shared/chart-kit.tssharedember/shared/spring.tssharedember/shared/accent.tssharedember/shared/ember.cssshared
API
| Prop | Type | Default |
|---|---|---|
accentAccent | 'ember' | 'volt' | 'arc' | 'ember' |
stateState | 'idle' | 'live' | 'ok' | 'warn' | 'error' | 'live' |
sizeSize | 'sm' | 'md' | 'md' |
labelLabel | string | — |