Status chips that never rely on colour alone, with one shared status map and a tooltip explaining what each status means.
StopsStatus read wrong by colour-blind users
"use client"
import * as React from "react"
import { RotateCw } from "lucide-react"
import { StatusChip, type DefaultStatus } from "@/components/ui/status-chip"
const initial: { id: string; customer: string; status: DefaultStatus; detail?: string }[] = [
{ id: "1042", customer: "Asha Rao", status: "failed", detail: "Card ending 4242 was declined: insufficient funds." },
{ id: "1041", customer: "Leo Martin", status: "paid" },
{ id: "1040", customer: "Mei Tan", status: "pending" },
{ id: "1039", customer: "Omar Haddad", status: "refunded" },
{ id: "1038", customer: "Draft order", status: "draft" },
]
export function Demo() {
const [orders, setOrders] = React.useState(initial)
const [noColour, setNoColour] = React.useState(true)
const timer = React.useRef<ReturnType<typeof setTimeout>>(undefined)
React.useEffect(() => () => clearTimeout(timer.current), [])
const set = (id: string, status: DefaultStatus) =>
setOrders((list) => list.map((o) => (o.id === id ? { ...o, status, detail: undefined } : o)))
const retry = (id: string) => {
set(id, "processing")
timer.current = setTimeout(() => set(id, "paid"), 1400)
}
return (
<div className="grid w-full max-w-sm min-w-0 gap-3">
<label className="flex w-fit items-center gap-2 text-xs text-muted-foreground">
<input type="checkbox" checked={noColour} onChange={(e) => setNoColour(e.target.checked)} className="size-4 accent-foreground" />
Preview with no colour vision
</label>
<ul className={`divide-y rounded-lg border transition-[filter] ${noColour ? "grayscale" : ""}`}>
{orders.map((order) => (
<li key={order.id} className="flex items-center gap-2 px-3 py-2 text-sm">
<span className="min-w-0 flex-1 truncate">
<span className="text-muted-foreground tabular-nums">#{order.id}</span> {order.customer}
</span>
{order.status === "failed" && (
<button
type="button"
onClick={() => retry(order.id)}
aria-label="Retry payment"
title="Retry payment"
className="grid size-6 shrink-0 place-items-center rounded-md border transition-colors outline-none hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<RotateCw aria-hidden className="size-3.5" />
</button>
)}
<StatusChip status={order.status} detail={order.detail} size="sm" />
</li>
))}
</ul>
<p className="text-xs text-muted-foreground">Hover or tab to a status to see what it means.</p>
</div>
)
}Installation
The files land in your project. They're yours to edit.
Install the dependencies
Copy and paste the following code into
components/uistatus-chip.tsx179 lines"use client" // Bollard UI — status-chip · MIT licence · https://bollard-ui.vercel.app/components/status-chip import * as React from "react" import { CircleCheck, CircleDashed, CircleDotDashed, CircleHelp, Info, OctagonX, TriangleAlert, type LucideIcon } from "lucide-react" import { cn } from "@/lib/utils" /** Each tone has its own icon shape, so no two statuses differ by colour alone. */ export type StatusTone = "success" | "warning" | "danger" | "info" | "progress" | "neutral" export interface StatusDefinition { /** Short visible text, e.g. "Payment failed". */ label: string tone: StatusTone /** What the status means, in one sentence. */ meaning: string /** What the person should do next, if anything. */ next?: string } /** * Colours follow the Okabe-Ito colour-blind-safe palette (bluish green, orange, * vermillion, reddish purple, sky blue). Danger is the only solid chip, so it * still stands out by lightness when hue can't be seen at all. */ export const STATUS_TONES: Record<StatusTone, { icon: LucideIcon; className: string }> = { success: { icon: CircleCheck, className: "border-emerald-700/30 bg-emerald-500/10 text-emerald-800 dark:border-emerald-400/30 dark:text-emerald-300" }, warning: { icon: TriangleAlert, className: "border-amber-600/40 bg-amber-400/15 text-amber-900 dark:border-amber-400/40 dark:text-amber-200" }, danger: { icon: OctagonX, className: "border-transparent bg-red-700 text-white dark:bg-red-400 dark:text-red-950" }, info: { icon: Info, className: "border-fuchsia-700/30 bg-fuchsia-500/10 text-fuchsia-800 dark:border-fuchsia-400/30 dark:text-fuchsia-300" }, progress: { icon: CircleDotDashed, className: "border-sky-700/30 bg-sky-500/10 text-sky-800 dark:border-sky-400/30 dark:text-sky-300" }, neutral: { icon: CircleDashed, className: "border-dashed border-foreground/30 bg-transparent text-muted-foreground" }, } /** * Declares an app's statuses in one place. In development it warns when one * label is given two tones, the drift that makes "Pending" amber on one page * and blue on the next. */ export function defineStatuses<K extends string>(map: Record<K, StatusDefinition>) { if (process.env.NODE_ENV !== "production") { const seen = new Map<string, StatusTone>() for (const { label, tone } of Object.values<StatusDefinition>(map)) { const key = label.toLowerCase() if (seen.has(key) && seen.get(key) !== tone) console.warn(`StatusChip: "${label}" is defined as both ${seen.get(key)} and ${tone}.`) seen.set(key, tone) } } return map } /** A starter map for orders and payments. Replace or extend it with `defineStatuses`. */ export const DEFAULT_STATUSES = defineStatuses({ paid: { label: "Paid", tone: "success", meaning: "The payment cleared and the money is on its way to you." }, processing: { label: "Processing", tone: "progress", meaning: "The bank is still confirming the payment. This usually takes under a minute.", next: "Nothing to do, it updates on its own." }, pending: { label: "Awaiting payment", tone: "warning", meaning: "The invoice was sent but hasn't been paid yet.", next: "Send a reminder if it's past the due date." }, failed: { label: "Payment failed", tone: "danger", meaning: "The bank declined the payment, so the order won't ship.", next: "Ask the customer for another card or retry." }, refunded: { label: "Refunded", tone: "info", meaning: "The money went back to the customer. The order is closed." }, draft: { label: "Draft", tone: "neutral", meaning: "Only you can see this. Nothing has been sent.", next: "Finish it and send when ready." }, }) export type DefaultStatus = keyof typeof DEFAULT_STATUSES export interface StatusChipProps<K extends string = DefaultStatus> extends Omit<React.ComponentProps<"button">, "children"> { /** The status key, looked up in `statuses`. Unknown keys render a "?" chip and warn in development. */ status: K /** The app-wide status map. Share one map so a status means the same thing everywhere. */ statuses?: Record<K, StatusDefinition> /** Details about this instance, shown first in the explanation, e.g. "Card ending 4242 was declined." */ detail?: string /** Chip size. */ size?: "sm" | "default" /** Heading before `next` in the explanation. */ nextLabel?: string /** Meaning shown for a status missing from the map. */ unknownMeaning?: string } /** * A status badge that never relies on colour alone: every tone has its own icon * shape plus text, colours come from a colour-blind-safe palette, and hovering * or focusing the chip explains what the status means and what to do next. * A change of status gets one short pop so it isn't missed. */ export function StatusChip<K extends string = DefaultStatus>({ status, statuses = DEFAULT_STATUSES as Record<string, StatusDefinition> as Record<K, StatusDefinition>, detail, size = "default", nextLabel = "What to do", unknownMeaning = "This status isn't defined in the app's status map.", className, onKeyDown, ref, ...props }: StatusChipProps<K>) { const known = statuses[status] as StatusDefinition | undefined const def: StatusDefinition = known ?? { label: String(status).replace(/[_-]+/g, " "), tone: "neutral", meaning: unknownMeaning } const Icon = known ? STATUS_TONES[def.tone].icon : CircleHelp const [open, setOpen] = React.useState(false) const [alignEnd, setAlignEnd] = React.useState(false) const chipRef = React.useRef<HTMLButtonElement>(null) const previous = React.useRef(status) const tipId = React.useId() React.useImperativeHandle(ref, () => chipRef.current as HTMLButtonElement) React.useEffect(() => { if (!known && process.env.NODE_ENV !== "production") console.warn(`StatusChip: unknown status "${status}".`) }, [known, status]) React.useEffect(() => { if (previous.current === status) return previous.current = status if (matchMedia("(prefers-reduced-motion: reduce)").matches) return chipRef.current?.animate([{ transform: "scale(0.85)", opacity: 0.5 }, { transform: "scale(1.06)" }, { transform: "scale(1)", opacity: 1 }], { duration: 320, easing: "ease-out" }) }, [status]) // Open toward the side with room, inside the surrounding table, list or card (or the screen). React.useLayoutEffect(() => { const chip = chipRef.current if (!open || !chip) return const frame = chip.closest("table, ul, ol, [role=table], [role=grid], [role=list], section, article") const right = Math.min(window.innerWidth - 8, frame?.getBoundingClientRect().right ?? Infinity) setAlignEnd(chip.getBoundingClientRect().left + 256 > right) }, [open]) return ( <span className="relative inline-flex" onMouseEnter={() => setOpen(true)} onMouseLeave={() => setOpen(false)}> <button ref={chipRef} type="button" data-slot="status-chip" data-tone={def.tone} aria-describedby={tipId} onFocus={() => setOpen(true)} onBlur={() => setOpen(false)} onKeyDown={(event) => { if (event.key === "Escape") setOpen(false) onKeyDown?.(event) }} className={cn( "inline-flex shrink-0 cursor-help items-center gap-1 rounded-full border font-medium whitespace-nowrap outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50", size === "sm" ? "h-5 px-1.5 text-[11px] [&_svg]:size-3" : "h-6 px-2 text-xs [&_svg]:size-3.5", STATUS_TONES[def.tone].className, className, )} {...props} > <Icon aria-hidden className="shrink-0" /> {def.label} </button> <span id={tipId} role="tooltip" hidden={!open} className={cn( "absolute top-full z-50 mt-1.5 w-64 max-w-[calc(100vw-2rem)] rounded-md border bg-popover p-3 text-left text-xs font-normal whitespace-normal text-popover-foreground shadow-md", alignEnd ? "right-0" : "left-0", )} > <span className="mb-1 flex items-center gap-1.5 font-medium"> <Icon aria-hidden className="size-3.5 shrink-0" /> {def.label} </span> {detail && <span className="mb-1 block">{detail}</span>} <span className="block text-muted-foreground">{def.meaning}</span> {def.next && ( <span className="mt-2 block"> <span className="font-medium">{nextLabel}: </span> {def.next} </span> )} </span> </span> ) }Update the import paths to match your project setup.
It uses the
cn()helper from@/lib/utils, which every shadcn/ui project already has.
Usage
import { StatusChip, type DefaultStatus } from "@/components/ui/status-chip"
<StatusChip status={order.status} detail={order.detail} size="sm" />What it catches
- Statuses that differ only by colour (each tone has its own icon)
- Inconsistent meanings across screens (one shared map)
- Unknown statuses, shown with a fallback meaning
- What to do next, explained on hover or focus
Props
| Prop | Type | Default |
|---|---|---|
status* (required)The status key, looked up in `statuses`. Unknown keys render a "?" chip and warn in development. | K | — |
statusesThe app-wide status map. Share one map so a status means the same thing everywhere. | Record<K, StatusDefinition> | DEFAULT_STATUSES as Record<string, StatusDefinition> as R… |
detailDetails about this instance, shown first in the explanation, e.g. "Card ending 4242 was declined." | string | — |
sizeChip size. | "sm" | "default" | "default" |
nextLabelHeading before `next` in the explanation. | string | "What to do" |
unknownMeaningMeaning shown for a status missing from the map. | string | "This status isn't defined in the app's status map." |
* required. Also accepts the native props of <button> (className, aria-*, data-*…), passed through to it.
Accessibility
- Chip is a button described by its tooltip (aria-describedby, role=tooltip)
- Escape closes the tooltip
- Icon shape plus text per tone; danger is the only solid chip
- Tooltip motion skipped under reduced motion
Only what the source does. Test with your own screen reader and content before shipping.
Limitations
- Meanings are only as clear as the copy you write
- Tooltip content is not reachable on touch without focus
What it stops
Status read wrong by colour-blind users
How people describe it
Also known as
status badge · chip · tag · pill · order status · colour blind · a11y · label