shshadcn-htmx

Components

Badge

A small visual marker — status pill, counter, label. Non-interactive by default; renders as <span>, switches to <a> when an href is supplied.

Installation

1. Install via the shadcn CLI

npx shadcn@latest add http://localhost/r/badge.json

2. Use it

components/ui/badge.tsx
import { Badge } from "@/components/ui/badge"

<Badge>New</Badge>
<Badge variant="destructive">Unstable</Badge>
<Badge as="a" href="/docs">Docs</Badge>
Or copy the source manually
components/ui/badge.tsx
/** @jsxImportSource hono/jsx */
import type { PropsWithChildren } from "hono/jsx"
import { cloneElement, isValidElement } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"

// Badge — shadcn-htmx, htmx v4 + Tailwind v4.
//
// Source of truth (variants + base class 1:1):
//   repos/shadcn-ui/apps/v4/registry/new-york-v4/ui/badge.tsx
//
// A non-interactive visual marker. Renders as <span> by default. Pass
// `as="a"` (or `asChild` with a child anchor) to make it a link — the
// upstream uses Radix Slot.Root; we use cloneElement.
//
// Accessibility:
//   - The badge's content (text) IS the accessible name. If you render an
//     icon-only badge, set `ariaLabel` so screen readers can name it.
//   - Status-style badges ("New", "3 unread") that update in place should
//     live inside an aria-live region (use Alert / Toast for that, not
//     Badge). Badge itself is presentational.
//   - See repos/mdn/files/en-us/web/html/reference/elements/span/index.md for
//     <span> semantics (none — it's a generic inline container).

export type BadgeVariant =
  | "default"
  | "secondary"
  | "destructive"
  | "outline"
  | "ghost"
  | "link"

const base =
  "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] " +
  "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 " +
  "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 " +
  "[&>svg]:pointer-events-none [&>svg]:size-3"

const variants: Record<BadgeVariant, string> = {
  default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
  secondary: "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
  destructive:
    "bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
  outline:
    "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
  ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
  link: "text-primary underline-offset-4 [a&]:hover:underline",
}

export function badgeClasses(opts?: {
  variant?: BadgeVariant
  class?: ClassValue
}): string {
  const variant = opts?.variant ?? "default"
  return cn(base, variants[variant], opts?.class)
}

type BadgeProps = PropsWithChildren<{
  variant?: BadgeVariant
  class?: ClassValue
  id?: string
  ariaLabel?: string
  ariaLabelledby?: string
  // Render as a different element. <a> is the common case (link badge).
  as?: "span" | "a" | "div" | "button"
  href?: string
  // SSR-friendly equivalent of shadcn's asChild — clone the single JSX
  // child and merge classes onto it. Useful for wrapping a custom <Link>.
  asChild?: boolean
}>

export function Badge(props: BadgeProps) {
  const {
    children,
    variant,
    class: className,
    as,
    href,
    asChild,
    ariaLabel,
    ariaLabelledby,
    id,
    ...rest
  } = props

  const classes = badgeClasses({ variant, class: className })

  if (asChild && isValidElement(children)) {
    const child = children as any
    return cloneElement(child, {
      ...rest,
      class: cn(classes, child?.props?.class),
      "data-slot": "badge",
      "data-variant": variant ?? "default",
      "aria-label": ariaLabel,
      "aria-labelledby": ariaLabelledby,
    })
  }

  const Tag: any = as ?? (href ? "a" : "span")
  return (
    <Tag
      id={id}
      href={href}
      data-slot="badge"
      data-variant={variant ?? "default"}
      class={classes}
      aria-label={ariaLabel}
      aria-labelledby={ariaLabelledby}
      {...rest}
    >
      {children}
    </Tag>
  )
}

1. Save the file

Copy badge.html into templates/components/.

2. Use it

templates/components/badge.html
{% from "components/badge.html" import badge %}

{{ badge("New") }}
{{ badge("Unstable", variant="destructive") }}
{{ badge("Docs", tag="a", href="/docs") }}
View source
templates/components/badge.html
{# Badge macro — shadcn-htmx, htmx v4 + Tailwind v4.
   Mirrors registry/ui/badge.tsx. Renders <span> by default; pass tag="a"
   + href for a link badge, tag="button" for an interactive one.

   Usage:
     {% from "components/badge.html" import badge %}
     {{ badge("New", variant="default") }}
     {{ badge("Unstable", variant="destructive") }}
     {{ badge("Docs", tag="a", href="/docs") }} #}

{% macro badge(
    text,
    variant="default",
    tag="span",
    href=none,
    id=none,
    aria_label=none,
    aria_labelledby=none,
    extra_class="",
    **attrs
) %}
{%- set base -%}
inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3
{%- endset -%}
{%- set variants = {
    "default": "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
    "secondary": "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
    "destructive": "bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
    "outline": "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
    "ghost": "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
    "link": "text-primary underline-offset-4 [a&]:hover:underline"
} -%}
<{{ tag }}
  {%- if id %} id="{{ id }}"{% endif %}
  {%- if href and tag == "a" %} href="{{ href }}"{% endif %}
  {%- if aria_label %} aria-label="{{ aria_label }}"{% endif %}
  {%- if aria_labelledby %} aria-labelledby="{{ aria_labelledby }}"{% endif %}
  data-slot="badge"
  data-variant="{{ variant }}"
  class="{{ base }} {{ variants[variant] }} {{ extra_class }}"
  {%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
>{{ text }}</{{ tag }}>
{% endmacro %}

1. Save the file

Add badge.tmpl alongside button.tmpl.

2. Use it

templates/components/badge.tmpl
{{template "badge" (dict "Text" "New")}}
{{template "badge" (dict "Text" "Unstable" "Variant" "destructive")}}
{{template "badge" (dict "Text" "Docs" "Tag" "a" "Href" "/docs")}}
View source
templates/components/badge.tmpl
{{/*
  Badge template — shadcn-htmx, htmx v4 + Tailwind v4.
  Mirrors registry/ui/badge.tsx.

  Usage:

      type BadgeArgs struct {
          Text                 string
          Variant              string // default | secondary | destructive | outline | ghost | link
          Tag                  string // "span" (default) | "a" | "div" | "button"
          Href                 string
          ID, AriaLabel        string
          AriaLabelledby       string
          Attrs                map[string]string
      }
*/}}

{{define "badge"}}
{{- $tag := or .Tag "span" -}}
{{- $variant := or .Variant "default" -}}
{{- $base := "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3" -}}
{{- $variants := dict
    "default" "bg-primary text-primary-foreground [a&]:hover:bg-primary/90"
    "secondary" "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90"
    "destructive" "bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90"
    "outline" "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground"
    "ghost" "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground"
    "link" "text-primary underline-offset-4 [a&]:hover:underline" -}}
<{{$tag}}
  {{- if .ID}} id="{{.ID}}"{{end}}
  {{- if and (eq $tag "a") .Href}} href="{{.Href}}"{{end}}
  {{- if .AriaLabel}} aria-label="{{.AriaLabel}}"{{end}}
  {{- if .AriaLabelledby}} aria-labelledby="{{.AriaLabelledby}}"{{end}}
  data-slot="badge" data-variant="{{$variant}}"
  class="{{$base}} {{index $variants $variant}}"
  {{- range $k, $v := .Attrs}} {{$k}}="{{$v}}"{{end -}}
>{{.Text}}</{{$tag}}>
{{end}}

1. Save the file

Drop badge.ex into lib/my_app_web/components/.

2. Use it

lib/my_app_web/components/badge.ex
<.badge>New</.badge>
<.badge variant="destructive">Unstable</.badge>
<.badge as="a" href={~p"/docs"}>Docs</.badge>
View source
lib/my_app_web/components/badge.ex
defmodule ShadcnHtmx.Components.Badge do
  @moduledoc """
  Badge — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.

  Mirrors registry/ui/badge.tsx. Non-interactive visual marker; renders
  as `<span>` by default. Pass `as="a"` + `href` for a link badge,
  `as="button"` for a clickable one.

  Accessibility: badge text is the accessible name. Use `aria-label` for
  icon-only badges. Status badges that change in place should live inside
  an `aria-live` region (see Alert / Toast).
  """

  use Phoenix.Component

  @base "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] " <>
          "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 " <>
          "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 " <>
          "[&>svg]:pointer-events-none [&>svg]:size-3"

  @variants %{
    "default" => "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
    "secondary" =>
      "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
    "destructive" =>
      "bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
    "outline" =>
      "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
    "ghost" => "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
    "link" => "text-primary underline-offset-4 [a&]:hover:underline"
  }

  attr :variant, :string,
    default: "default",
    values: ~w(default secondary destructive outline ghost link)

  attr :as, :string, default: "span", values: ~w(span a div button)
  attr :href, :string, default: nil
  attr :class, :string, default: nil
  attr :rest, :global

  slot :inner_block, required: true

  def badge(assigns) do
    assigns =
      assigns
      |> assign(:variant_class, Map.fetch!(@variants, assigns.variant))
      |> assign(:base_class, @base)

    ~H"""
    <.dynamic_tag
      tag_name={@as}
      data-slot="badge"
      data-variant={@variant}
      href={if @as == "a", do: @href}
      class={[@base_class, @variant_class, @class]}
      {@rest}
    >
      {render_slot(@inner_block)}
    </.dynamic_tag>
    """
  end
end

1. Save the file

Tailwind v4 utilities only; no script.

2. Use it

index.html
<span data-slot="badge" data-variant="default"
      class="inline-flex w-fit shrink-0 items-center …">
  New
</span>
View source
index.html
<!--
  shadcn-htmx — raw HTML badge snippets.

  BASE (all variants):
    inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden
    rounded-full border border-transparent px-2 py-0.5 text-xs font-medium
    whitespace-nowrap transition-[color,box-shadow]
    focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50
    aria-invalid:border-destructive aria-invalid:ring-destructive/20
    dark:aria-invalid:ring-destructive/40
    [&>svg]:pointer-events-none [&>svg]:size-3
-->

<!-- default -->
<span data-slot="badge" data-variant="default"
  class="inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3 bg-primary text-primary-foreground">
  New
</span>

<!-- secondary -->
<span data-slot="badge" data-variant="secondary"
  class="inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3 bg-secondary text-secondary-foreground">
  Beta
</span>

<!-- destructive -->
<span data-slot="badge" data-variant="destructive"
  class="inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3 bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40">
  Unstable
</span>

<!-- outline -->
<span data-slot="badge" data-variant="outline"
  class="inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3 border-border text-foreground">
  Draft
</span>

<!-- ghost -->
<span data-slot="badge" data-variant="ghost"
  class="inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3 text-foreground">
  Pinned
</span>

<!-- Link variant (as anchor) -->
<a href="/docs/badge" data-slot="badge" data-variant="link"
  class="inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3 text-primary underline-offset-4 hover:underline">
  Read docs
</a>

<!-- Icon-only badge — set aria-label so it has an accessible name -->
<span data-slot="badge" data-variant="destructive" aria-label="3 unread notifications"
  class="inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3 bg-destructive text-white">
  3
</span>

Examples

Variants — six on-brand colours

Same six variants as Button, mapped to the badge's pill shape.

Variant choice carries meaning: destructive for errors / removal, outline for low-emphasis tags, secondary for neutral counts. Don't over-decorate — a single accent per row reads cleanly, three competes for attention.

DefaultSecondaryDestructiveOutlineGhost
<Badge>Default</Badge>
<Badge variant="secondary">Secondary</Badge>
<Badge variant="destructive">Destructive</Badge>
<Badge variant="outline">Outline</Badge>
<Badge variant="ghost">Ghost</Badge>
{{ badge("Default") }}
{{ badge("Secondary", variant="secondary") }}
{{ badge("Destructive", variant="destructive") }}
{{ badge("Outline", variant="outline") }}
{{ badge("Ghost", variant="ghost") }}
{{template "badge" (dict "Text" "Default")}}
{{template "badge" (dict "Text" "Secondary"   "Variant" "secondary")}}
{{template "badge" (dict "Text" "Destructive" "Variant" "destructive")}}
{{template "badge" (dict "Text" "Outline"     "Variant" "outline")}}
{{template "badge" (dict "Text" "Ghost"       "Variant" "ghost")}}
<.badge>Default</.badge>
<.badge variant="secondary">Secondary</.badge>
<.badge variant="destructive">Destructive</.badge>
<.badge variant="outline">Outline</.badge>
<.badge variant="ghost">Ghost</.badge>
<div class="flex flex-wrap items-center justify-center gap-2">
  <span data-slot="badge" data-variant="default" class="inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;&gt;svg]:pointer-events-none [&amp;&gt;svg]:size-3 bg-primary text-primary-foreground [a&amp;]:hover:bg-primary/90">Default</span>
  <span data-slot="badge" data-variant="secondary" class="inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;&gt;svg]:pointer-events-none [&amp;&gt;svg]:size-3 bg-secondary text-secondary-foreground [a&amp;]:hover:bg-secondary/90">Secondary</span>
  <span data-slot="badge" data-variant="destructive" class="inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;&gt;svg]:pointer-events-none [&amp;&gt;svg]:size-3 bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&amp;]:hover:bg-destructive/90">Destructive</span>
  <span data-slot="badge" data-variant="outline" class="inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;&gt;svg]:pointer-events-none [&amp;&gt;svg]:size-3 border-border text-foreground [a&amp;]:hover:bg-accent [a&amp;]:hover:text-accent-foreground">Outline</span>
  <span data-slot="badge" data-variant="ghost" class="inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;&gt;svg]:pointer-events-none [&amp;&gt;svg]:size-3 [a&amp;]:hover:bg-accent [a&amp;]:hover:text-accent-foreground">Ghost</span>
</div>

Pass href to render as <a> with hover state. Use variant="link" for an underlined text-only style.

When the badge is clickable, render a real link (<a>) so the platform handles cursor, focus ring, middle-click, and assistive-tech link role. The [a&]: Tailwind selector only applies hover styles when the badge is rendered as an anchor — non-link badges stay static.

<Badge as="a" href="/docs/badge">Read the docs</Badge>
<Badge as="a" href="https://htmx.org" variant="link">htmx.org</Badge>
{{ badge("Read the docs", tag="a", href="/docs/badge") }}
{{ badge("htmx.org",     tag="a", href="https://htmx.org", variant="link") }}
{{template "badge" (dict "Text" "Read the docs" "Tag" "a" "Href" "/docs/badge")}}
{{template "badge" (dict "Text" "htmx.org" "Tag" "a" "Href" "https://htmx.org" "Variant" "link")}}
<.badge as="a" href={~p"/docs/badge"}>Read the docs</.badge>
<.badge as="a" href="https://htmx.org" variant="link">htmx.org</.badge>
<div class="flex flex-wrap items-center justify-center gap-2">
  <a href="/docs/badge" data-slot="badge" data-variant="default" class="inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;&gt;svg]:pointer-events-none [&amp;&gt;svg]:size-3 bg-primary text-primary-foreground [a&amp;]:hover:bg-primary/90">Read the docs</a>
  <a href="https://htmx.org" data-slot="badge" data-variant="link" class="inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;&gt;svg]:pointer-events-none [&amp;&gt;svg]:size-3 text-primary underline-offset-4 [a&amp;]:hover:underline">htmx.org</a>
</div>

Further reading

Icon-only — counter with accessible name

Number-only badges (notification counters) need ariaLabel so screen readers announce "3 unread notifications" instead of just "3".

WCAG 1.3.1 (Info and Relationships) says programmatic meaning must match visual meaning. A "3" by itself is ambiguous — pair it with ariaLabel="3 unread notifications" so the count's context comes through. Counters that update on the client (htmx swap, push) should also live in a live-region (see Alert) so changes are announced.

31299+
<Badge variant="destructive" ariaLabel="3 unread notifications">3</Badge>
<Badge variant="secondary"   ariaLabel="12 items">12</Badge>
<Badge variant="default"     ariaLabel="99 plus messages">99+</Badge>
{{ badge("3",   variant="destructive", aria_label="3 unread notifications") }}
{{ badge("12",  variant="secondary",   aria_label="12 items") }}
{{ badge("99+", variant="default",     aria_label="99 plus messages") }}
{{template "badge" (dict "Text" "3"   "Variant" "destructive" "AriaLabel" "3 unread notifications")}}
{{template "badge" (dict "Text" "12"  "Variant" "secondary"   "AriaLabel" "12 items")}}
{{template "badge" (dict "Text" "99+" "Variant" "default"     "AriaLabel" "99 plus messages")}}
<.badge variant="destructive" aria-label="3 unread notifications">3</.badge>
<.badge variant="secondary"   aria-label="12 items">12</.badge>
<.badge variant="default"     aria-label="99 plus messages">99+</.badge>
<div class="flex flex-wrap items-center justify-center gap-3">
  <span data-slot="badge" data-variant="destructive" class="inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;&gt;svg]:pointer-events-none [&amp;&gt;svg]:size-3 bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&amp;]:hover:bg-destructive/90" aria-label="3 unread notifications">3</span>
  <span data-slot="badge" data-variant="secondary" class="inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;&gt;svg]:pointer-events-none [&amp;&gt;svg]:size-3 bg-secondary text-secondary-foreground [a&amp;]:hover:bg-secondary/90" aria-label="12 items">12</span>
  <span data-slot="badge" data-variant="default" class="inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;&gt;svg]:pointer-events-none [&amp;&gt;svg]:size-3 bg-primary text-primary-foreground [a&amp;]:hover:bg-primary/90" aria-label="99 plus messages">99+</span>
</div>

With icon — SVG + label

An inline SVG plus a short label. The SVG sits inside the badge as a child; the [&>svg]:size-3 utility sizes it automatically.

Decorative SVGs should carry aria-hidden="true" so AT doesn't double-announce. If the icon is the entire badge content (no text), drop aria-hidden and add role="img" + aria-label to the badge itself instead.

SavedFailedPending
<Badge>
  <CheckIcon aria-hidden="true" /> Saved
</Badge>
<Badge variant="destructive">
  <XCircleIcon aria-hidden="true" /> Failed
</Badge>
{{ badge("Saved",  variant="default") }}    {# wrap an <svg> inside the macro call if your macro accepts a body #}
{{ badge("Failed", variant="destructive") }}
{{template "badge" (dict "Text" "Saved")}}
{{template "badge" (dict "Text" "Failed" "Variant" "destructive")}}
<.badge>
  <.icon name="hero-check" aria-hidden="true" /> Saved
</.badge>
<div class="flex flex-wrap items-center justify-center gap-2">
  <span data-slot="badge" data-variant="default" class="inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;&gt;svg]:pointer-events-none [&amp;&gt;svg]:size-3 bg-primary text-primary-foreground [a&amp;]:hover:bg-primary/90">
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
      <polyline points="20 6 9 17 4 12">
      </polyline>
    </svg>
    Saved
  </span>
  <span data-slot="badge" data-variant="destructive" class="inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;&gt;svg]:pointer-events-none [&amp;&gt;svg]:size-3 bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&amp;]:hover:bg-destructive/90">
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
      <circle cx="12" cy="12" r="10">
      </circle>
      <line x1="15" y1="9" x2="9" y2="15">
      </line>
      <line x1="9" y1="9" x2="15" y2="15">
      </line>
    </svg>
    Failed
  </span>
  <span data-slot="badge" data-variant="outline" class="inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;&gt;svg]:pointer-events-none [&amp;&gt;svg]:size-3 border-border text-foreground [a&amp;]:hover:bg-accent [a&amp;]:hover:text-accent-foreground">
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
      <circle cx="12" cy="12" r="10">
      </circle>
      <line x1="12" y1="8" x2="12" y2="12">
      </line>
      <line x1="12" y1="16" x2="12.01" y2="16">
      </line>
    </svg>
    Pending
  </span>
</div>

Further reading

API Reference

<Badge>

PropTypeDefaultDescription
target / rel / download / referrerpolicy / hreflang / type / pingstring
Anchor attributes forwarded to the underlying <a> when as="a" (or via asChild). Use target="_blank" (with rel="noopener") for new-tab link badges, and download for download badges.MDN<a>
hx-*any
Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference
variant"default"|"secondary"|"destructive"|"outline"|"ghost"|"link""default"
Visual variant.
as"span"|"a"|"div"|"button""span"
Element to render. Defaults to span (or a if href provided).
hrefstring
When set, renders as <a> and adds hover state.
ariaLabelstring
Required for icon-only badges (e.g. "3 unread notifications").
asChildboolean
Render child instead, merging badge classes onto it.
classstring
Extra Tailwind classes appended to the root element.