Components
Figure
Self-contained, referenced content — an image, diagram, code block, or quotation — wrapped in a native <figure> whose <figcaption> supplies its accessible name. Distinct from a Card's generic surface: a figure can be moved out of the main flow without breaking it. Zero JavaScript.
Installation
1. Install via the shadcn CLI
npx shadcn@latest add http://localhost/r/figure.json2. Use it
import {
Figure,
FigureCaption,
FigureContent,
FigureCredit,
} from "@/components/ui/figure"
// Image with a caption below
<Figure>
<img src="/elephant.jpg" alt="An elephant at sunset" class="w-full rounded-md" />
<FigureCaption>An elephant at sunset</FigureCaption>
</Figure>
// Code block with a legend on top
<Figure captionSide="top">
<FigureCaption>Detect the browser via navigator</FigureCaption>
<FigureContent>
<pre class="overflow-x-auto p-4"><code>navigator.userAgent</code></pre>
</FigureContent>
</Figure>Or copy the source manually
/** @jsxImportSource hono/jsx */
import type { Child, PropsWithChildren } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"
// Figure — shadcn-htmx, htmx v4 + Tailwind v4.
//
// Self-contained captioned content — an image, diagram, code block, or
// quotation — wrapped in a native <figure> whose <figcaption> supplies the
// figure's accessible name. Unlike <Card> (a generic styled surface),
// <Figure> is *referential*: it stands apart from the main flow and can be
// moved elsewhere without breaking it, and the caption is semantically tied
// to the content. No interactivity, no JavaScript.
//
// Built on (native elements; we add nothing the platform doesn't ship):
// - <figure> — represents self-contained content, optionally captioned;
// the figure, its caption, and its contents are one unit. Implicit ARIA
// role "figure".
// repos/mdn/files/en-us/web/html/reference/elements/figure/index.md
// - <figcaption> — caption/legend for the parent <figure>; provides the
// <figure> its accessible name. Must be the figure's first or last child
// (the first <figcaption> found is presented as the caption).
// repos/mdn/files/en-us/web/html/reference/elements/figcaption/index.md
//
// Anatomy mirrors shadcn's Card family (read for intent only, not copied —
// different element, different semantics):
// repos/shadcn-ui/apps/v4/registry/new-york-v4/ui/card.tsx
//
// Theme tokens only: border / bg-card / text-card-foreground for the surface,
// text-muted-foreground for the caption, bg-muted for plain content slots.
// Where the caption sits relative to the content. Both are valid per the
// spec (first OR last child); we render the <figcaption> in the chosen DOM
// position so the accessible name is correct either way.
export type FigureCaptionSide = "top" | "bottom"
type FigureProps = PropsWithChildren<{
class?: ClassValue
id?: string
// Caption position. "bottom" (default) reads as a credit/label under the
// content; "top" reads as a legend introducing it (common for code blocks
// and quotations). See the MDN figure examples.
captionSide?: FigureCaptionSide
// Forward hx-*, data-*, aria-* and standard global attributes onto the
// <figure> root.
[key: string]: unknown
}>
// Surface: a bordered card-like box. We keep it visually distinct from the
// document body but lighter than a full Card (no shadow) so it reads as
// "referenced content", not a UI panel. Caption position is purely a matter
// of DOM order (figcaption first vs last) — both are spec-valid — so it needs
// no extra class; the column flow handles either. `data-caption-side` records
// the author's intent for styling hooks / tests.
const root =
"flex flex-col gap-3 overflow-hidden rounded-lg border bg-card p-3 text-card-foreground"
export function Figure(props: FigureProps) {
const {
class: className,
id,
captionSide = "bottom",
children,
...rest
} = props
return (
<figure
id={id}
data-slot="figure"
data-caption-side={captionSide}
class={cn(root, className)}
{...rest}
>
{children}
</figure>
)
}
// The content slot — image, code block, quote, diagram. Optional thin wrapper
// that gives non-replaced content (code, quotes) a muted backdrop and rounds
// it to match the surface. For a bare <img> you can skip this and put the
// image directly in <Figure>.
export function FigureContent(
props: PropsWithChildren<{ class?: ClassValue }>,
) {
return (
<div
data-slot="figure-content"
class={cn(
"overflow-hidden rounded-md bg-muted text-sm text-foreground",
props.class,
)}
>
{props.children}
</div>
)
}
// The caption — renders a native <figcaption>, which gives the parent
// <figure> its accessible name. Keep it concise; it is the figure's label,
// not its body. Use <FigureCredit> inside for a secondary line (source,
// author) styled lighter.
export function FigureCaption(
props: PropsWithChildren<{ class?: ClassValue; id?: string }>,
) {
return (
<figcaption
id={props.id}
data-slot="figure-caption"
class={cn(
"px-1 text-sm leading-snug text-muted-foreground",
props.class,
)}
>
{props.children}
</figcaption>
)
}
// Optional secondary line inside a caption (attribution / source / date),
// rendered a touch smaller. Purely presentational; stays inside <figcaption>
// so it remains part of the accessible name.
export function FigureCredit(
props: PropsWithChildren<{ class?: ClassValue }>,
) {
return (
<span
data-slot="figure-credit"
class={cn("mt-1 block text-xs text-muted-foreground/80", props.class)}
>
{props.children}
</span>
)
}
export type { FigureProps }
export type FigureChild = Child
1. Save the file
Copy figure.html into templates/components/.
2. Use it
{% from "components/figure.html" import figure, figure_content, figure_caption, figure_credit %}
{% call figure() %}
<img src="/elephant.jpg" alt="An elephant at sunset" class="w-full rounded-md">
{% call figure_caption() %}An elephant at sunset{% endcall %}
{% endcall %}View source
{# Figure macros — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/figure.tsx.
Self-contained captioned content (image, diagram, code, quote) in a native
<figure>; the <figcaption> supplies the figure's accessible name. Zero JS.
MDN figure: repos/mdn/files/en-us/web/html/reference/elements/figure/index.md
MDN figcaption: repos/mdn/files/en-us/web/html/reference/elements/figcaption/index.md
The <figcaption> must be the figure's first or last child; choose
caption_side accordingly and put the figure_caption(...) call in that DOM
position yourself (this mirrors how the .tsx renders children in order).
Usage:
{% from "components/figure.html" import figure, figure_content,
figure_caption, figure_credit %}
{% call figure(caption_side="bottom") %}
<img src="/elephant.jpg" alt="An elephant at sunset" class="w-full rounded-md">
{% call figure_caption() %}An elephant at sunset{% endcall %}
{% endcall %} #}
{% macro figure(caption_side="bottom", id=none, extra_class="", **attrs) -%}
<figure
{%- if id %} id="{{ id }}"{% endif %}
data-slot="figure"
data-caption-side="{{ caption_side }}"
class="flex flex-col gap-3 overflow-hidden rounded-lg border bg-card p-3 text-card-foreground {{ extra_class }}"
{%- for k, v in attrs.items() %} {{ k|replace('_','-') }}="{{ v }}"{% endfor %}>{{ caller() }}</figure>
{%- endmacro %}
{% macro figure_content(extra_class="") -%}
<div data-slot="figure-content" class="overflow-hidden rounded-md bg-muted text-sm text-foreground {{ extra_class }}">{{ caller() }}</div>
{%- endmacro %}
{% macro figure_caption(id=none, extra_class="") -%}
<figcaption {% if id %}id="{{ id }}" {% endif %}data-slot="figure-caption" class="px-1 text-sm leading-snug text-muted-foreground {{ extra_class }}">{{ caller() }}</figcaption>
{%- endmacro %}
{% macro figure_credit(extra_class="") -%}
<span data-slot="figure-credit" class="mt-1 block text-xs text-muted-foreground/80 {{ extra_class }}">{{ caller() }}</span>
{%- endmacro %}
1. Save the file
Add figure.tmpl alongside your templates.
2. Use it
{{template "figure" (dict
"Body" "<img src=\"/elephant.jpg\" alt=\"An elephant at sunset\" class=\"w-full rounded-md\">"
"Caption" "An elephant at sunset")}}View source
{{/*
Figure template — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/figure.tsx.
Self-contained captioned content (image, diagram, code, quote) in a native
<figure>; the <figcaption> supplies the figure's accessible name. Zero JS.
MDN figure: repos/mdn/files/en-us/web/html/reference/elements/figure/index.md
MDN figcaption: repos/mdn/files/en-us/web/html/reference/elements/figcaption/index.md
The <figcaption> must be the figure's first or last child. The "figure"
template renders Caption first when CaptionSide == "top", otherwise last,
matching the .tsx DOM order. Compose the inner content/caption yourself via
the helper templates if you need finer control.
type FigureArgs struct {
CaptionSide string // "bottom" (default) | "top"
ID string
Class string
Caption template.HTML // <figcaption> inner HTML
Body template.HTML // content (img / code block / quote …)
}
*/}}
{{define "figure-caption"}}
{{- $id := .ID -}}
<figcaption {{if $id}}id="{{$id}}" {{end}}data-slot="figure-caption" class="px-1 text-sm leading-snug text-muted-foreground {{.Class}}">{{htmlSafe .Body}}</figcaption>
{{end}}
{{define "figure-content"}}
<div data-slot="figure-content" class="overflow-hidden rounded-md bg-muted text-sm text-foreground {{.Class}}">{{htmlSafe .Body}}</div>
{{end}}
{{define "figure-credit"}}
<span data-slot="figure-credit" class="mt-1 block text-xs text-muted-foreground/80 {{.Class}}">{{htmlSafe .Body}}</span>
{{end}}
{{define "figure"}}
{{- $side := or .CaptionSide "bottom" -}}
<figure {{if .ID}}id="{{.ID}}" {{end}}data-slot="figure" data-caption-side="{{$side}}" class="flex flex-col gap-3 overflow-hidden rounded-lg border bg-card p-3 text-card-foreground {{.Class}}">
{{- if eq $side "top" -}}
{{- if .Caption}}<figcaption data-slot="figure-caption" class="px-1 text-sm leading-snug text-muted-foreground">{{htmlSafe .Caption}}</figcaption>{{end -}}
{{- htmlSafe .Body -}}
{{- else -}}
{{- htmlSafe .Body -}}
{{- if .Caption}}<figcaption data-slot="figure-caption" class="px-1 text-sm leading-snug text-muted-foreground">{{htmlSafe .Caption}}</figcaption>{{end -}}
{{- end -}}
</figure>
{{end}}
1. Save the file
Drop figure.ex into lib/my_app_web/components/.
2. Use it
<.figure>
<img src="/elephant.jpg" alt="An elephant at sunset" class="w-full rounded-md" />
<.figure_caption>An elephant at sunset</.figure_caption>
</.figure>View source
defmodule ShadcnHtmx.Components.Figure do
@moduledoc """
Figure — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.
Mirrors registry/ui/figure.tsx. Self-contained captioned content (image,
diagram, code block, quotation) in a native `<figure>`; the `<figcaption>`
supplies the figure's accessible name. No JavaScript.
MDN figure: repos/mdn/files/en-us/web/html/reference/elements/figure/index.md
MDN figcaption: repos/mdn/files/en-us/web/html/reference/elements/figcaption/index.md
The `<figcaption>` must be the figure's first or last child. Pass
`caption_side="top"` to render the caption above the content (legend),
otherwise it renders below (credit/label).
## Examples
<.figure>
<img src="/elephant.jpg" alt="An elephant at sunset" class="w-full rounded-md" />
<.figure_caption>An elephant at sunset</.figure_caption>
</.figure>
<.figure caption_side="top">
<.figure_caption>
Get browser details <.figure_credit>via navigator</.figure_credit>
</.figure_caption>
<.figure_content>
<pre class="overflow-x-auto p-4"><code>navigator.userAgent</code></pre>
</.figure_content>
</.figure>
"""
use Phoenix.Component
attr :caption_side, :string, default: "bottom", values: ~w(top bottom)
attr :class, :string, default: nil
attr :rest, :global
slot :inner_block, required: true
def figure(assigns) do
~H"""
<figure
data-slot="figure"
data-caption-side={@caption_side}
class={[
"flex flex-col gap-3 overflow-hidden rounded-lg border bg-card p-3 text-card-foreground",
@class
]}
{@rest}
>
{render_slot(@inner_block)}
</figure>
"""
end
attr :class, :string, default: nil
slot :inner_block, required: true
def figure_content(assigns) do
~H"""
<div
data-slot="figure-content"
class={["overflow-hidden rounded-md bg-muted text-sm text-foreground", @class]}
>
{render_slot(@inner_block)}
</div>
"""
end
attr :id, :string, default: nil
attr :class, :string, default: nil
slot :inner_block, required: true
def figure_caption(assigns) do
~H"""
<figcaption
id={@id}
data-slot="figure-caption"
class={["px-1 text-sm leading-snug text-muted-foreground", @class]}
>
{render_slot(@inner_block)}
</figcaption>
"""
end
attr :class, :string, default: nil
slot :inner_block, required: true
def figure_credit(assigns) do
~H"""
<span
data-slot="figure-credit"
class={["mt-1 block text-xs text-muted-foreground/80", @class]}
>
{render_slot(@inner_block)}
</span>
"""
end
end
1. Save the file
Paste the markup; relies only on theme tokens.
2. Use it
<figure data-slot="figure" data-caption-side="bottom"
class="flex flex-col gap-3 overflow-hidden rounded-lg border bg-card p-3 text-card-foreground">
<img src="/elephant.jpg" alt="An elephant at sunset" class="w-full rounded-md">
<figcaption data-slot="figure-caption"
class="px-1 text-sm leading-snug text-muted-foreground">
An elephant at sunset
</figcaption>
</figure>View source
<!--
shadcn-htmx — raw HTML figure snippets.
Mirrors registry/ui/figure.tsx.
Self-contained captioned content (image, diagram, code, quote) in a native
<figure>; the <figcaption> gives the <figure> its accessible name. The
caption must be the figure's first or last child. Zero JavaScript —
Tailwind theme tokens only.
MDN figure: repos/mdn/files/en-us/web/html/reference/elements/figure/index.md
MDN figcaption: repos/mdn/files/en-us/web/html/reference/elements/figcaption/index.md
ROOT:
flex flex-col gap-3 overflow-hidden rounded-lg border bg-card p-3 text-card-foreground
data-caption-side="top" | "bottom" records where the figcaption sits.
-->
<!-- Image with a caption below (the common case) -->
<figure data-slot="figure" data-caption-side="bottom"
class="flex flex-col gap-3 overflow-hidden rounded-lg border bg-card p-3 text-card-foreground">
<img src="/elephant.jpg" alt="An elephant at sunset"
class="w-full rounded-md">
<figcaption data-slot="figure-caption"
class="px-1 text-sm leading-snug text-muted-foreground">
An elephant at sunset
<span data-slot="figure-credit" class="mt-1 block text-xs text-muted-foreground/80">
Photo: J. Doe / CC BY 4.0
</span>
</figcaption>
</figure>
<!-- Code block with a legend on top -->
<figure data-slot="figure" data-caption-side="top"
class="flex flex-col gap-3 overflow-hidden rounded-lg border bg-card p-3 text-card-foreground">
<figcaption data-slot="figure-caption"
class="px-1 text-sm leading-snug text-muted-foreground">
Get browser details using <code>navigator</code>.
</figcaption>
<div data-slot="figure-content"
class="overflow-hidden rounded-md bg-muted text-sm text-foreground">
<pre class="overflow-x-auto p-4"><code>console.log(navigator.userAgent);</code></pre>
</div>
</figure>
<!-- Quotation: blockquote content, attribution in the caption -->
<figure data-slot="figure" data-caption-side="bottom"
class="flex flex-col gap-3 overflow-hidden rounded-lg border bg-card p-3 text-card-foreground">
<div data-slot="figure-content"
class="overflow-hidden rounded-md bg-muted text-sm text-foreground">
<blockquote class="border-l-2 border-border px-4 py-3 italic">
If debugging is the process of removing software bugs, then programming
must be the process of putting them in.
</blockquote>
</div>
<figcaption data-slot="figure-caption"
class="px-1 text-sm leading-snug text-muted-foreground">
— Edsger W. Dijkstra
</figcaption>
</figure>
Examples
Captioned image
An <img> followed by a <figcaption>. The caption becomes the figure's accessible name.
The <figcaption> is the figure's caption and its accessible name: a screen reader announces "An elephant at sunset, figure". The <img> still needs its own alt; the two serve different jobs. A nested FigureCredit stays inside the caption so attribution is part of that name.
<Figure>
<img src="/elephant.jpg" alt="An elephant at sunset" class="w-full rounded-md" />
<FigureCaption>
An elephant at sunset
<FigureCredit>Photo: J. Doe / CC BY 4.0</FigureCredit>
</FigureCaption>
</Figure>{% call figure() %}
<img src="/elephant.jpg" alt="An elephant at sunset" class="w-full rounded-md">
{% call figure_caption() %}
An elephant at sunset
{% call figure_credit() %}Photo: J. Doe / CC BY 4.0{% endcall %}
{% endcall %}
{% endcall %}{{template "figure" (dict
"Body" "<img src=\"/elephant.jpg\" alt=\"An elephant at sunset\" class=\"w-full rounded-md\">"
"Caption" "An elephant at sunset<span data-slot=\"figure-credit\" class=\"mt-1 block text-xs text-muted-foreground/80\">Photo: J. Doe / CC BY 4.0</span>")}}<.figure>
<img src="/elephant.jpg" alt="An elephant at sunset" class="w-full rounded-md" />
<.figure_caption>
An elephant at sunset
<.figure_credit>Photo: J. Doe / CC BY 4.0</.figure_credit>
</.figure_caption>
</.figure><div class="w-full max-w-sm">
<figure data-slot="figure" data-caption-side="bottom" class="flex flex-col gap-3 overflow-hidden rounded-lg border bg-card p-3 text-card-foreground">
<img src="data:image/svg+xml;utf8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width%3D'640'%20height%3D'360'%3E%3Cdefs%3E%3ClinearGradient%20id%3D'g'%20x1%3D'0'%20y1%3D'0'%20x2%3D'1'%20y2%3D'1'%3E%3Cstop%20offset%3D'0'%20stop-color%3D'%252306b6d4'%2F%3E%3Cstop%20offset%3D'1'%20stop-color%3D'%25237c3aed'%2F%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Crect%20width%3D'640'%20height%3D'360'%20fill%3D'url(%2523g)'%2F%3E%3Ctext%20x%3D'50%25'%20y%3D'50%25'%20fill%3D'white'%20font-family%3D'sans-serif'%20font-size%3D'28'%20text-anchor%3D'middle'%20dominant-baseline%3D'middle'%3E640%20%C3%97%20360%3C%2Ftext%3E%3C%2Fsvg%3E" alt="Gradient placeholder, 640 by 360" class="w-full rounded-md"/>
<figcaption data-slot="figure-caption" class="px-1 text-sm leading-snug text-muted-foreground">
An elephant at sunset
<span data-slot="figure-credit" class="mt-1 block text-xs text-muted-foreground/80">Photo: J. Doe / CC BY 4.0</span>
</figcaption>
</figure>
</div>Further reading
Code block with a legend on top
captionSide="top" renders the <figcaption> first, as a legend introducing the snippet.
The spec allows the <figcaption> to be the figure's first or last child — the first one found is the caption. Set captionSide="top" and place the caption before the content for a legend. FigureContent gives non-replaced content a muted backdrop.
navigator.console.log(navigator.userAgent);<Figure captionSide="top">
<FigureCaption>
Read the user-agent string via <code>navigator</code>.
</FigureCaption>
<FigureContent>
<pre class="overflow-x-auto p-4"><code>console.log(navigator.userAgent);</code></pre>
</FigureContent>
</Figure>{% call figure(caption_side="top") %}
{% call figure_caption() %}Read the user-agent string via <code>navigator</code>.{% endcall %}
{% call figure_content() %}
<pre class="overflow-x-auto p-4"><code>console.log(navigator.userAgent);</code></pre>
{% endcall %}
{% endcall %}{{template "figure" (dict
"CaptionSide" "top"
"Caption" "Read the user-agent string via <code>navigator</code>."
"Body" "<div data-slot=\"figure-content\" class=\"overflow-hidden rounded-md bg-muted text-sm text-foreground\"><pre class=\"overflow-x-auto p-4\"><code>console.log(navigator.userAgent);</code></pre></div>")}}<.figure caption_side="top">
<.figure_caption>
Read the user-agent string via <code>navigator</code>.
</.figure_caption>
<.figure_content>
<pre class="overflow-x-auto p-4"><code>console.log(navigator.userAgent);</code></pre>
</.figure_content>
</.figure><div class="w-full max-w-md">
<figure data-slot="figure" data-caption-side="top" class="flex flex-col gap-3 overflow-hidden rounded-lg border bg-card p-3 text-card-foreground">
<figcaption data-slot="figure-caption" class="px-1 text-sm leading-snug text-muted-foreground">
Read the user-agent string via
<code>navigator</code>
.
</figcaption>
<div data-slot="figure-content" class="overflow-hidden rounded-md bg-muted text-sm text-foreground">
<pre class="overflow-x-auto p-4">
<code>console.log(navigator.userAgent);</code>
</pre>
</div>
</figure>
</div>Further reading
Quotation with attribution
A <blockquote> inside FigureContent; the <figcaption> carries the attribution.
Per the MDN quotation example, the quoted text is the content and the source is the caption. Keep the attribution in the <figcaption> so it remains the figure's accessible name.
If debugging is the process of removing software bugs, then programming must be the process of putting them in.
<Figure>
<FigureContent>
<blockquote class="border-l-2 border-border px-4 py-3 italic">
If debugging is the process of removing software bugs, then
programming must be the process of putting them in.
</blockquote>
</FigureContent>
<FigureCaption>— Edsger W. Dijkstra</FigureCaption>
</Figure>{% call figure() %}
{% call figure_content() %}
<blockquote class="border-l-2 border-border px-4 py-3 italic">…</blockquote>
{% endcall %}
{% call figure_caption() %}— Edsger W. Dijkstra{% endcall %}
{% endcall %}{{template "figure" (dict
"Body" "<div data-slot=\"figure-content\" class=\"overflow-hidden rounded-md bg-muted text-sm text-foreground\"><blockquote class=\"border-l-2 border-border px-4 py-3 italic\">…</blockquote></div>"
"Caption" "— Edsger W. Dijkstra")}}<.figure>
<.figure_content>
<blockquote class="border-l-2 border-border px-4 py-3 italic">…</blockquote>
</.figure_content>
<.figure_caption>— Edsger W. Dijkstra</.figure_caption>
</.figure><div class="w-full max-w-md">
<figure data-slot="figure" data-caption-side="bottom" class="flex flex-col gap-3 overflow-hidden rounded-lg border bg-card p-3 text-card-foreground">
<div data-slot="figure-content" class="overflow-hidden rounded-md bg-muted text-sm text-foreground">
<blockquote class="border-l-2 border-border px-4 py-3 italic">
If debugging is the process of removing software bugs, then programming must be the process of putting them in.
</blockquote>
</div>
<figcaption data-slot="figure-caption" class="px-1 text-sm leading-snug text-muted-foreground">— Edsger W. Dijkstra</figcaption>
</figure>
</div>Further reading
API Reference
<Figure>
| Prop | Type | Default | Description |
|---|---|---|---|
captionSide | "bottom"|"top" | "bottom" | Where the <figcaption> sits relative to the content. "bottom" reads as a credit/label below; "top" reads as a legend introducing the content (common for code blocks). The spec allows the caption as the figure's first or last child, so place the caption in that DOM order.MDN<figure> |
id | string | — | Forwarded onto the <figure> root. |
class | string | — | Extra Tailwind classes appended to the root element. |
hx-* | any | — | Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference |