shshadcn-htmx

Components

Breadcrumb

A <nav> landmark wrapping an <ol> of links. The current page is a plain <span aria-current="page"> — not a link. Zero JS; separators are decorative.

Installation

1. Install via the shadcn CLI

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

2. Use it

components/ui/breadcrumb.tsx
import { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink,
  BreadcrumbPage, BreadcrumbSeparator, BreadcrumbEllipsis } from "@/components/ui/breadcrumb"

<Breadcrumb>
  <BreadcrumbList>
    <BreadcrumbItem><BreadcrumbLink href="/">Home</BreadcrumbLink></BreadcrumbItem>
    <BreadcrumbSeparator />
    <BreadcrumbItem><BreadcrumbLink href="/components">Components</BreadcrumbLink></BreadcrumbItem>
    <BreadcrumbSeparator />
    <BreadcrumbItem><BreadcrumbPage>Breadcrumb</BreadcrumbPage></BreadcrumbItem>
  </BreadcrumbList>
</Breadcrumb>
Or copy the source manually
components/ui/breadcrumb.tsx
/** @jsxImportSource hono/jsx */
import type { PropsWithChildren } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"

// Breadcrumb — shadcn-htmx, htmx v4 + Tailwind v4.
//
// shadcn source of truth (React/Radix anatomy we mirror):
//   repos/shadcn-ui/apps/v4/registry/new-york-v4/ui/breadcrumb.tsx
//   repos/shadcn-ui/apps/v4/content/docs/components/radix/breadcrumb.mdx
//
// APG pattern (the accessibility contract):
//   repos/aria-practices/content/patterns/breadcrumb/breadcrumb-pattern.html
// The APG says: keyboard interaction is "Not applicable" — a breadcrumb is
// just a list of links, so there is ZERO JS here. The ARIA contract is:
//   1. The trail lives inside a navigation landmark.            (<nav>)
//   2. The landmark is labelled via aria-label / aria-labelledby.
//   3. The link to the current page carries aria-current="page".
//      "If the element representing the current page is not a link,
//       aria-current is optional."
//
// HOW WE DIFFER FROM RADIX shadcn:
//   - Radix renders BreadcrumbPage as <span role="link" aria-disabled="true"
//     aria-current="page">. That role="link" is an emulation that makes AT
//     announce a non-interactive element as a link — exactly the kind of
//     platform-faking AGENTS.md forbids. We drop role/aria-disabled and ship
//     a plain <span aria-current="page">: a real non-link element, which the
//     APG explicitly endorses ("If … not a link, aria-current is optional").
//     We keep aria-current because it still conveys "this is the current page"
//     and is harmless on a span:
//       repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-current/
//   - Radix BreadcrumbSeparator/Ellipsis use role="presentation"; we use plain
//     aria-hidden="true" which already removes the node from the a11y tree
//     (MDN aria-hidden) — no extra role needed for a decorative <li>/<span>.
//   - We render BreadcrumbList as <ol> (ordered: hierarchy has a direction),
//     matching the APG description "list of links … in hierarchical order".
//       repos/mdn/files/en-us/web/html/reference/elements/ol/

// Root navigation landmark. data-slot="breadcrumb".
type BreadcrumbProps = PropsWithChildren<{
  // Accessible name for the navigation landmark.
  ariaLabel?: string
  // Id of a visible heading that labels the landmark. When set, that heading
  // is the name source and the defaulted aria-label is NOT emitted, so the
  // <nav> never carries two competing names. APG: the landmark "is labelled
  // via aria-label or aria-labelledby" — both are first-class options.
  //   repos/aria-practices/content/patterns/breadcrumb/breadcrumb-pattern.html
  ariaLabelledby?: string
  class?: ClassValue
  [key: `hx-${string}`]: any
  [key: `data-${string}`]: any
  [key: `aria-${string}`]: any
}>
export function Breadcrumb(props: BreadcrumbProps) {
  const { ariaLabel = "Breadcrumb", ariaLabelledby, class: className, children, ...rest } = props
  return (
    <nav
      data-slot="breadcrumb"
      aria-label={ariaLabelledby ? undefined : ariaLabel}
      aria-labelledby={ariaLabelledby}
      class={cn(className)}
      {...rest}
    >
      {children}
    </nav>
  )
}

// Ordered list of trail items.
type BreadcrumbListProps = PropsWithChildren<{
  class?: ClassValue
  [key: `data-${string}`]: any
}>
export function BreadcrumbList(props: BreadcrumbListProps) {
  const { class: className, children, ...rest } = props
  return (
    <ol
      data-slot="breadcrumb-list"
      class={cn(
        "flex flex-wrap items-center gap-1.5 text-sm break-words text-muted-foreground sm:gap-2.5",
        className,
      )}
      {...rest}
    >
      {children}
    </ol>
  )
}

// A single trail item (link, page, or ellipsis goes inside).
type BreadcrumbItemProps = PropsWithChildren<{
  class?: ClassValue
  [key: `data-${string}`]: any
}>
export function BreadcrumbItem(props: BreadcrumbItemProps) {
  const { class: className, children, ...rest } = props
  return (
    <li data-slot="breadcrumb-item" class={cn("inline-flex items-center gap-1.5", className)} {...rest}>
      {children}
    </li>
  )
}

// A real <a> to a parent page. htmx attrs ride along for partial nav.
type BreadcrumbLinkProps = PropsWithChildren<{
  href?: string
  class?: ClassValue
  [key: `hx-${string}`]: any
  [key: `data-${string}`]: any
  [key: `aria-${string}`]: any
}>
export function BreadcrumbLink(props: BreadcrumbLinkProps) {
  const { href, class: className, children, ...rest } = props
  return (
    <a
      href={href}
      data-slot="breadcrumb-link"
      class={cn(
        "transition-colors hover:text-foreground",
        "focus-visible:rounded-sm focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none",
        className,
      )}
      {...rest}
    >
      {children}
    </a>
  )
}

// The current page. Plain <span aria-current="page"> — NOT a link.
type BreadcrumbPageProps = PropsWithChildren<{
  class?: ClassValue
  [key: `data-${string}`]: any
}>
export function BreadcrumbPage(props: BreadcrumbPageProps) {
  const { class: className, children, ...rest } = props
  return (
    <span
      data-slot="breadcrumb-page"
      aria-current="page"
      class={cn("font-normal text-foreground", className)}
      {...rest}
    >
      {children}
    </span>
  )
}

// Decorative separator between items. aria-hidden so AT skips the glyph.
// data-* passes through (a global attribute valid on every element) so callers
// can attach CSS/JS hooks, matching the other subcomponents.
//   repos/mdn/files/en-us/web/html/reference/global_attributes/index.md
type BreadcrumbSeparatorProps = PropsWithChildren<{
  class?: ClassValue
  [key: `data-${string}`]: any
}>
export function BreadcrumbSeparator(props: BreadcrumbSeparatorProps) {
  const { class: className, children, ...rest } = props
  return (
    <li
      data-slot="breadcrumb-separator"
      aria-hidden="true"
      class={cn("[&>svg]:size-3.5", className)}
      {...rest}
    >
      {children ?? (
        <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="9 18 15 12 9 6" />
        </svg>
      )}
    </li>
  )
}

// Collapsed-range indicator. aria-hidden glyph + sr-only "More" text so AT
// users still hear that items were omitted. data-* passes through (a global
// attribute valid on every element), matching the other subcomponents.
//   repos/mdn/files/en-us/web/html/reference/global_attributes/index.md
type BreadcrumbEllipsisProps = {
  class?: ClassValue
  [key: `data-${string}`]: any
}
export function BreadcrumbEllipsis(props: BreadcrumbEllipsisProps) {
  const { class: className, ...rest } = props
  return (
    <span
      data-slot="breadcrumb-ellipsis"
      aria-hidden="true"
      class={cn("flex size-9 items-center justify-center", className)}
      {...rest}
    >
      <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"
        class="size-4"
        aria-hidden="true"
      >
        <circle cx="12" cy="12" r="1" />
        <circle cx="19" cy="12" r="1" />
        <circle cx="5" cy="12" r="1" />
      </svg>
      <span class="sr-only">More</span>
    </span>
  )
}

1. Save the file

Copy breadcrumb.html into templates/components/.

2. Use it

templates/components/breadcrumb.html
{% from "components/breadcrumb.html" import breadcrumb_open, breadcrumb_close,
   breadcrumb_list_open, breadcrumb_list_close, breadcrumb_item,
   breadcrumb_link, breadcrumb_page, breadcrumb_separator %}

{{ breadcrumb_open(aria_label="Breadcrumb") }}{{ breadcrumb_list_open() }}
  {{ breadcrumb_item(breadcrumb_link("Home", href="/")) }}
  {{ breadcrumb_separator() }}
  {{ breadcrumb_item(breadcrumb_link("Components", href="/components")) }}
  {{ breadcrumb_separator() }}
  {{ breadcrumb_item(breadcrumb_page("Breadcrumb")) }}
{{ breadcrumb_list_close() }}{{ breadcrumb_close() }}
View source
templates/components/breadcrumb.html
{# Breadcrumb macros — shadcn-htmx, htmx v4 + Tailwind v4.
   Mirrors registry/ui/breadcrumb.tsx EXACTLY (same elements, ARIA,
   data-slot, classes). Zero JS — a breadcrumb is just a list of links.

   APG: repos/aria-practices/content/patterns/breadcrumb/breadcrumb-pattern.html
   The current page is a plain <span aria-current="page">, not a link.

   Usage:
     {% from "components/breadcrumb.html" import breadcrumb_open, breadcrumb_close,
        breadcrumb_list_open, breadcrumb_list_close, breadcrumb_item,
        breadcrumb_link, breadcrumb_page, breadcrumb_separator, breadcrumb_ellipsis %}

     {{ breadcrumb_open(aria_label="Breadcrumb") }}{{ breadcrumb_list_open() }}
       {{ breadcrumb_item(breadcrumb_link("Home", href="/")) }}
       {{ breadcrumb_separator() }}
       {{ breadcrumb_item(breadcrumb_link("Components", href="/components")) }}
       {{ breadcrumb_separator() }}
       {{ breadcrumb_item(breadcrumb_page("Breadcrumb")) }}
     {{ breadcrumb_list_close() }}{{ breadcrumb_close() }} #}

{# Pass aria_labelledby to point the <nav> at a visible heading id; when set,
   the defaulted aria-label is NOT emitted so the landmark never carries two
   competing names. APG: the landmark "is labelled via aria-label or
   aria-labelledby". repos/aria-practices/content/patterns/breadcrumb/breadcrumb-pattern.html #}
{% macro breadcrumb_open(aria_label="Breadcrumb", aria_labelledby=none, extra_class="", **attrs) %}
<nav data-slot="breadcrumb"
     {%- if aria_labelledby %} aria-labelledby="{{ aria_labelledby }}"{% else %} aria-label="{{ aria_label }}"{% endif %} class="{{ extra_class }}"
     {%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}>
{% endmacro %}

{% macro breadcrumb_close() %}
</nav>
{% endmacro %}

{% macro breadcrumb_list_open() %}
<ol data-slot="breadcrumb-list" class="flex flex-wrap items-center gap-1.5 text-sm break-words text-muted-foreground sm:gap-2.5">
{% endmacro %}

{% macro breadcrumb_list_close() %}
</ol>
{% endmacro %}

{% macro breadcrumb_item(body, **attrs) %}
<li data-slot="breadcrumb-item" class="inline-flex items-center gap-1.5"
    {%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}>{{ body|safe }}</li>
{% endmacro %}

{% macro breadcrumb_link(label, href=none, **attrs) %}
<a {% if href %}href="{{ href }}"{% endif %} data-slot="breadcrumb-link"
   class="transition-colors hover:text-foreground focus-visible:rounded-sm focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none"
   {%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}>{{ label|safe }}</a>
{% endmacro %}

{% macro breadcrumb_page(label) %}
<span data-slot="breadcrumb-page" aria-current="page" class="font-normal text-foreground">{{ label|safe }}</span>
{% endmacro %}

{# **attrs forwards data-* (a global attribute valid on every element) so
   callers can attach CSS/JS hooks, matching the other subcomponents.
   repos/mdn/files/en-us/web/html/reference/global_attributes/index.md #}
{% macro breadcrumb_separator(body=none, **attrs) %}
<li data-slot="breadcrumb-separator" aria-hidden="true" class="[&>svg]:size-3.5"
    {%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}>{% if body %}{{ body|safe }}{% else %}<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="9 18 15 12 9 6" /></svg>{% endif %}</li>
{% endmacro %}

{% macro breadcrumb_ellipsis(**attrs) %}
<span data-slot="breadcrumb-ellipsis" aria-hidden="true" class="flex size-9 items-center justify-center"
      {%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}><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" class="size-4" aria-hidden="true"><circle cx="12" cy="12" r="1" /><circle cx="19" cy="12" r="1" /><circle cx="5" cy="12" r="1" /></svg><span class="sr-only">More</span></span>
{% endmacro %}

1. Save the file

Add breadcrumb.tmpl alongside your other templates.

2. Use it

components/breadcrumb.tmpl
{{template "breadcrumb" (dict "Body" (htmlSafe `
  {{template "breadcrumb_list" (dict "Body" (htmlSafe \`
    {{template "breadcrumb_item" (dict "Body" (htmlSafe "<a href=\"/\" data-slot=breadcrumb-link>Home</a>"))}}
    {{template "breadcrumb_separator" (dict)}}
    {{template "breadcrumb_item" (dict "Body" (htmlSafe "..."))}}
    {{template "breadcrumb_separator" (dict)}}
    {{template "breadcrumb_item" (dict "Body" (htmlSafe "<span data-slot=breadcrumb-page aria-current=page>Breadcrumb</span>"))}}
  \`))}}`))}}
View source
components/breadcrumb.tmpl
{{/* Breadcrumb templates — shadcn-htmx, htmx v4 + Tailwind v4.
     Mirrors registry/ui/breadcrumb.tsx EXACTLY (elements, ARIA, data-slot,
     classes). Zero JS — a breadcrumb is just a list of links.

     APG: repos/aria-practices/content/patterns/breadcrumb/breadcrumb-pattern.html
     Current page is a plain <span aria-current="page">, not a link.

     Usage:
       {{template "breadcrumb" (dict "AriaLabel" "Breadcrumb" "Body" (htmlSafe `
         {{template "breadcrumb_list" (dict "Body" (htmlSafe `
           {{template "breadcrumb_item" (dict "Body" (htmlSafe (printf "%s" (call ... ))))}}
         `))}}
       `))}}
*/}}

{{/* Pass AriaLabelledby to point the <nav> at a visible heading id; when set,
     the defaulted aria-label is NOT emitted so the landmark never carries two
     competing names. APG: the landmark "is labelled via aria-label or
     aria-labelledby".
     repos/aria-practices/content/patterns/breadcrumb/breadcrumb-pattern.html */}}
{{define "breadcrumb"}}
<nav data-slot="breadcrumb"{{if .AriaLabelledby}} aria-labelledby="{{.AriaLabelledby}}"{{else}} aria-label="{{or .AriaLabel "Breadcrumb"}}"{{end}} class="{{.Class}}">{{.Body}}</nav>
{{end}}

{{define "breadcrumb_list"}}
<ol data-slot="breadcrumb-list" class="flex flex-wrap items-center gap-1.5 text-sm break-words text-muted-foreground sm:gap-2.5">{{.Body}}</ol>
{{end}}

{{define "breadcrumb_item"}}
<li data-slot="breadcrumb-item" class="inline-flex items-center gap-1.5">{{.Body}}</li>
{{end}}

{{define "breadcrumb_link"}}
<a {{if .Href}}href="{{.Href}}" {{end}}data-slot="breadcrumb-link" class="transition-colors hover:text-foreground focus-visible:rounded-sm focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none">{{.Label}}</a>
{{end}}

{{define "breadcrumb_page"}}
<span data-slot="breadcrumb-page" aria-current="page" class="font-normal text-foreground">{{.Label}}</span>
{{end}}

{{/* .Attrs forwards data-* (a global attribute valid on every element) so
     callers can attach CSS/JS hooks, matching the other subcomponents. Pass an
     htmlSafe-wrapped attribute string (e.g. ` data-variant="slash"`).
     repos/mdn/files/en-us/web/html/reference/global_attributes/index.md */}}
{{define "breadcrumb_separator"}}
<li data-slot="breadcrumb-separator" aria-hidden="true" class="[&>svg]:size-3.5"{{with .Attrs}}{{.}}{{end}}>{{if .Body}}{{.Body}}{{else}}<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="9 18 15 12 9 6" /></svg>{{end}}</li>
{{end}}

{{define "breadcrumb_ellipsis"}}
<span data-slot="breadcrumb-ellipsis" aria-hidden="true" class="flex size-9 items-center justify-center"{{with .Attrs}}{{.}}{{end}}><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" class="size-4" aria-hidden="true"><circle cx="12" cy="12" r="1" /><circle cx="19" cy="12" r="1" /><circle cx="5" cy="12" r="1" /></svg><span class="sr-only">More</span></span>
{{end}}

1. Save the file

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

2. Use it

lib/my_app_web/components/breadcrumb.ex
<.breadcrumb aria-label="Breadcrumb">
  <.breadcrumb_list>
    <.breadcrumb_item><.breadcrumb_link href={~p"/"}>Home</.breadcrumb_link></.breadcrumb_item>
    <.breadcrumb_separator />
    <.breadcrumb_item><.breadcrumb_link href={~p"/components"}>Components</.breadcrumb_link></.breadcrumb_item>
    <.breadcrumb_separator />
    <.breadcrumb_item><.breadcrumb_page>Breadcrumb</.breadcrumb_page></.breadcrumb_item>
  </.breadcrumb_list>
</.breadcrumb>
View source
lib/my_app_web/components/breadcrumb.ex
defmodule ShadcnHtmx.Components.Breadcrumb do
  @moduledoc """
  Breadcrumb — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.

  A `<nav>` landmark with `aria-label` wrapping an ordered list of links.
  Zero JS — a breadcrumb is just a list of links (the WAI-ARIA APG lists
  keyboard interaction as "Not applicable"). The current page is a plain
  `<span aria-current="page">`, not a link. Separators / ellipsis are
  `aria-hidden`.

  Mirrors registry/ui/breadcrumb.tsx EXACTLY (elements, ARIA, data-slot,
  classes). APG pattern:
  repos/aria-practices/content/patterns/breadcrumb/breadcrumb-pattern.html

  ## Examples

      <.breadcrumb aria-label="Breadcrumb">
        <.breadcrumb_list>
          <.breadcrumb_item>
            <.breadcrumb_link href={~p"/"}>Home</.breadcrumb_link>
          </.breadcrumb_item>
          <.breadcrumb_separator />
          <.breadcrumb_item>
            <.breadcrumb_link href={~p"/components"}>Components</.breadcrumb_link>
          </.breadcrumb_item>
          <.breadcrumb_separator />
          <.breadcrumb_item>
            <.breadcrumb_page>Breadcrumb</.breadcrumb_page>
          </.breadcrumb_item>
        </.breadcrumb_list>
      </.breadcrumb>
  """

  use Phoenix.Component

  attr :"aria-label", :string, default: "Breadcrumb"
  # Id of a visible heading that labels the landmark. When set, that heading is
  # the name source and the defaulted aria-label is NOT emitted, so the <nav>
  # never carries two competing names. APG: the landmark "is labelled via
  # aria-label or aria-labelledby".
  # repos/aria-practices/content/patterns/breadcrumb/breadcrumb-pattern.html
  attr :"aria-labelledby", :string, default: nil
  attr :class, :string, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def breadcrumb(assigns) do
    ~H"""
    <nav
      data-slot="breadcrumb"
      aria-label={if assigns[:"aria-labelledby"], do: nil, else: assigns[:"aria-label"]}
      aria-labelledby={assigns[:"aria-labelledby"]}
      class={[@class]}
      {@rest}
    >
      {render_slot(@inner_block)}
    </nav>
    """
  end

  attr :class, :string, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def breadcrumb_list(assigns) do
    ~H"""
    <ol
      data-slot="breadcrumb-list"
      class={[
        "flex flex-wrap items-center gap-1.5 text-sm break-words text-muted-foreground sm:gap-2.5",
        @class
      ]}
      {@rest}
    >
      {render_slot(@inner_block)}
    </ol>
    """
  end

  attr :class, :string, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def breadcrumb_item(assigns) do
    ~H"""
    <li data-slot="breadcrumb-item" class={["inline-flex items-center gap-1.5", @class]} {@rest}>
      {render_slot(@inner_block)}
    </li>
    """
  end

  attr :href, :string, default: nil
  attr :class, :string, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def breadcrumb_link(assigns) do
    ~H"""
    <a
      href={@href}
      data-slot="breadcrumb-link"
      class={[
        "transition-colors hover:text-foreground",
        "focus-visible:rounded-sm focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none",
        @class
      ]}
      {@rest}
    >
      {render_slot(@inner_block)}
    </a>
    """
  end

  attr :class, :string, default: nil
  slot :inner_block, required: true

  def breadcrumb_page(assigns) do
    ~H"""
    <span data-slot="breadcrumb-page" aria-current="page" class={["font-normal text-foreground", @class]}>
      {render_slot(@inner_block)}
    </span>
    """
  end

  attr :class, :string, default: nil
  # :rest forwards data-* (a global attribute valid on every element) so callers
  # can attach CSS/JS hooks, matching the other subcomponents.
  # repos/mdn/files/en-us/web/html/reference/global_attributes/index.md
  attr :rest, :global
  slot :inner_block

  def breadcrumb_separator(assigns) do
    ~H"""
    <li data-slot="breadcrumb-separator" aria-hidden="true" class={["[&>svg]:size-3.5", @class]} {@rest}>
      <%= if @inner_block != [] do %>
        {render_slot(@inner_block)}
      <% else %>
        <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="9 18 15 12 9 6" />
        </svg>
      <% end %>
    </li>
    """
  end

  attr :class, :string, default: nil
  # :rest forwards data-* (a global attribute valid on every element) so callers
  # can attach CSS/JS hooks, matching the other subcomponents.
  # repos/mdn/files/en-us/web/html/reference/global_attributes/index.md
  attr :rest, :global

  def breadcrumb_ellipsis(assigns) do
    ~H"""
    <span
      data-slot="breadcrumb-ellipsis"
      aria-hidden="true"
      class={["flex size-9 items-center justify-center", @class]}
      {@rest}
    >
      <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"
        class="size-4"
        aria-hidden="true"
      >
        <circle cx="12" cy="12" r="1" />
        <circle cx="19" cy="12" r="1" />
        <circle cx="5" cy="12" r="1" />
      </svg>
      <span class="sr-only">More</span>
    </span>
    """
  end
end

1. Save the file

Paste the markup; it relies only on the theme tokens in styles.css.

2. Use it

snippets/breadcrumb.html
<nav data-slot="breadcrumb" aria-label="Breadcrumb">
  <ol data-slot="breadcrumb-list" class="flex flex-wrap items-center gap-1.5 text-sm …">
    <li data-slot="breadcrumb-item" class="inline-flex items-center gap-1.5">
      <a href="/" data-slot="breadcrumb-link" class="hover:text-foreground …">Home</a>
    </li>
    <li data-slot="breadcrumb-separator" aria-hidden="true">›</li>
    <li data-slot="breadcrumb-item">
      <span data-slot="breadcrumb-page" aria-current="page" class="text-foreground">Breadcrumb</span>
    </li>
  </ol>
</nav>
View source
snippets/breadcrumb.html
<!--
  shadcn-htmx — raw HTML breadcrumb snippet.
  A <nav> landmark with aria-label wrapping an <ol> of links. The current
  page is a plain <span aria-current="page"> (not a link). Separators are
  decorative <li aria-hidden="true">. Zero JS — relies only on theme tokens
  in styles.css.

  Labelling: the landmark is named via aria-label OR aria-labelledby — both
  are first-class per the APG. To point the <nav> at an existing visible
  heading instead, drop aria-label and use aria-labelledby="heading-id" (do
  not set both, or the <nav> carries two competing names).

  Separators / ellipsis are decorative and accept any global attribute
  (e.g. a data-* hook for CSS/JS) directly on the <li>/<span>.

  APG: https://www.w3.org/WAI/ARIA/apg/patterns/breadcrumb/
-->

<nav data-slot="breadcrumb" aria-label="Breadcrumb">
  <ol data-slot="breadcrumb-list"
      class="flex flex-wrap items-center gap-1.5 text-sm break-words text-muted-foreground sm:gap-2.5">
    <li data-slot="breadcrumb-item" class="inline-flex items-center gap-1.5">
      <a href="/" data-slot="breadcrumb-link"
         class="transition-colors hover:text-foreground focus-visible:rounded-sm focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none">Home</a>
    </li>
    <li data-slot="breadcrumb-separator" aria-hidden="true" class="[&>svg]:size-3.5">
      <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="9 18 15 12 9 6" /></svg>
    </li>
    <li data-slot="breadcrumb-item" class="inline-flex items-center gap-1.5">
      <a href="/components" data-slot="breadcrumb-link"
         class="transition-colors hover:text-foreground focus-visible:rounded-sm focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none">Components</a>
    </li>
    <li data-slot="breadcrumb-separator" aria-hidden="true" class="[&>svg]:size-3.5">
      <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="9 18 15 12 9 6" /></svg>
    </li>
    <li data-slot="breadcrumb-item" class="inline-flex items-center gap-1.5">
      <span data-slot="breadcrumb-page" aria-current="page" class="font-normal text-foreground">Breadcrumb</span>
    </li>
  </ol>
</nav>

Examples

Basic — real links + current page

Parent pages are real <a href> links; the current page is a non-interactive <span aria-current="page">.

The <nav aria-label="Breadcrumb"> landmark lets AT users jump straight to the trail. Per the APG, the current page carries aria-current="page" and is rendered as a plain span — not a faked link — so the semantics match what the element actually is.

import { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink,
  BreadcrumbPage, BreadcrumbSeparator, BreadcrumbEllipsis } from "@/components/ui/breadcrumb"

<Breadcrumb>
  <BreadcrumbList>
    <BreadcrumbItem><BreadcrumbLink href="/">Home</BreadcrumbLink></BreadcrumbItem>
    <BreadcrumbSeparator />
    <BreadcrumbItem><BreadcrumbLink href="/components">Components</BreadcrumbLink></BreadcrumbItem>
    <BreadcrumbSeparator />
    <BreadcrumbItem><BreadcrumbPage>Breadcrumb</BreadcrumbPage></BreadcrumbItem>
  </BreadcrumbList>
</Breadcrumb>
{% from "components/breadcrumb.html" import breadcrumb_open, breadcrumb_close,
   breadcrumb_list_open, breadcrumb_list_close, breadcrumb_item,
   breadcrumb_link, breadcrumb_page, breadcrumb_separator %}

{{ breadcrumb_open(aria_label="Breadcrumb") }}{{ breadcrumb_list_open() }}
  {{ breadcrumb_item(breadcrumb_link("Home", href="/")) }}
  {{ breadcrumb_separator() }}
  {{ breadcrumb_item(breadcrumb_link("Components", href="/components")) }}
  {{ breadcrumb_separator() }}
  {{ breadcrumb_item(breadcrumb_page("Breadcrumb")) }}
{{ breadcrumb_list_close() }}{{ breadcrumb_close() }}
{{template "breadcrumb" (dict "Body" (htmlSafe `
  {{template "breadcrumb_list" (dict "Body" (htmlSafe \`
    {{template "breadcrumb_item" (dict "Body" (htmlSafe "<a href=\"/\" data-slot=breadcrumb-link>Home</a>"))}}
    {{template "breadcrumb_separator" (dict)}}
    {{template "breadcrumb_item" (dict "Body" (htmlSafe "..."))}}
    {{template "breadcrumb_separator" (dict)}}
    {{template "breadcrumb_item" (dict "Body" (htmlSafe "<span data-slot=breadcrumb-page aria-current=page>Breadcrumb</span>"))}}
  \`))}}`))}}
<.breadcrumb aria-label="Breadcrumb">
  <.breadcrumb_list>
    <.breadcrumb_item><.breadcrumb_link href={~p"/"}>Home</.breadcrumb_link></.breadcrumb_item>
    <.breadcrumb_separator />
    <.breadcrumb_item><.breadcrumb_link href={~p"/components"}>Components</.breadcrumb_link></.breadcrumb_item>
    <.breadcrumb_separator />
    <.breadcrumb_item><.breadcrumb_page>Breadcrumb</.breadcrumb_page></.breadcrumb_item>
  </.breadcrumb_list>
</.breadcrumb>
<div class="p-4">
  <nav data-slot="breadcrumb" aria-label="Breadcrumb" class="">
    <ol data-slot="breadcrumb-list" class="flex flex-wrap items-center gap-1.5 text-sm break-words text-muted-foreground sm:gap-2.5">
      <li data-slot="breadcrumb-item" class="inline-flex items-center gap-1.5">
        <a href="#" data-slot="breadcrumb-link" class="transition-colors hover:text-foreground focus-visible:rounded-sm focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none">Home</a>
      </li>
      <li data-slot="breadcrumb-separator" aria-hidden="true" class="[&amp;&gt;svg]:size-3.5">
        <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="9 18 15 12 9 6">
          </polyline>
        </svg>
      </li>
      <li data-slot="breadcrumb-item" class="inline-flex items-center gap-1.5">
        <a href="#" data-slot="breadcrumb-link" class="transition-colors hover:text-foreground focus-visible:rounded-sm focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none">Components</a>
      </li>
      <li data-slot="breadcrumb-separator" aria-hidden="true" class="[&amp;&gt;svg]:size-3.5">
        <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="9 18 15 12 9 6">
          </polyline>
        </svg>
      </li>
      <li data-slot="breadcrumb-item" class="inline-flex items-center gap-1.5">
        <span data-slot="breadcrumb-page" aria-current="page" class="font-normal text-foreground">Breadcrumb</span>
      </li>
    </ol>
  </nav>
</div>

Custom separator

Pass children to <BreadcrumbSeparator> to override the default chevron — e.g. a slash.

The separator is a decorative <li aria-hidden="true">, so changing the glyph never affects what AT announces — the links alone carry the meaning.

<BreadcrumbSeparator>/</BreadcrumbSeparator>
{{ breadcrumb_separator("/") }}
{{template "breadcrumb_separator" (dict "Body" (htmlSafe "/"))}}
<.breadcrumb_separator>/</.breadcrumb_separator>
<div class="p-4">
  <nav data-slot="breadcrumb" aria-label="Breadcrumb" class="">
    <ol data-slot="breadcrumb-list" class="flex flex-wrap items-center gap-1.5 text-sm break-words text-muted-foreground sm:gap-2.5">
      <li data-slot="breadcrumb-item" class="inline-flex items-center gap-1.5">
        <a href="#" data-slot="breadcrumb-link" class="transition-colors hover:text-foreground focus-visible:rounded-sm focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none">Docs</a>
      </li>
      <li data-slot="breadcrumb-separator" aria-hidden="true" class="[&amp;&gt;svg]:size-3.5">/</li>
      <li data-slot="breadcrumb-item" class="inline-flex items-center gap-1.5">
        <a href="#" data-slot="breadcrumb-link" class="transition-colors hover:text-foreground focus-visible:rounded-sm focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none">Guides</a>
      </li>
      <li data-slot="breadcrumb-separator" aria-hidden="true" class="[&amp;&gt;svg]:size-3.5">/</li>
      <li data-slot="breadcrumb-item" class="inline-flex items-center gap-1.5">
        <span data-slot="breadcrumb-page" aria-current="page" class="font-normal text-foreground">Routing</span>
      </li>
    </ol>
  </nav>
</div>

Further reading

Collapsed — ellipsis for long trails

Use <BreadcrumbEllipsis> to collapse middle items. The glyph is aria-hidden but ships an sr-only "More" label.

When a trail is too deep, collapse the middle. The ellipsis is decorative but includes a visually-hidden More so AT users still hear that items were omitted.

<BreadcrumbItem><BreadcrumbEllipsis /></BreadcrumbItem>
{{ breadcrumb_item(breadcrumb_ellipsis()) }}
{{template "breadcrumb_item" (dict "Body" (htmlSafe (printf "%s" "<span ...ellipsis...>")))}}
<.breadcrumb_item><.breadcrumb_ellipsis /></.breadcrumb_item>
<div class="p-4">
  <nav data-slot="breadcrumb" aria-label="Breadcrumb" class="">
    <ol data-slot="breadcrumb-list" class="flex flex-wrap items-center gap-1.5 text-sm break-words text-muted-foreground sm:gap-2.5">
      <li data-slot="breadcrumb-item" class="inline-flex items-center gap-1.5">
        <a href="#" data-slot="breadcrumb-link" class="transition-colors hover:text-foreground focus-visible:rounded-sm focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none">Home</a>
      </li>
      <li data-slot="breadcrumb-separator" aria-hidden="true" class="[&amp;&gt;svg]:size-3.5">
        <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="9 18 15 12 9 6">
          </polyline>
        </svg>
      </li>
      <li data-slot="breadcrumb-item" class="inline-flex items-center gap-1.5">
        <span data-slot="breadcrumb-ellipsis" aria-hidden="true" class="flex size-9 items-center justify-center">
          <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" class="size-4" aria-hidden="true">
            <circle cx="12" cy="12" r="1">
            </circle>
            <circle cx="19" cy="12" r="1">
            </circle>
            <circle cx="5" cy="12" r="1">
            </circle>
          </svg>
          <span class="sr-only">More</span>
        </span>
      </li>
      <li data-slot="breadcrumb-separator" aria-hidden="true" class="[&amp;&gt;svg]:size-3.5">
        <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="9 18 15 12 9 6">
          </polyline>
        </svg>
      </li>
      <li data-slot="breadcrumb-item" class="inline-flex items-center gap-1.5">
        <a href="#" data-slot="breadcrumb-link" class="transition-colors hover:text-foreground focus-visible:rounded-sm focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none">Components</a>
      </li>
      <li data-slot="breadcrumb-separator" aria-hidden="true" class="[&amp;&gt;svg]:size-3.5">
        <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="9 18 15 12 9 6">
          </polyline>
        </svg>
      </li>
      <li data-slot="breadcrumb-item" class="inline-flex items-center gap-1.5">
        <span data-slot="breadcrumb-page" aria-current="page" class="font-normal text-foreground">Breadcrumb</span>
      </li>
    </ol>
  </nav>
</div>

Further reading

API Reference

<Breadcrumb>

PropTypeDefaultDescription
ariaLabelledbystring
Id of a visible heading that names the <nav> landmark (Breadcrumb root). When set, aria-label is not emitted so the landmark has a single name source. Use aria-label OR aria-labelledby, not both.APGBreadcrumb pattern
data-*string
Global data-* attribute, forwarded onto the rendered element. Now also accepted on BreadcrumbSeparator and BreadcrumbEllipsis (e.g. a CSS/JS hook), matching the other subcomponents.MDNdata-* global attribute
ariaLabelstring"Breadcrumb"
Accessible name for the <nav> landmark (Breadcrumb root).APGBreadcrumb pattern
hrefstring
Destination for a BreadcrumbLink. Rendered as a real <a href>; omit on the current page (use BreadcrumbPage instead).MDN<a> element
childrenChild
Trail content. BreadcrumbSeparator accepts children to override the default chevron glyph.
hx-*any
Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference
classstring
Extra Tailwind classes appended to the root element.