# Badge

> Status labels with colored dots, removable tags that slide closed and counts that roll to their new value.

Docs: https://hextaui.com/docs/badge
Markdown: https://hextaui.com/docs/badge.md

```tsx title="components/examples/badge/demo.tsx"
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>
  )
}
```

## Installation

### CLI

```bash
npx shadcn@latest add https://hextaui.com/r/badge.json
```

This adds the component, the HextaUI theme tokens and any HextaUI components it depends on.

### Manual

Add the HextaUI theme tokens (https://hextaui.com/docs/installation#theme) to your global CSS if you haven't yet, then install the dependencies.

```bash
pnpm add @base-ui/react @tabler/icons-react class-variance-authority cn
```

Copy and paste the following code into your project.

```tsx title="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,
}
```

```tsx title="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,
}
```

```ts title="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 }
```

Update the import paths to match your project setup.

## Usage

```tsx
import {
  Badge,
  BadgeClose,
  BadgeCount,
  BadgeDot,
} from "@/components/ui/badge"
```

```tsx
<Badge variant="success">
  <BadgeDot />
  Paid
</Badge>
```

## Composition

```text
Badge
├── BadgeDot
├── BadgeCount
└── BadgeClose
```

## Examples

### Variants 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.

```tsx title="components/examples/badge/variants.tsx"
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.

```tsx title="components/examples/badge/sizes.tsx"
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.

```tsx title="components/examples/badge/removable.tsx"
"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.

```tsx title="components/examples/badge/controlled.tsx"
"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.

```tsx title="components/examples/badge/counts.tsx"
"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.

```tsx title="components/examples/badge/interactive.tsx"
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.

```tsx title="components/examples/badge/long-content.tsx"
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" />
        firstname.lastname+tag@subdomain.example.co.uk
        <BadgeClose />
      </Badge>
    </div>
  )
}
```

### Right to left

Icons, the close button and the collapse animation follow the reading direction. Counts always read left to right.

```tsx title="components/examples/badge/rtl.tsx"
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>
  )
}
```

## Keyboard

These keys apply to `<BadgeClose />`.

| Key | Action |
| --- | --- |
| `Enter` `Space` | Removes the badge. |
| `Backspace` `Delete` | 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. |

## Accessibility

- The close button is named after the badge, so a screen reader announces “Remove design” rather than a bare “Remove”. Pass `aria-label` to 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.

## API reference

`<Badge />` renders a `<span>` and accepts all of its attributes.

### Badge

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `"default" \| "success" \| "info" \| "warning" \| "destructive"` | `"default"` | Colors the dot and icons in outline, or the surface in solid. |
| `appearance` | `"outline" \| "solid"` | `"outline"` |  |
| `size` | `"sm" \| "default" \| "lg"` | `"default"` |  |
| `open` | `boolean` | – | Controlled visibility. |
| `defaultOpen` | `boolean` | `true` |  |
| `onOpenChange` | `(open: boolean) => void` | – | Called when BadgeClose is activated. |
| `onOpenChangeComplete` | `(open: boolean) => void` | – | Called after the exit animation finishes. Remove the item from your data here. |
| `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. |

### BadgeDot

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `pulse` | `boolean` | `false` | Adds a ping animation for live states. Off when reduced motion is on. |

| Attribute | Description |
| --- | --- |
| `data-slot="badge-dot"` | Target dots in CSS. |
| `data-pulse` | Present when pulse is on. |

### BadgeClose

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `ReactNode` | `<IconX />` |  |
| `aria-label` | `string` | – | Overrides the automatic “Remove {label}” name. |
| `onClick` | `(event) => void` | – | Call event.preventDefault() to keep the badge open. |

| Attribute | Description |
| --- | --- |
| `data-slot="badge-close"` | Target the close button in CSS. |

### BadgeCount

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `number` | – | Negative and non-finite values show 0. |
| `max` | `number` | `99` | Values above it show as max+. Pass Infinity for no cap. |
| `duration` | `number` | `600` |  |
| `animated` | `boolean` | `true` |  |
| `trend` | `"auto" \| "up" \| "down" \| "shortest"` | `"auto"` | The direction the digits spin. |
| `locales` | `Intl.LocalesArgument` | – |  |
| `format` | `Intl.NumberFormatOptions` | – |  |

| Attribute | Description |
| --- | --- |
| `data-slot="badge-count"` | Target counts in CSS. |

## Notes for AI assistants

- Install a component with the shadcn CLI: `npx shadcn@latest add https://hextaui.com/r/<name>.json`. It adds the source, the HextaUI theme tokens and any HextaUI components it depends on. `https://hextaui.com/r/all.json` installs every component.
- The code is then owned by the project, like shadcn/ui. There is no HextaUI npm package. HextaUI is MIT licensed and free for personal and commercial use.
- Behavior and accessibility come from Base UI (`@base-ui/react`). Compose with the `render` prop, not `asChild`.
- Styling uses Tailwind CSS v4 with theme tokens. Merge classes with `cn` from the `cn` package.
- Icons come from `@tabler/icons-react`.
- Import components from `@/components/ui/<name>`, hooks from `@/hooks/<name>` and utilities from `@/lib/<name>`.

Every HextaUI doc: https://hextaui.com/llms.txt
