Status labels with colored dots, removable tags that slide closed and counts that roll to their new value.
import {
IconAlertTriangle,
IconCircleCheck,
IconCircleX,
IconClock,
IconEye,
IconLoader2,
} from "@tabler/icons-react"
import { Badge } from "@/components/ui/badge"
export function BadgeDemo() {
return (
<div className="flex flex-wrap items-center justify-center gap-2">
<Badge variant="warning">
<IconAlertTriangle data-icon="inline-start" />
Pending
</Badge>
<Badge variant="info">
<IconLoader2 data-icon="inline-start" />
In progress
</Badge>
<Badge variant="warning">
<IconEye data-icon="inline-start" />
In review
</Badge>
<Badge variant="destructive">
<IconCircleX data-icon="inline-start" />
Failed
</Badge>
<Badge variant="success">
<IconCircleCheck data-icon="inline-start" />
Success
</Badge>
<Badge>
<IconClock data-icon="inline-start" />
Expired
</Badge>
</div>
)
}pnpm dlx shadcn@latest add https://hextaui.com/r/badge.jsonAdds the component, the HextaUI theme tokens and any HextaUI components it depends on.
- 1
Add the theme tokens to your global CSS, if you haven’t yet.
- 2
Install the dependencies.
pnpm add @base-ui/react @tabler/icons-react class-variance-authority cn - 3
Copy and paste the following code into your project.
components/ui/badge.tsx "use client" import * as React from "react" import { mergeProps } from "@base-ui/react/merge-props" import { useRender } from "@base-ui/react/use-render" import { IconX } from "@tabler/icons-react" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "cn" import { easeOut, easeSpring } from "@/lib/motion" import { NumberFlow } from "@/components/ui/number-flow" const badgeVariants = cva( "group/badge relative inline-flex h-(--badge-height) w-fit max-w-full min-w-0 shrink-0 items-center justify-center gap-1 overflow-hidden rounded-(--badge-radius) align-middle leading-none font-medium whitespace-nowrap inset-ring-(length:--hairline) inset-ring-transparent transition-[background-color,border-color,color,box-shadow,scale] duration-150 ease-out-quint outline-none select-none [--badge-inset:3px] after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:bg-current after:opacity-0 after:transition-opacity after:duration-150 focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:inset-ring-ring focus-visible:outline-hidden has-data-[slot=badge-close]:overflow-visible has-[>[data-slot=badge-close]]:pe-(--badge-inset) aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:inset-ring-destructive dark:aria-invalid:ring-destructive/40 forced-colors:border [&:is(a,button)]:cursor-pointer [&:is(a,button)]:hover:after:opacity-8 motion-safe:[&:is(a,button)]:active:scale-[0.97] [&>svg]:pointer-events-none [&>svg]:shrink-0 [&>svg]:text-(--badge-accent) [&>svg:not([class*='size-'])]:size-3", { variants: { variant: { default: "[--badge-accent:var(--color-muted-foreground)]", success: "[--badge-accent:var(--color-success)]", info: "[--badge-accent:var(--color-info)]", warning: "[--badge-accent:var(--color-warning)]", destructive: "[--badge-accent:var(--color-destructive)]", }, appearance: { solid: "[--badge-accent:currentColor]", outline: "bg-background text-foreground inset-ring-border", }, size: { sm: "px-1.5 text-[0.6875rem] [--badge-height:1.125rem] [--badge-radius:min(var(--radius-sm),5px)] has-data-[icon=inline-end]:pe-1 has-data-[icon=inline-start]:ps-1 [&>svg:not([class*='size-'])]:size-2.5", default: "px-2 text-xs [--badge-height:1.25rem] [--badge-radius:min(var(--radius-sm),6px)] has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5", lg: "gap-1.5 px-2.5 text-sm [--badge-height:1.5rem] [--badge-inset:4px] [--badge-radius:min(var(--radius-md),7px)] has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2 [&>svg:not([class*='size-'])]:size-3.5", }, }, compoundVariants: [ { appearance: "solid", variant: "default", className: "bg-primary text-primary-foreground", }, { appearance: "solid", variant: "success", className: "bg-success text-success-foreground", }, { appearance: "solid", variant: "info", className: "bg-info text-info-foreground", }, { appearance: "solid", variant: "warning", className: "bg-warning text-warning-foreground", }, { appearance: "solid", variant: "destructive", className: "bg-destructive text-destructive-foreground", }, ], defaultVariants: { variant: "default", appearance: "outline", size: "default", }, } ) type BadgeVariant = NonNullable<VariantProps<typeof badgeVariants>["variant"]> type BadgeAppearance = NonNullable< VariantProps<typeof badgeVariants>["appearance"] > type BadgeSize = NonNullable<VariantProps<typeof badgeVariants>["size"]> const BadgeContext = React.createContext<{ close: () => void labelId: string | undefined } | null>(null) function useBadgeContext(part: string) { const context = React.useContext(BadgeContext) if (!context) { throw new Error(`<${part}> must be used within <Badge>.`) } return context } function subscribe() { return () => {} } function prefersReducedMotion() { return ( typeof window.matchMedia !== "function" || window.matchMedia("(prefers-reduced-motion: reduce)").matches ) } function dataAttributes(slot: string, extra?: Record<string, string>) { return { "data-slot": slot, ...extra } as Record<string, string> } const focusableSelector = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]), [contenteditable="true"]' function focusNearest(from: HTMLElement) { const candidates = Array.from( document.querySelectorAll<HTMLElement>(focusableSelector) ).filter( (node) => !from.contains(node) && (typeof node.checkVisibility !== "function" || node.checkVisibility()) ) const target = candidates.find( (node) => from.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING ) ?? candidates.findLast( (node) => from.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_PRECEDING ) target?.focus() } function siblingCloses(element: HTMLElement) { const parent = element.parentElement if (!parent) { return { next: [], previous: [] } } const closes = Array.from( parent.querySelectorAll<HTMLElement>( ":scope > [data-slot=badge] > [data-slot=badge-close]" ) ).filter((close) => !element.contains(close)) return { next: closes.filter( (close) => element.compareDocumentPosition(close) & Node.DOCUMENT_POSITION_FOLLOWING ), previous: closes .filter( (close) => element.compareDocumentPosition(close) & Node.DOCUMENT_POSITION_PRECEDING ) .reverse(), } } function inlineGap(element: HTMLElement) { const parent = element.parentElement if (!parent) { return 0 } const style = getComputedStyle(parent) const flowsInline = style.display.includes("grid") || (style.display.includes("flex") && style.flexDirection.startsWith("row")) return flowsInline ? parseFloat(style.columnGap) || 0 : 0 } function isText(child: React.ReactNode) { return ( (typeof child === "string" || typeof child === "number") && String(child).trim() !== "" ) } function hasText(children: React.ReactNode) { return React.Children.toArray(children).some(isText) } function wrapText(children: React.ReactNode, labelId: string) { const items = React.Children.toArray(children) const first = items.findIndex(isText) return items.map((child, index) => isText(child) ? ( <span key={`badge-label-${index}`} id={index === first ? labelId : undefined} data-slot="badge-label" className="min-w-0 truncate" > {child} </span> ) : ( child ) ) } type BadgeProps = useRender.ComponentProps<"span"> & { variant?: BadgeVariant appearance?: BadgeAppearance size?: BadgeSize open?: boolean defaultOpen?: boolean onOpenChange?: (open: boolean) => void onOpenChangeComplete?: (open: boolean) => void } function Badge({ className, variant = "default", appearance = "outline", size = "default", open: openProp, defaultOpen = true, onOpenChange, onOpenChangeComplete, render, ref, children, ...props }: BadgeProps) { const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen) const open = openProp ?? uncontrolledOpen const [present, setPresent] = React.useState(open) const elementRef = React.useRef<HTMLElement>(null) const closingRef = React.useRef(false) const labelId = React.useId() const onCompleteRef = React.useRef(onOpenChangeComplete) React.useLayoutEffect(() => { onCompleteRef.current = onOpenChangeComplete }) const isHydrating = React.useSyncExternalStore( subscribe, () => false, () => true ) const [animateIn, setAnimateIn] = React.useState(!isHydrating) if (open && !present) { setPresent(true) } React.useEffect(() => { if (open) { closingRef.current = false } }, [open]) React.useEffect(() => { const element = elementRef.current if (open || !present || !element) { return } const hadFocus = element.contains(document.activeElement) const { next, previous } = siblingCloses(element) const finish = () => { if (hadFocus) { const target = [...next, ...previous].find((close) => close.isConnected) if (target) { target.focus() } else { focusNearest(element) } } setAnimateIn(true) setPresent(false) onCompleteRef.current?.(false) } if (typeof element.animate !== "function") { finish() return } element.dataset.endingStyle = "" const animation = element.animate( [ { width: `${element.offsetWidth}px`, minWidth: "0px", }, { width: "0px", minWidth: "0px", paddingInline: "0px", borderInlineWidth: "0px", marginInlineEnd: `${-inlineGap(element)}px`, }, ], { duration: prefersReducedMotion() ? 0 : 260, easing: easeSpring, fill: "forwards", } ) const fade = element.animate( [ { opacity: 1, scale: 1 }, { opacity: 0, scale: 0.9 }, ], { duration: prefersReducedMotion() ? 0 : 140, easing: easeOut, fill: "forwards", } ) animation.onfinish = finish return () => { animation.onfinish = null animation.cancel() fade.cancel() delete element.dataset.endingStyle } }, [open, present]) const labelled = hasText(children) const context = React.useMemo( () => ({ close: () => { if (closingRef.current) { return } closingRef.current = true if (openProp === undefined) { setUncontrolledOpen(false) } onOpenChange?.(false) }, labelId: labelled ? labelId : undefined, }), [openProp, onOpenChange, labelled, labelId] ) const element = useRender({ defaultTagName: "span", render, ref: ref ? [elementRef, ref] : elementRef, enabled: present, props: mergeProps<"span">( { className: cn( badgeVariants({ variant, appearance, size }), animateIn && "motion-safe:animate-in motion-safe:ease-out-quint motion-safe:animation-duration-200 motion-safe:fade-in-0 motion-safe:zoom-in-90", className ), children: wrapText(children, labelId), }, props, dataAttributes("badge", { "data-variant": variant, "data-appearance": appearance, "data-size": size, }) ), }) return ( <BadgeContext.Provider value={context}>{element}</BadgeContext.Provider> ) } function BadgeDot({ className, pulse = false, ...props }: React.ComponentProps<"span"> & { pulse?: boolean }) { return ( <span aria-hidden data-pulse={pulse ? "" : undefined} className={cn( "relative size-1.5 shrink-0 rounded-full bg-(--badge-accent,currentColor) group-data-[size=lg]/badge:size-2", pulse && "after:absolute after:inset-0 after:rounded-full after:bg-inherit motion-safe:after:animate-ping", className )} {...props} data-slot="badge-dot" /> ) } function BadgeClose({ className, children, onClick, onKeyDown, ...props }: React.ComponentProps<"button">) { const { close, labelId } = useBadgeContext("BadgeClose") const id = React.useId() const labelled = props["aria-label"] === undefined return ( <button id={id} type="button" aria-labelledby={labelled && labelId ? `${id} ${labelId}` : undefined} className={cn( "relative z-1 inline-flex size-[calc(var(--badge-height)-var(--badge-inset)*2)] shrink-0 cursor-pointer items-center justify-center rounded-[max(calc(var(--radius-sm)*0.25),calc(var(--badge-radius)-var(--badge-inset)))] opacity-70 transition-[opacity,background-color] duration-150 ease-out-quint outline-none before:absolute before:-inset-(--badge-inset) after:absolute after:-inset-1.5 hover:bg-current/15 hover:opacity-100 focus-visible:bg-current/15 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-40 pointer-coarse:after:-inset-3 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-[75%]", className )} onClick={(event) => { onClick?.(event) if (!event.defaultPrevented) { close() } }} onKeyDown={(event) => { onKeyDown?.(event) if ( !event.defaultPrevented && (event.key === "Backspace" || event.key === "Delete") ) { event.preventDefault() close() } }} {...props} data-slot="badge-close" > {children ?? ( <> <IconX aria-hidden /> {labelled ? <span className="sr-only">Remove</span> : null} </> )} </button> ) } type BadgeCountProps = Omit< React.ComponentProps<typeof NumberFlow>, "value" | "suffix" > & { value: number max?: number } function BadgeCount({ className, value, max = 99, ...props }: BadgeCountProps) { const safe = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0 const cap = max === Infinity ? Infinity : Number.isFinite(max) && max >= 1 ? Math.floor(max) : 99 const over = safe > cap return ( <span data-slot="badge-count" dir="ltr" className="inline-flex"> <NumberFlow aria-hidden value={over ? cap : safe} suffix={over ? "+" : undefined} format={{ useGrouping: false }} className={className} {...props} /> <span className="sr-only">{safe}</span> </span> ) } export { Badge, BadgeDot, BadgeClose, BadgeCount, badgeVariants, type BadgeProps, type BadgeCountProps, type BadgeVariant, type BadgeAppearance, type BadgeSize, }components/ui/number-flow.tsx "use client" import * as React from "react" import { cn } from "cn" import { easeSpring } from "@/lib/motion" type NumberFlowTrend = "auto" | "up" | "down" | "shortest" type NumberFlowChar = { key: string section: number order: number value: string digit: number | null } type NumberFlowItem = NumberFlowChar & { exiting: boolean exitId: number } type NumberFlowProps = Omit< React.ComponentProps<"span">, "children" | "prefix" > & { value: number locales?: Intl.LocalesArgument format?: Intl.NumberFormatOptions prefix?: string suffix?: string trend?: NumberFlowTrend duration?: number easing?: string animated?: boolean onAnimationsStart?: () => void onAnimationsFinish?: () => void } const rows = 30 const restingClasses = "data-[digit='0']:translate-y-[-33.3333%] data-[digit='1']:translate-y-[-36.6667%] data-[digit='2']:translate-y-[-40%] data-[digit='3']:translate-y-[-43.3333%] data-[digit='4']:translate-y-[-46.6667%] data-[digit='5']:translate-y-[-50%] data-[digit='6']:translate-y-[-53.3333%] data-[digit='7']:translate-y-[-56.6667%] data-[digit='8']:translate-y-[-60%] data-[digit='9']:translate-y-[-63.3333%]" function getSpinDelta( from: number, to: number, trend: Exclude<NumberFlowTrend, "auto"> ) { let delta = to - from if (trend === "up") { if (delta < 0) { delta += 10 } } else if (trend === "down") { if (delta > 0) { delta -= 10 } } else if (delta > 5) { delta -= 10 } else if (delta < -4) { delta += 10 } return delta } function digitGlyphs( formatter: Intl.NumberFormat, locales: Intl.LocalesArgument ) { const { numberingSystem } = formatter.resolvedOptions() const plain = new Intl.NumberFormat(locales, { numberingSystem, useGrouping: false, }) return Array.from({ length: 10 }, (_, digit) => plain.format(digit)) } function formatChars( value: number, locales: Intl.LocalesArgument, format: Intl.NumberFormatOptions | undefined, prefix: string | undefined, suffix: string | undefined ) { const formatter = new Intl.NumberFormat(locales, format) const glyphs = digitGlyphs(formatter, locales) const parts = formatter.formatToParts(value) const integerCount = parts .filter((part) => part.type === "integer") .reduce((count, part) => count + Array.from(part.value).length, 0) const chars: NumberFlowChar[] = [] const occurrences = new Map<string, number>() let integerIndex = 0 let fractionIndex = 0 let seenNumber = false function symbol(section: number, type: string, text: string) { const name = `${section}-${type}` const occurrence = occurrences.get(name) ?? 0 occurrences.set(name, occurrence + 1) chars.push({ key: `s${name}-${occurrence}`, section, order: occurrence, value: text, digit: null, }) } if (prefix) { chars.push({ key: "prefix", section: 0, order: 0, value: prefix, digit: null, }) } for (const part of parts) { if (part.type === "integer") { seenNumber = true for (const glyph of Array.from(part.value)) { const place = integerCount - 1 - integerIndex const digit = glyphs.indexOf(glyph) chars.push({ key: `i${place}`, section: 2, order: -place, value: glyph, digit: digit === -1 ? null : digit, }) integerIndex += 1 } } else if (part.type === "group") { const place = integerCount - 1 - integerIndex chars.push({ key: `g${place}`, section: 2, order: -place - 0.5, value: part.value, digit: null, }) } else if (part.type === "decimal") { seenNumber = true chars.push({ key: "decimal", section: 3, order: 0, value: part.value, digit: null, }) } else if (part.type === "fraction") { for (const glyph of Array.from(part.value)) { const digit = glyphs.indexOf(glyph) chars.push({ key: `f${fractionIndex}`, section: 4, order: fractionIndex, value: glyph, digit: digit === -1 ? null : digit, }) fractionIndex += 1 } } else { symbol(seenNumber ? 5 : 1, part.type, part.value) } } if (suffix) { chars.push({ key: "suffix", section: 6, order: 0, value: suffix, digit: null, }) } return chars } function signature(chars: NumberFlowChar[]) { return chars.map((char) => `${char.key}:${char.value}`).join("|") } function mergeItems(previous: NumberFlowItem[], next: NumberFlowChar[]) { const keys = new Set(next.map((char) => char.key)) const kept = previous .filter((item) => !keys.has(item.key)) .map((item) => item.exiting ? item : { ...item, exiting: true, exitId: item.exitId + 1 } ) const fresh = next.map((char) => { const before = previous.find((item) => item.key === char.key) return { ...char, exiting: false, exitId: before?.exitId ?? 0 } }) return [...kept, ...fresh].sort( (a, b) => a.section - b.section || a.order - b.order ) } function subscribeReducedMotion(callback: () => void) { if (typeof window.matchMedia !== "function") { return () => {} } const query = window.matchMedia("(prefers-reduced-motion: reduce)") query.addEventListener("change", callback) return () => query.removeEventListener("change", callback) } function getReducedMotion() { return ( typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches ) } function canAnimate() { return ( typeof Element !== "undefined" && typeof Element.prototype.animate === "function" ) } function liveIndex(column: HTMLElement) { const height = column.offsetHeight if (!height) { return null } const translate = getComputedStyle(column).translate const y = translate === "none" ? "0" : (translate.split(" ")[1] ?? "0") const amount = parseFloat(y) || 0 return y.endsWith("%") ? (-amount / 100) * rows : (-amount / height) * rows } function NumberFlow({ value, locales = "en-US", format, prefix, suffix, trend = "auto", duration = 600, easing = easeSpring, animated = true, onAnimationsStart, onAnimationsFinish, className, ...props }: NumberFlowProps) { const chars = formatChars(value, locales, format, prefix, suffix) const key = signature(chars) const reducedMotion = React.useSyncExternalStore( subscribeReducedMotion, getReducedMotion, () => false ) const animate = animated && !reducedMotion && canAnimate() const [state, setState] = React.useState(() => ({ key, items: chars.map((char) => ({ ...char, exiting: false, exitId: 0 })), })) if (state.key !== key) { setState({ key, items: animate ? mergeItems(state.items, chars) : chars.map((char) => ({ ...char, exiting: false, exitId: 0 })), }) } const rootRef = React.useRef<HTMLSpanElement>(null) const previousRef = React.useRef<Map< string, { digit: number | null; exiting: boolean; exitId: number } > | null>(null) const valueRef = React.useRef(value) const batchRef = React.useRef(0) const callbacksRef = React.useRef({ onAnimationsStart, onAnimationsFinish }) const finishExitRef = React.useRef((itemKey: string, exitId: number) => { setState((current) => ({ ...current, items: current.items.filter( (item) => !(item.key === itemKey && item.exiting && item.exitId === exitId) ), })) }) React.useLayoutEffect(() => { callbacksRef.current = { onAnimationsStart, onAnimationsFinish } }) React.useLayoutEffect(() => { const root = rootRef.current const previous = previousRef.current const current = new Map( state.items.map((item) => [ item.key, { digit: item.digit, exiting: item.exiting, exitId: item.exitId }, ]) ) const previousValue = valueRef.current previousRef.current = current valueRef.current = value if (!root || !previous || !animate) { return } const direction = trend !== "auto" ? trend : value > previousValue ? "up" : value < previousValue ? "down" : "shortest" const timing = { duration, easing } const started: Animation[] = [] const cells = root.querySelectorAll<HTMLElement>( ":scope > [data-number-flow-key]" ) for (const cell of cells) { const itemKey = cell.dataset.numberFlowKey! const item = current.get(itemKey) const before = previous.get(itemKey) if (!item || typeof cell.animate !== "function") { continue } const column = cell.querySelector<HTMLElement>( "[data-slot=number-flow-column]" ) const entering = !item.exiting && (!before || before.exiting) const leaving = item.exiting && (!before || !before.exiting) if (entering || leaving) { const running = cell.getAnimations() const fromWidth = running.length ? cell.getBoundingClientRect().width : entering ? 0 : cell.getBoundingClientRect().width const fromOpacity = running.length ? Number(getComputedStyle(cell).opacity) : entering ? 0 : 1 running.forEach((animation) => animation.cancel()) const toWidth = entering ? cell.getBoundingClientRect().width : 0 const animation = cell.animate( [ { width: `${fromWidth}px`, opacity: fromOpacity }, { width: `${toWidth}px`, opacity: entering ? 1 : 0 }, ], { ...timing, fill: leaving ? "forwards" : "none" } ) started.push(animation) if (leaving) { const exitId = item.exitId animation.finished.then( () => finishExitRef.current(itemKey, exitId), () => {} ) } } if (!column || item.digit === null || item.exiting) { continue } const target = item.digit const running = column.getAnimations() const live = running.length ? liveIndex(column) : null running.forEach((animation) => animation.cancel()) const from = live !== null ? live - 10 : entering ? 0 : before && before.digit !== null ? before.digit : target if (live === null && from === target && !entering) { continue } const delta = getSpinDelta( from, target, entering && live === null ? "up" : direction ) if (delta === 0) { continue } started.push( column.animate( [ { translate: `0 ${(-(target + 10 - delta) / rows) * 100}%` }, { translate: `0 ${(-(target + 10) / rows) * 100}%` }, ], timing ) ) } if (!started.length) { return } batchRef.current += 1 const batch = batchRef.current callbacksRef.current.onAnimationsStart?.() Promise.all(started.map((animation) => animation.finished)).then( () => { if (batch === batchRef.current) { callbacksRef.current.onAnimationsFinish?.() } }, () => {} ) }, [state.items, animate, duration, easing, trend, value]) return ( <span ref={rootRef} dir="ltr" className={cn("inline-block max-w-full tabular-nums", className)} {...props} data-slot="number-flow" > <span data-slot="number-flow-text" className="sr-only"> {state.items .filter((item) => !item.exiting) .map((item) => item.value) .join("")} </span> {state.items.map((item) => item.digit === null ? ( <span key={item.key} data-number-flow-key={item.key} data-slot="number-flow-symbol" aria-hidden className="inline-block max-w-full overflow-clip wrap-anywhere whitespace-pre-wrap" > {item.value} </span> ) : ( <span key={item.key} data-number-flow-key={item.key} data-slot="number-flow-digit" aria-hidden className="relative -my-[0.2em] inline-block overflow-clip [mask-image:linear-gradient(to_bottom,transparent,black_0.2em,black_calc(100%-0.2em),transparent)] py-[0.2em]" > <span className="opacity-0">{item.value}</span> <span aria-hidden data-slot="number-flow-column" data-digit={item.digit} className={cn( "pointer-events-none absolute inset-x-0 top-0 flex flex-col items-center select-none", restingClasses )} > {Array.from({ length: rows }, (_, row) => ( <span key={row} className="block py-[0.2em]"> {glyphFor(item, row)} </span> ))} </span> </span> ) )} </span> ) } function glyphFor(item: NumberFlowItem, row: number) { return String.fromCodePoint( item.value.codePointAt(0)! - (item.digit ?? 0) + (row % 10) ) } export { NumberFlow, getSpinDelta, formatChars, type NumberFlowProps, type NumberFlowTrend, }lib/motion.ts import * as React from "react" const easeOut = "cubic-bezier(0.23, 1, 0.32, 1)" const easeInOut = "cubic-bezier(0.77, 0, 0.175, 1)" const easeSpring = "linear(0, 0.015 2%, 0.0532 4%, 0.1065 6%, 0.1686 8%, 0.2351 10%, 0.3363 13%, 0.4332 16%, 0.5495 20%, 0.648 24%, 0.7287 28%, 0.807 33%, 0.8646 38%, 0.9128 44%, 0.9447 50%, 0.968 57%, 0.9834 65%, 0.9924 74%, 0.9975 85%, 1)" const duration = { press: 100, release: 200, hover: 150, enter: 200, exit: 150, morph: 300, } as const function prefersReducedMotion() { return ( typeof window === "undefined" || typeof window.matchMedia !== "function" || window.matchMedia("(prefers-reduced-motion: reduce)").matches ) } type SizeAxis = "width" | "height" type SizeMorphOptions = { axis: SizeAxis enabled?: boolean duration?: number easing?: string } function readSize(element: HTMLElement, axis: SizeAxis) { const value = parseFloat(getComputedStyle(element)[axis]) if (Number.isFinite(value)) { return value } const rect = element.getBoundingClientRect() return axis === "width" ? rect.width : rect.height } function attachSizeMorph( element: HTMLElement, axis: SizeAxis, time: number, easing: string ) { if ( typeof MutationObserver === "undefined" || typeof element.animate !== "function" ) { return undefined } let settled = readSize(element, axis) let animation: Animation | null = null const morph = () => { const from = animation ? readSize(element, axis) : settled animation?.cancel() animation = null const to = readSize(element, axis) settled = to if ( Math.abs(from - to) < 0.5 || prefersReducedMotion() || !element.isConnected || element.getClientRects().length === 0 ) { element.removeAttribute("data-morphing") return } element.setAttribute("data-morphing", "") const running = element.animate( [{ [axis]: `${from}px` }, { [axis]: `${to}px` }], { duration: time, easing } ) animation = running running.onfinish = () => { if (animation === running) { animation = null element.removeAttribute("data-morphing") settled = readSize(element, axis) } } } const mutations = new MutationObserver(morph) mutations.observe(element, { childList: true, subtree: true, characterData: true, }) const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => { if (!animation) { settled = readSize(element, axis) } }) resize?.observe(element) return () => { mutations.disconnect() resize?.disconnect() animation?.cancel() element.removeAttribute("data-morphing") } } function useSizeMorph<T extends HTMLElement>({ axis, enabled = true, duration: time = duration.morph, easing = easeOut, }: SizeMorphOptions): React.RefCallback<T> { return React.useCallback( (element: T | null) => { if (!element || !enabled) { return undefined } return attachSizeMorph(element, axis, time, easing) }, [axis, enabled, time, easing] ) } function useSlidingHighlight( barRef: React.RefObject<HTMLElement | null>, highlightRef: React.RefObject<HTMLElement | null>, selector: string, attribute = "data-popup-open" ) { React.useLayoutEffect(() => { const bar = barRef.current const highlight = highlightRef.current if (!bar || !highlight) { return } let current: HTMLElement | null = null const place = (trigger: HTMLElement, instant: boolean) => { if (instant || prefersReducedMotion()) { highlight.setAttribute("data-instant", "") } else { highlight.removeAttribute("data-instant") } const frame = bar.getBoundingClientRect() const box = trigger.getBoundingClientRect() const scale = bar.offsetWidth > 0 ? frame.width / bar.offsetWidth : 1 highlight.style.left = "0px" highlight.style.width = `${box.width / scale}px` highlight.style.height = `${box.height / scale}px` highlight.style.transform = `translate(${(box.left - frame.left) / scale - bar.clientLeft}px, ${(box.top - frame.top) / scale - bar.clientTop}px)` } const sync = () => { const trigger = bar.querySelector<HTMLElement>(selector) if (trigger === current) { if (trigger) { place(trigger, true) } return } const appearing = current === null current = trigger if (!trigger) { highlight.removeAttribute("data-visible") return } place(trigger, appearing) if (appearing) { void highlight.offsetWidth } highlight.setAttribute("data-visible", "") } sync() const mutations = new MutationObserver(sync) mutations.observe(bar, { subtree: true, childList: true, attributes: true, attributeFilter: [attribute], }) const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => { if (current) { place(current, true) } }) resize?.observe(bar) const onScroll = () => { if (current) { place(current, true) } } bar.addEventListener("scroll", onScroll, { capture: true, passive: true }) return () => { mutations.disconnect() resize?.disconnect() bar.removeEventListener("scroll", onScroll, { capture: true }) } }, [barRef, highlightRef, selector, attribute]) } export { duration, easeInOut, easeOut, easeSpring, prefersReducedMotion, useSizeMorph, useSlidingHighlight, } export type { SizeMorphOptions } - 4
Update the import paths to match your project setup.
import {
Badge,
BadgeClose,
BadgeCount,
BadgeDot,
} from "@/components/ui/badge"<Badge variant="success">
<BadgeDot />
Paid
</Badge>Badge
├── BadgeDot
├── BadgeCount
└── BadgeCloseVariants and appearances#
The default outline appearance keeps a neutral surface and puts the status color on the dot or icon. Use appearance="solid" when the badge needs to stand out.
import { Badge, BadgeDot } from "@/components/ui/badge"
export function BadgeVariants() {
return (
<div className="flex flex-col items-center gap-3">
<div className="flex flex-wrap items-center justify-center gap-2">
<Badge>
<BadgeDot />
Draft
</Badge>
<Badge variant="success">
<BadgeDot />
Paid
</Badge>
<Badge variant="info">
<BadgeDot />
Syncing
</Badge>
<Badge variant="warning">
<BadgeDot />
Pending
</Badge>
<Badge variant="destructive">
<BadgeDot />
Failed
</Badge>
</div>
<div className="flex flex-wrap items-center justify-center gap-2">
<Badge appearance="solid">Draft</Badge>
<Badge appearance="solid" variant="success">
Paid
</Badge>
<Badge appearance="solid" variant="info">
Syncing
</Badge>
<Badge appearance="solid" variant="warning">
Pending
</Badge>
<Badge appearance="solid" variant="destructive">
Failed
</Badge>
</div>
</div>
)
}Sizes#
sm, default and lg. Icons, dots, the close button and counts scale with the badge.
import { IconCheck } from "@tabler/icons-react"
import { Badge, BadgeClose, BadgeCount, BadgeDot } from "@/components/ui/badge"
export function BadgeSizes() {
return (
<div className="flex flex-col items-center gap-3">
<div className="flex flex-wrap items-center justify-center gap-2">
<Badge size="sm">Small</Badge>
<Badge size="sm" variant="success">
<IconCheck data-icon="inline-start" />
Paid
</Badge>
<Badge size="sm" variant="info">
<BadgeDot pulse />
Live
</Badge>
<Badge size="sm">
design
<BadgeClose />
</Badge>
<Badge size="sm">
<BadgeCount value={42} />
</Badge>
</div>
<div className="flex flex-wrap items-center justify-center gap-2">
<Badge>Default</Badge>
<Badge variant="success">
<IconCheck data-icon="inline-start" />
Paid
</Badge>
<Badge variant="info">
<BadgeDot pulse />
Live
</Badge>
<Badge>
design
<BadgeClose />
</Badge>
<Badge>
<BadgeCount value={42} />
</Badge>
</div>
<div className="flex flex-wrap items-center justify-center gap-2">
<Badge size="lg">Large</Badge>
<Badge size="lg" variant="success">
<IconCheck data-icon="inline-start" />
Paid
</Badge>
<Badge size="lg" variant="info">
<BadgeDot pulse />
Live
</Badge>
<Badge size="lg">
design
<BadgeClose />
</Badge>
<Badge size="lg">
<BadgeCount value={42} />
</Badge>
</div>
</div>
)
}Removable#
Add a <BadgeClose /> to make a badge removable. It shrinks closed and its neighbours slide into the gap, then focus moves to the next close button. Remove the item from your data in onOpenChangeComplete so the exit animation can finish first.
"use client"
import * as React from "react"
import { Badge, BadgeClose } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
const initialTags = [
"design",
"engineering",
"research",
"marketing",
"customer-success",
"ops",
]
export function BadgeRemovable() {
const [tags, setTags] = React.useState(initialTags)
const [next, setNext] = React.useState(1)
return (
<div className="flex w-full max-w-md flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
{tags.map((tag) => (
<Badge
key={tag}
onOpenChangeComplete={(open) => {
if (!open) {
setTags((current) => current.filter((item) => item !== tag))
}
}}
>
{tag}
<BadgeClose />
</Badge>
))}
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
setTags((current) => [...current, `tag-${next}`])
setNext((value) => value + 1)
}}
>
Add tag
</Button>
<Button variant="ghost" size="sm" onClick={() => setTags(initialTags)}>
Reset
</Button>
</div>
</div>
)
}Controlled#
Pass open and onOpenChange to own the visibility. Setting open back to true brings the badge back with an enter animation.
"use client"
import * as React from "react"
import { Badge, BadgeClose } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
export function BadgeControlled() {
const [open, setOpen] = React.useState(true)
return (
<div className="flex flex-wrap items-center justify-center gap-2">
<Badge variant="warning" open={open} onOpenChange={setOpen}>
Beta features on
<BadgeClose />
</Badge>
<Button
variant="outline"
size="sm"
disabled={open}
onClick={() => setOpen(true)}
>
Restore
</Button>
</div>
)
}Counts#
<BadgeCount /> rolls only the digits that change, and caps at max (99 by default) with a plus sign. Screen readers always hear the real number.
"use client"
import * as React from "react"
import { IconInbox } from "@tabler/icons-react"
import { Badge, BadgeCount } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
export function BadgeCounts() {
const [count, setCount] = React.useState(9)
return (
<div className="flex flex-col items-center gap-4">
<div className="flex flex-wrap items-center justify-center gap-2">
<Badge appearance="solid" variant="destructive">
<BadgeCount value={count} />
</Badge>
<Badge>
<BadgeCount value={count} max={999} />
unread
</Badge>
<Button variant="outline">
<IconInbox />
Inbox
<Badge size="sm">
<BadgeCount value={count} />
</Badge>
</Button>
</div>
<div className="flex flex-wrap items-center justify-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCount((value) => value + 1)}
>
+1
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCount((value) => Math.max(0, value - 1))}
>
−1
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCount((value) => value + 10)}
>
+10
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCount(Math.floor(Math.random() * 1200))}
>
Random
</Button>
</div>
</div>
)
}Interactive#
Use render to make a badge a link or a button. It gains a hover tint, a press scale and a focus ring. aria-invalid shows the error state.
import { IconArrowUpRight, IconX } from "@tabler/icons-react"
import { Badge, BadgeDot } from "@/components/ui/badge"
export function BadgeInteractive() {
return (
<div className="flex flex-wrap items-center justify-center gap-2">
<Badge render={<a href="#changelog" />}>
v2.0 is out
<IconArrowUpRight data-icon="inline-end" />
</Badge>
<Badge render={<button type="button" />} variant="info">
<BadgeDot />
Filter: open
</Badge>
<Badge render={<a href="#new" />}>New</Badge>
<Badge render={<button type="button" />} aria-invalid>
<IconX data-icon="inline-start" />
Invalid
</Badge>
</div>
)
}Long content#
A badge never grows wider than its container. Long labels truncate with an ellipsis while icons and the close button stay visible.
import { IconCheck } from "@tabler/icons-react"
import { Badge, BadgeClose } from "@/components/ui/badge"
export function BadgeLongContent() {
return (
<div className="flex w-56 max-w-full flex-col items-start gap-2">
<Badge>
SupercalifragilisticexpialidociousSupercalifragilisticexpialidocious
</Badge>
<Badge variant="success">
<IconCheck data-icon="inline-start" />
[email protected]
<BadgeClose />
</Badge>
</div>
)
}Right to left#
Icons, the close button and the collapse animation follow the reading direction. Counts always read left to right.
import { IconCheck } from "@tabler/icons-react"
import { Badge, BadgeClose, BadgeCount, BadgeDot } from "@/components/ui/badge"
export function BadgeRtl() {
return (
<div dir="rtl" className="flex flex-wrap items-center justify-center gap-2">
<Badge variant="success">
<IconCheck data-icon="inline-start" />
مدفوع
</Badge>
<Badge>
<BadgeDot pulse />
مباشر
</Badge>
<Badge>
تصميم
<BadgeClose />
</Badge>
<Badge>
<BadgeCount value={120} />
</Badge>
</div>
)
}These keys apply to <BadgeClose />.
| Key | Action |
|---|---|
| EnterSpace | Removes the badge. |
| BackspaceDelete | Removes the badge. |
| Tab | Moves to the next close button. After a removal, focus lands on the next close button, or the previous one when it was the last. |
- The close button is named after the badge, so a screen reader announces “Remove design” rather than a bare “Remove”. Pass
aria-labelto override it. <BadgeDot />is decorative and hidden from assistive tech. Keep the status in the text label.<BadgeCount />exposes the exact value even when the visible text is capped, like 99+.- The removal and count animations are skipped when reduced motion is on.
<Badge /> renders a <span> and accepts all of its attributes.
| Prop | Type | Default |
|---|---|---|
variantColors the dot and icons in outline, or the surface in solid. | "default" | "success" | "info" | "warning" | "destructive" | "default" |
appearance | "outline" | "solid" | "outline" |
size | "sm" | "default" | "lg" | "default" |
openControlled visibility. | boolean | – |
defaultOpen | boolean | true |
onOpenChangeCalled when BadgeClose is activated. | (open: boolean) => void | – |
onOpenChangeCompleteCalled after the exit animation finishes. Remove the item from your data here. | (open: boolean) => void | – |
render | ReactElement | (props, state) => ReactElement | <span> |
| Attribute | Description |
|---|---|
data-slot="badge" | Target badges in CSS. |
data-variant | The current variant. |
data-appearance | The current appearance. |
data-size | The current size. |
data-ending-style | Present while the badge animates out. |
--badge-accent | The status color used by dots and icons. Override it for a custom accent. |
--badge-height | The badge height. |
--badge-radius | The corner radius. The close button derives its own radius from it. |
| Prop | Type | Default |
|---|---|---|
pulseAdds a ping animation for live states. Off when reduced motion is on. | boolean | false |
| Attribute | Description |
|---|---|
data-slot="badge-dot" | Target dots in CSS. |
data-pulse | Present when pulse is on. |
| Prop | Type | Default |
|---|---|---|
children | ReactNode | <IconX /> |
aria-labelOverrides the automatic “Remove {label}” name. | string | – |
onClickCall event.preventDefault() to keep the badge open. | (event) => void | – |
| Attribute | Description |
|---|---|
data-slot="badge-close" | Target the close button in CSS. |
| Prop | Type | Default |
|---|---|---|
valueNegative and non-finite values show 0. | number | – |
maxValues above it show as max+. Pass Infinity for no cap. | number | 99 |
duration | number | 600 |
animated | boolean | true |
trendThe direction the digits spin. | "auto" | "up" | "down" | "shortest" | "auto" |
locales | Intl.LocalesArgument | – |
format | Intl.NumberFormatOptions | – |
| Attribute | Description |
|---|---|
data-slot="badge-count" | Target counts in CSS. |