State Badge Overlay
State chip pinned to a canvas object's corner — idle, queued, running, complete, failed, stopped.
Preview
Switch between light and dark to inspect the embedded Storybook preview.
Installation
pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/state-badge-overlay.jsonbash
Storybook
Explore all variants, controls, and accessibility checks in the interactive Storybook playground.
View in Storybook4 stories available:
Code
"use client";
import {
type ComponentPropsWithoutRef,
forwardRef,
type ReactNode,
} from "react";
import { cn } from "../../lib/utils";
/**
* State name — drives the badge tone.
*
* @public
*/
export type StateBadgeState =
| "complete"
| "failed"
| "idle"
| "queued"
| "running"
| "stopped";
const STATE_LABEL: Record<StateBadgeState, string> = {
complete: "Complete",
failed: "Failed",
idle: "Idle",
queued: "Queued",
running: "Running",
stopped: "Stopped",
};
const STATE_TONE: Record<StateBadgeState, string> = {
complete:
"border-emerald-500/40 bg-emerald-500/15 text-emerald-700 dark:text-emerald-300",
failed: "border-red-500/40 bg-red-500/15 text-red-700 dark:text-red-300",
idle: "border-border bg-muted/40 text-muted-foreground",
queued:
"border-amber-500/40 bg-amber-500/15 text-amber-700 dark:text-amber-300",
running: "border-blue-500/40 bg-blue-500/15 text-blue-700 dark:text-blue-300",
stopped: "border-border bg-background text-foreground",
};
const STATE_DOT: Record<StateBadgeState, string> = {
complete: "bg-emerald-500",
failed: "bg-red-500",
idle: "bg-muted-foreground",
queued: "bg-amber-500",
running: "bg-blue-500 animate-pulse",
stopped: "bg-muted-foreground",
};
/**
* Anchor corner relative to the canvas object the badge attaches to.
*
* @public
*/
export type StateBadgeAnchor =
| "bottom-left"
| "bottom-right"
| "top-left"
| "top-right";
const ANCHOR_TRANSFORM: Record<StateBadgeAnchor, string> = {
"bottom-left": "translate(-100%, 100%)",
"bottom-right": "translate(0%, 100%)",
"top-left": "translate(-100%, -100%)",
"top-right": "translate(0%, -100%)",
};
/**
* Localizable strings.
*
* @public
*/
export type StateBadgeOverlayLabels = {
/** Aria-label override. Defaults to `"State"`. */
region?: string;
};
const DEFAULT_LABELS = {
region: "State",
} as const satisfies Required<StateBadgeOverlayLabels>;
/**
* Props for {@link StateBadgeOverlay}.
*
* @public
*/
export type StateBadgeOverlayProps = {
/** Anchor corner. Defaults to `"top-right"`. */
anchor?: StateBadgeAnchor;
/** Optional override label (defaults to humanized state name). */
label?: ReactNode;
/** Localizable strings. */
labels?: StateBadgeOverlayLabels;
/** State to display. */
state: StateBadgeState;
/** Anchor X in canvas pixels. */
x: number;
/** Anchor Y in canvas pixels. */
y: number;
} & ComponentPropsWithoutRef<"div">;
/**
* State chip pinned to a canvas object's corner. Use when a single-
* letter glyph in `ObjectCard` doesn't carry enough signal — for
* runs that have transitioned, jobs that failed, agents idling. Pure
* presentation; the host computes the anchor from the object's
* bounding box.
*
* The wrapper is `pointer-events: none` — host gestures pass through.
*
* @example
* ```tsx
* <div className="relative h-screen w-screen">
* <Canvas />
* <StateBadgeOverlay state="running" x={420} y={180} anchor="top-right" />
* </div>
* ```
*
* @public
*/
export const StateBadgeOverlay = forwardRef<
HTMLDivElement,
StateBadgeOverlayProps
>((props, ref) => {
const {
anchor = "top-right",
className,
label,
labels,
state,
x,
y,
...rest
} = props;
const resolvedLabels = { ...DEFAULT_LABELS, ...labels };
const text = label ?? STATE_LABEL[state];
return (
<div
aria-label={`${resolvedLabels.region}: ${STATE_LABEL[state]}`}
className={cn(
"pointer-events-none absolute z-20 inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide shadow-sm backdrop-blur",
STATE_TONE[state],
className,
)}
data-state-anchor={anchor}
data-state-badge-overlay
data-state-name={state}
ref={ref}
role="status"
style={{
left: x,
top: y,
transform: ANCHOR_TRANSFORM[anchor],
}}
{...rest}
>
<span
aria-hidden="true"
className={cn("size-1.5 rounded-full", STATE_DOT[state])}
data-state-badge-dot
/>
{text}
</div>
);
});
StateBadgeOverlay.displayName = "StateBadgeOverlay";
typescript