Badge

Displays a badge or a component that looks like a badge.

BadgeSecondaryDestructiveOutline
import { Badge } from "@/components/ui/badge"
import { HStack } from "@/components/ui/stack"

export default function BadgeDemo() {
  return (
    <HStack align="center" gap={2} wrap="wrap">
      <Badge>Badge</Badge>
      <Badge variant="secondary">Secondary</Badge>
      <Badge variant="destructive">Destructive</Badge>
      <Badge variant="outline">Outline</Badge>
    </HStack>
  )
}

Installation

Install the following dependencies:

pnpm add @base-ui/react @stylexjs/stylex clsx

Set up the token layer and the StyleX compiler, if you have not already. See Installation, and paste states.ts from the same page. The badge's focus ring and invalid ring come from it. The badge also needs interop.css, which sizes the icon.

Copy and paste the following code into your project.

components/ui/badge.tsx
"use client"

import { useRender } from "@base-ui/react/use-render"
import * as stylex from "@stylexjs/stylex"
import type { StyleXStyles } from "@stylexjs/stylex"
import { clsx } from "clsx"

import { states } from "@/theme/states"
import {
  color,
  duration,
  ease,
  leading,
  radius,
  shadow,
  space,
  text,
} from "@/theme/tokens.stylex"

const styles = stylex.create({
  base: {
    display: "inline-flex",
    alignItems: "center",
    justifyContent: "center",
    width: "fit-content",
    flexShrink: 0,
    whiteSpace: "nowrap",
    overflow: "hidden",
    height: space.s5,
    gap: space.s1,
    paddingInline: space.s2,
    paddingBlock: space.s0_5,
    borderRadius: radius.xl4,
    borderWidth: 1,
    borderStyle: "solid",
    fontSize: text.xs,
    lineHeight: leading.xs,
    fontWeight: 500,
    transitionProperty: "color, background-color, border-color, box-shadow",
    transitionDuration: duration.color,
    transitionTimingFunction: ease.standard,
    "--carbon-icon-size": space.s3,
  },
  iconStart: { paddingInlineStart: space.s1_5 },
  iconEnd: { paddingInlineEnd: space.s1_5 },
})

const variants = stylex.create({
  default: {
    backgroundColor: color.primary,
    color: color.primaryForeground,
  },
  secondary: {
    backgroundColor: color.secondary,
    color: color.secondaryForeground,
  },
  destructive: {
    backgroundColor: color.buttonDestructiveBg,
    color: color.destructive,
    boxShadow: { default: null, ":focus-visible": shadow.ringInvalid },
  },
  outline: {
    "--carbon-border-color": color.border,
    color: color.foreground,
  },
  ghost: {},
  link: {
    color: color.primary,
    textUnderlineOffset: "4px",
    textDecorationLine: "none",
  },
})

const badgeStyles = {
  base: [styles.base, states.focusRing],
  variants,
  iconStart: styles.iconStart,
  iconEnd: styles.iconEnd,
}

type BadgeVariant = keyof typeof variants

type BadgeProps = useRender.ComponentProps<"span"> & {
  variant?: BadgeVariant
  iconPosition?: "start" | "end" | "both"
  xstyle?: StyleXStyles
}

function Badge({
  variant = "default",
  iconPosition,
  xstyle,
  className,
  render,
  ...props
}: BadgeProps) {
  const sx = stylex.props(
    styles.base,
    states.focusRing,
    variants[variant] ?? variants.default,
    (iconPosition === "start" || iconPosition === "both") && styles.iconStart,
    (iconPosition === "end" || iconPosition === "both") && styles.iconEnd,
    props["aria-invalid"] && states.invalidRing,
    xstyle,
  )

  return useRender({
    defaultTagName: "span",
    render,
    props: {
      "data-slot": "badge",
      "data-variant": variant,
      ...sx,
      ...props,
      className: clsx("carbon-icons", sx.className, className),
    },
  })
}

export { Badge, badgeStyles, type BadgeVariant }

Update the import paths to match your project setup.

Usage

import { Badge } from "@/components/ui/badge"
<Badge variant="secondary">Badge</Badge>

Variants

Use the variant prop to change the visual style.

DefaultSecondaryDestructiveOutlineGhostLink
import { Badge } from "@/components/ui/badge"
import { HStack } from "@/components/ui/stack"

export default function BadgeVariants() {
  return (
    <HStack align="center" gap={2} wrap="wrap">
      <Badge variant="default">Default</Badge>
      <Badge variant="secondary">Secondary</Badge>
      <Badge variant="destructive">Destructive</Badge>
      <Badge variant="outline">Outline</Badge>
      <Badge variant="ghost">Ghost</Badge>
      <Badge variant="link">Link</Badge>
    </HStack>
  )
}

With icon

Set iconPosition to the side the icon is on, so the badge tightens its padding there.

VerifiedBookmark
import { Badge } from "@/components/ui/badge"
import { HStack } from "@/components/ui/stack"
import { BadgeCheck, Bookmark } from "lucide-react"

export default function BadgeWithIcon() {
  return (
    <HStack align="center" gap={2} wrap="wrap">
      <Badge variant="secondary" iconPosition="start">
        <BadgeCheck />
        Verified
      </Badge>
      <Badge variant="outline" iconPosition="end">
        Bookmark
        <Bookmark />
      </Badge>
    </HStack>
  )
}

Without iconPosition the padding stays symmetric and the glyph looks inset against the round end. StyleX has no :has(), so the badge has to be told which side the icon is on.

Loading

Render a Spinner and set iconPosition to its side.

DeletingGenerating
import { Badge } from "@/components/ui/badge"
import { Spinner } from "@/components/ui/spinner"
import { HStack } from "@/components/ui/stack"

export default function BadgeLoading() {
  return (
    <HStack align="center" gap={2} wrap="wrap">
      <Badge variant="destructive" iconPosition="start">
        <Spinner aria-hidden="true" />
        Deleting
      </Badge>
      <Badge variant="secondary" iconPosition="end">
        Generating
        <Spinner aria-hidden="true" />
      </Badge>
    </HStack>
  )
}

Pass render to change the element the badge renders.

import * as stylex from "@stylexjs/stylex"
import { color } from "@/theme/tokens.stylex"
import { Badge } from "@/components/ui/badge"
import { HStack } from "@/components/ui/stack"
import { ArrowUpRight } from "lucide-react"

const styles = stylex.create({
  primary: {
    backgroundColor: {
      default: color.primary,
      ":hover": `color-mix(in oklab, ${color.primary} 80%, transparent)`,
    },
  },
  outline: { backgroundColor: { default: null, ":hover": color.muted } },
})

export default function BadgeAsLink() {
  return (
    <HStack align="center" gap={2} wrap="wrap">
      <Badge
        xstyle={styles.primary}
        render={<a href="https://base-ui.com" target="_blank" rel="noopener noreferrer" />}
      >
        Base UI
      </Badge>
      <Badge
        variant="outline"
        xstyle={styles.outline}
        iconPosition="end"
        render={<a href="https://base-ui.com/react/utils/use-render" />}
      >
        useRender
        <ArrowUpRight />
      </Badge>
    </HStack>
  )
}

Badges have no built-in hover styles. Add a hover through xstyle when using render for a link or another interactive element.

Custom colors

Pass xstyle to change the background, border and text colors.

InfoSuccessWarningTip
import * as stylex from "@stylexjs/stylex"
import { Badge } from "@/components/ui/badge"
import { HStack } from "@/components/ui/stack"
import { color } from "@/theme/tokens.stylex"

const styles = stylex.create({
  info: {
    backgroundColor: color.calloutInfoBg,
    borderColor: color.calloutInfoBorder,
    color: color.calloutInfoAccent,
  },
  success: {
    backgroundColor: color.calloutSuccessBg,
    borderColor: color.calloutSuccessBorder,
    color: color.calloutSuccessAccent,
  },
  warn: {
    backgroundColor: color.calloutWarnBg,
    borderColor: color.calloutWarnBorder,
    color: color.calloutWarnAccent,
  },
  tip: {
    backgroundColor: color.calloutTipBg,
    borderColor: color.calloutTipBorder,
    color: color.calloutTipAccent,
  },
})

export default function BadgeColors() {
  return (
    <HStack align="center" gap={2} wrap="wrap">
      <Badge xstyle={styles.info}>Info</Badge>
      <Badge xstyle={styles.success}>Success</Badge>
      <Badge xstyle={styles.warn}>Warning</Badge>
      <Badge xstyle={styles.tip}>Tip</Badge>
    </HStack>
  )
}

API reference

Pass render to replace a part's element or compose it with another component.

Badge

Renders a span through Base UI's useRender, so it accepts render alongside every span attribute.

PropTypeDefault
variant"default" | "secondary" | "destructive" | "outline" | "ghost" | "link""default"
iconPosition"start" | "end" | "both"—
renderReactElement | ((props, state) => ReactElement)<span />
xstyleStyleXStyles—
classNamestring—

xstyle is merged last, so it beats the variant. className is concatenated with no conflict resolution. Use it for plain-CSS hooks.

Setting aria-invalid switches the border and ring to the destructive pair.

badgeStyles

The style maps behind Badge. Spread them onto any element:

import * as stylex from "@stylexjs/stylex"
import { badgeStyles } from "@/components/ui/badge"
import { clsx } from "clsx"

const sx = stylex.props(badgeStyles.base, badgeStyles.variants.outline)

;<span data-variant="outline" {...sx} className={clsx("carbon-icons", sx.className)} />

Merge className from the stylex.props() result by hand. A second className attribute beside the spread replaces it.

KeyWhat it is
baseShape and type, with the shared focus ring
variantsOne namespace per variant
iconStartLeading-icon padding
iconEndTrailing-icon padding

base is two styles, the badge's own and the shared states.focusRing. Set a border of your own through --carbon-border-color rather than borderColor. A flat borderColor replaces the property with all of its conditions, so the ring loses its focus colour.

Keep the carbon-icons class. interop.css sizes icons off it, and without it a Lucide icon keeps its own 24px.

Data attributes

AttributeOnEffect
data-slot="badge"the badgeTargets the badge from a parent
data-variantthe badgeReflects the variant prop, for styling from a parent

CSS variables

VariableOnEffect
--carbon-icon-sizethe badgeThe icon's size, read by interop.css. Defaults to 12px
--carbon-border-colorthe badgeThe resting border colour the focus ring reads. outline sets it