Status and count labels whose text, width and tone change in place.
01Preview
stealth-webProduction
main · 8f3c2a1 · Fix invoice rounding
All systems normal
3 unread
Inbox
Open it to mark everything read
- Billing APIDegraded
- Audit logEnterprise
- Webhooks v2New
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:
npm install motion @number-flow/react03Usage
import { Badge, BadgeCount, BadgeDot } from "@/components/ui/badge";
<Badge tone="success" dot>Ready</Badge>
<Badge variant="outline" size="sm">Production</Badge>
<BadgeCount count={unread} tone="danger" label={(n) => `${n} unread`}>
<button aria-label={`Notifications, ${unread} unread`}><Bell /></button>
</BadgeCount>04Source
"use client";
import NumberFlow from "@number-flow/react";
import { AnimatePresence, motion, useAnimate, useReducedMotion } from "motion/react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { cn } from "@/lib/cn";
import { ease, spring } from "@/lib/motion";
export type BadgeTone = "neutral" | "success" | "warning" | "danger" | "info";
export type BadgeVariant = "soft" | "outline" | "solid";
export type BadgeSize = "sm" | "md" | "lg";
// Soft is the default: a wash of the tone with the tone as text. Outline keeps the page quiet
// in dense tables. Solid is for the one badge on screen that has to be seen.
const tones: Record<BadgeVariant, Record<BadgeTone, string>> = {
soft: {
neutral: "bg-fg/[0.07] text-fg-2",
success: "bg-success-soft text-success",
warning: "bg-warning-soft text-warning",
danger: "bg-danger-soft text-danger",
info: "bg-info-soft text-info",
},
outline: {
neutral: "border-line-2 text-fg-2",
success: "border-success/35 text-success",
warning: "border-warning/35 text-warning",
danger: "border-danger/35 text-danger",
info: "border-info/35 text-info",
},
// Text on the solid fills is picked per theme for contrast: the pale dark-theme fills take the
// frame color; in light, green and amber are too pale for it and take the foreground instead.
solid: {
neutral: "bg-fg text-frame",
success: "bg-success text-frame in-data-[theme=light]:text-fg",
warning: "bg-warning text-frame in-data-[theme=light]:text-fg",
danger: "bg-danger text-frame",
info: "bg-info text-frame",
},
};
const sizes: Record<BadgeSize, string> = {
sm: "h-[18px] rounded-[5px] text-[11px]",
md: "h-5 rounded-md text-[11.5px]",
lg: "h-6 rounded-md text-[12.5px]",
};
// Padding lives on the inner span, so the width the badge glides to includes it.
const pads: Record<BadgeSize, string> = { sm: "gap-1 px-1.5", md: "gap-1.5 px-1.5", lg: "gap-1.5 px-2" };
/* -------------------------------------------------------------------------------------------------
* Dot
* -----------------------------------------------------------------------------------------------*/
export type BadgeDotProps = React.ComponentProps<"span"> & {
/** "current" follows the text color, for dots on solid fills. */
tone?: BadgeTone | "current";
/** A soft ring pulses out from the dot: for live and in-progress states only. */
pulse?: boolean;
/** Diameter in px. */
size?: number;
/** Spoken instead of shown, e.g. "Unread". Leave empty when a visible label says it. */
label?: string;
};
const dotTone: Record<BadgeTone | "current", string> = {
current: "bg-current",
neutral: "bg-fg-3",
success: "bg-success",
warning: "bg-warning",
danger: "bg-danger",
info: "bg-info",
};
/** A status dot on its own: unread markers, service health, the dot inside a badge. */
export function BadgeDot({ tone = "neutral", pulse = false, size = 6, label, className, style, ...rest }: BadgeDotProps) {
return (
<span
role={label ? "img" : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
data-tone={tone}
data-pulse={pulse || undefined}
style={{ width: size, height: size, ...style }}
className={cn("relative inline-block shrink-0 rounded-full transition-[background-color] duration-200", dotTone[tone], className)}
{...rest}
>
{pulse && <span className={cn("absolute inset-0 animate-ping-soft rounded-full motion-reduce:hidden", dotTone[tone])} />}
</span>
);
}
/* -------------------------------------------------------------------------------------------------
* Badge
* -----------------------------------------------------------------------------------------------*/
export type BadgeProps = React.ComponentProps<"span"> & {
tone?: BadgeTone;
variant?: BadgeVariant;
size?: BadgeSize;
/** A leading dot in the tone. */
dot?: boolean;
/** Pulse the dot, for states that are live or still running. */
pulse?: boolean;
/** A leading icon, drawn at 12px (14px in lg). Replaces the dot. */
icon?: React.ReactNode;
};
/**
* A short status or label. When the label changes (Queued → Building → Ready) the old word
* lifts out, the new one rises in, the width glides to fit and the color follows, so a
* status that updates in place is noticed without a flash.
*/
export function Badge({ tone = "neutral", variant = "soft", size = "md", dot = false, pulse = false, icon, className, children, ...rest }: BadgeProps) {
const reduce = useReducedMotion();
const inner = useRef<HTMLSpanElement>(null);
const [width, setWidth] = useState<number | null>(null);
// Track the content's width so the badge can glide to it instead of snapping.
useLayoutEffect(() => {
const el = inner.current;
if (!el) return;
const ro = new ResizeObserver(() => setWidth(el.offsetWidth));
ro.observe(el);
return () => ro.disconnect();
}, []);
const swapKey = typeof children === "string" || typeof children === "number" ? String(children) : "node";
const lead = icon ? (
<span aria-hidden className={cn("grid shrink-0 place-items-center [&_svg]:size-full", size === "lg" ? "size-3.5" : "size-3")}>
{icon}
</span>
) : dot || pulse ? (
<BadgeDot tone={variant === "solid" ? "current" : tone} pulse={pulse} size={size === "lg" ? 6 : 5} />
) : null;
return (
<motion.span
data-tone={tone}
data-variant={variant}
data-size={size}
initial={false}
animate={width == null ? undefined : { width }}
transition={reduce ? { duration: 0 } : spring.snappy}
className={cn(
"relative inline-flex shrink-0 select-none items-center overflow-hidden whitespace-nowrap align-middle font-medium leading-none tracking-[0.005em]",
"transition-[background-color,border-color,color] duration-200 ease-out",
variant === "outline" ? "border" : "border border-transparent",
tones[variant][tone],
sizes[size],
className,
)}
{...(rest as React.ComponentProps<typeof motion.span>)}
>
<span ref={inner} className={cn("relative inline-flex items-center", pads[size])}>
{lead}
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={swapKey}
className="tabular"
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 5, filter: "blur(2px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -5, filter: "blur(2px)", transition: { duration: 0.14, ease: ease.out } }}
transition={{ duration: reduce ? 0.12 : 0.22, ease: ease.out }}
>
{children}
</motion.span>
</AnimatePresence>
</span>
</motion.span>
);
}
/* -------------------------------------------------------------------------------------------------
* Count
* -----------------------------------------------------------------------------------------------*/
export type BadgeCountProps = Omit<React.ComponentProps<"span">, "children"> & {
count: number;
/** Above this the badge reads "99+". */
max?: number;
/** Keep the badge at 0 instead of letting it go. */
showZero?: boolean;
tone?: BadgeTone;
variant?: "soft" | "solid";
size?: "sm" | "md";
/** What the number counts, for screen readers: (n) => `${n} unread`. */
label?: (count: number) => string;
/**
* Pin the count to the top-right corner of this element (an icon button, an avatar). The
* count gets a ring in `--badge-ring`, the surface behind it, `--frame` by default.
*/
children?: React.ReactNode;
};
/**
* A number in a pill. Digits roll rather than swap, the pill pops once when the count goes up,
* and it scales in from nothing and back out when it reaches zero.
*/
export function BadgeCount({
count,
max = 99,
showZero = false,
tone = "neutral",
variant = "solid",
size = "md",
label = (n) => String(n),
className,
children,
...rest
}: BadgeCountProps) {
const reduce = useReducedMotion();
const [scope, animate] = useAnimate<HTMLSpanElement>();
const prev = useRef(count);
const visible = count > 0 || showZero;
const over = count > max;
// A single small pop when the number grows: the signal that something new arrived.
useEffect(() => {
const grew = count > prev.current;
prev.current = count;
if (!grew || reduce || !scope.current) return;
animate(scope.current, { scale: [1, 1.14, 1] }, { duration: 0.32, ease: ease.out });
}, [count, reduce, animate, scope]);
const pill = (
<AnimatePresence initial={false}>
{visible && (
<motion.span
key="count"
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.4 }}
animate={{ opacity: 1, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.4, transition: { duration: 0.14, ease: ease.out } }}
transition={reduce ? { duration: 0.12 } : spring.pop}
className={cn(!!children && "pointer-events-none absolute -right-1 -top-1 z-10")}
>
<span
ref={scope}
data-tone={tone}
data-variant={variant}
className={cn(
"inline-flex items-center justify-center rounded-full font-medium leading-none tabular",
"transition-[background-color,color] duration-200",
size === "sm" ? "h-4 min-w-4 px-1 text-[10px]" : "h-[18px] min-w-[18px] px-[5px] text-[11px]",
tones[variant][tone],
!!children && "shadow-[0_0_0_2px_var(--badge-ring,var(--frame))]",
className,
)}
{...rest}
>
<span aria-hidden className="flex items-center">
<NumberFlow value={Math.min(count, max)} className="leading-none" />
{over && <span className="-mr-px">+</span>}
</span>
<span className="sr-only">{label(count)}</span>
</span>
</motion.span>
)}
</AnimatePresence>
);
if (!children) return pill;
return (
<span className="relative inline-flex">
{children}
{pill}
</span>
);
}05Props
Badge
| Prop | Type | Default | Description |
|---|---|---|---|
| tone | "neutral" | "success" | "warning" | "danger" | "info" | "neutral" | Semantic tones are for meaning only: a state, a risk, a result. |
| variant | "soft" | "outline" | "solid" | "soft" | A wash, a hairline for dense tables, or a fill for the one badge that must be seen. |
| size | "sm" | "md" | "lg" | "md" | 18, 20 or 24px tall. |
| dot | boolean | false | A leading dot in the tone. |
| pulse | boolean | false | Pulses the dot for live or running states. Implies dot. |
| icon | ReactNode | — | A leading icon at 12px (14px in lg). Replaces the dot. |
BadgeCount
| Prop | Type | Default | Description |
|---|---|---|---|
| count* | number | — | The number. Digits roll between values. |
| max | number | 99 | Above it the pill reads "99+"; screen readers still hear the real count. |
| showZero | boolean | false | Keep the pill at 0 instead of letting it scale away. |
| tone | BadgeTone | "neutral" | Tone of the pill. |
| variant | "soft" | "solid" | "solid" | Fill or wash. |
| size | "sm" | "md" | "md" | 16 or 18px tall. |
| label | (count: number) => string | String(count) | What the number means, for screen readers. |
| children | ReactNode | — | Pins the count to this element's top-right corner, ringed in the badge-ring CSS variable (the frame by default). |
BadgeDot
| Prop | Type | Default | Description |
|---|---|---|---|
| tone | BadgeTone | "current" | "neutral" | "current" follows the text color. |
| pulse | boolean | false | A soft ring pulses out, for live states only. |
| size | number | 6 | Diameter in px. |
| label | string | — | Spoken name, when no visible text says it ("Unread"). |
06Notes
Behavior
- A badge whose label changes (Queued → Building → Ready) swaps the word in place and glides to the new width, so neighbors move smoothly instead of jumping.
- Counts above max read "99+"; a count reaching zero scales away unless showZero is set.
- Solid fills pick their text color per theme for contrast: pale green and amber take the foreground in light, the frame color elsewhere.
- The pulse is reserved for live states and disappears under reduced motion; the dot and word still carry the state.
Motion
- Labels lift out 5px with a 2px blur in 140ms while the new one rises in over 220ms (expo ease-out); the width follows on the snappy spring.
- Count digits roll with NumberFlow. When the count grows, the pill pops once to 1.14 over 320ms; it enters from 0.4 scale on the pop spring and leaves in 140ms.
- Tone changes cross-fade background, border and text color over 200ms. Reduced motion keeps short fades and drops the travel, blur, pop and pulse.
Accessibility
- Plain text in a span, so it reads as part of whatever it labels. Put aria-live on a badge that updates on its own, as the deploy status does.
- BadgeCount renders the number aria-hidden with a visually hidden label ("4 unread"); icon buttons it's pinned to should carry the count in their own name.
- BadgeDot is hidden from assistive tech unless given a label, since the words beside it usually say the same thing.