Components
Card
A rounded container with Header / Title / Description / Action / Content / Footer slots. Pure structure — no JS, no interactivity. Pair with htmx attributes on the surrounding element when you need to refresh the contents server-side.
Installation
1. Install via the shadcn CLI
npx shadcn@latest add http://localhost/r/card.json2. Use it
import { Card, CardHeader, CardTitle, CardDescription,
CardContent, CardFooter } from "@/components/ui/card"
<Card>
<CardHeader>
<CardTitle>Account</CardTitle>
<CardDescription>Update your settings here.</CardDescription>
</CardHeader>
<CardContent>
<p>Body content…</p>
</CardContent>
<CardFooter>
<Button>Save</Button>
</CardFooter>
</Card>Or copy the source manually
/** @jsxImportSource hono/jsx */
import type { PropsWithChildren } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"
// Card — shadcn-htmx, htmx v4 + Tailwind v4.
//
// Source of truth (1:1 class strings):
// repos/shadcn-ui/apps/v4/registry/new-york-v4/ui/card.tsx
//
// Card is structural — a rounded container with a Header / Title /
// Description / Content / Footer layout. No interactivity, no JS.
// Pair it with htmx attributes on inner elements when you need to swap
// the contents server-side.
type CardAs = "div" | "article" | "section" | "li" | "aside"
export function Card(
props: PropsWithChildren<
{
class?: ClassValue
id?: string
// Semantic element. shadcn upstream hardcodes <div>; we default to
// <div> for backwards-compat but encourage <article> for self-
// contained content (product card, blog tile, comment) and
// <section> for thematic groups inside a landmark.
// See repos/mdn/files/en-us/web/html/reference/elements/article/index.md:10,65-68
as?: CardAs
// Pair with the id of the CardTitle inside so the rendered <article>
// / <section> has an accessible name for AT landmark navigation.
ariaLabelledby?: string
ariaLabel?: string
} & Record<string, any>
>,
) {
const { class: className, children, as = "div", ariaLabelledby, ariaLabel, ...rest } = props
const Tag: any = as
return (
<Tag
data-slot="card"
class={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className,
)}
aria-labelledby={ariaLabelledby}
aria-label={ariaLabel}
{...rest}
>
{children}
</Tag>
)
}
export function CardHeader(
props: PropsWithChildren<{ class?: ClassValue }>,
) {
return (
<div
data-slot="card-header"
class={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
props.class,
)}
>
{props.children}
</div>
)
}
type CardTitleAs = "div" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6"
export function CardTitle(
props: PropsWithChildren<{
class?: ClassValue
// Render as a real heading so an `as="article"|"section"` Card gets a
// child heading and contributes to the document outline. Default "div"
// preserves shadcn upstream behaviour.
// See repos/mdn/files/en-us/web/html/reference/elements/article/index.md:66
// repos/mdn/files/en-us/web/html/reference/elements/section/index.md:55
as?: CardTitleAs
// Lets Card's aria-labelledby reference this visible title, giving a
// section/article card its accessible name (region landmark naming).
// See repos/mdn/files/en-us/web/accessibility/aria/reference/roles/region_role/index.md:25,29
id?: string
}>,
) {
const { class: className, children, as = "div", id } = props
const Tag: any = as
return (
<Tag data-slot="card-title" id={id} class={cn("leading-none font-semibold", className)}>
{children}
</Tag>
)
}
export function CardDescription(
props: PropsWithChildren<{ class?: ClassValue }>,
) {
return (
<div
data-slot="card-description"
class={cn("text-sm text-muted-foreground", props.class)}
>
{props.children}
</div>
)
}
// Sits in the top-right of CardHeader; the header grid auto-detects it and
// switches to two columns via `has-data-[slot=card-action]:grid-cols-[1fr_auto]`.
export function CardAction(
props: PropsWithChildren<{ class?: ClassValue }>,
) {
return (
<div
data-slot="card-action"
class={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
props.class,
)}
>
{props.children}
</div>
)
}
export function CardContent(
props: PropsWithChildren<{ class?: ClassValue }>,
) {
return (
<div data-slot="card-content" class={cn("px-6", props.class)}>
{props.children}
</div>
)
}
export function CardFooter(
props: PropsWithChildren<{ class?: ClassValue }>,
) {
return (
<div
data-slot="card-footer"
class={cn("flex items-center px-6 [.border-t]:pt-6", props.class)}
>
{props.children}
</div>
)
}
1. Save the file
Copy card.html into templates/components/.
2. Use it
{% from "components/card.html" import card_open, card_close,
card_header_open, card_header_close, card_title, card_description,
card_content_open, card_content_close,
card_footer_open, card_footer_close %}
{{ card_open() }}
{{ card_header_open() }}
{{ card_title("Account") }}
{{ card_description("Update your settings here.") }}
{{ card_header_close() }}
{{ card_content_open() }}
<p>Body content…</p>
{{ card_content_close() }}
{{ card_footer_open() }}
<button class="…">Save</button>
{{ card_footer_close() }}
{{ card_close() }}View source
{# Card macros — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/card.tsx.
Usage:
{% from "components/card.html" import card_open, card_close,
card_header_open, card_header_close,
card_title, card_description,
card_content_open, card_content_close,
card_footer_open, card_footer_close %}
{{ card_open() }}
{{ card_header_open() }}
{{ card_title("Account") }}
{{ card_description("Update your account settings here.") }}
{{ card_header_close() }}
{{ card_content_open() }}
<p>Body content…</p>
{{ card_content_close() }}
{{ card_footer_open() }}
<button class="…">Save</button>
{{ card_footer_close() }}
{{ card_close() }} #}
{% macro card_open(extra_class="") -%}
<div data-slot="card" class="flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm {{ extra_class }}">
{%- endmacro %}
{% macro card_close() %}</div>{% endmacro %}
{% macro card_header_open(extra_class="") -%}
<div data-slot="card-header" class="@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6 {{ extra_class }}">
{%- endmacro %}
{% macro card_header_close() %}</div>{% endmacro %}
{# `as` renders a real heading (h1-h6) so an as="article"/"section" card gets a
child heading per the document-outline guidance; default "div" keeps shadcn
upstream behaviour. `id` lets card_open(aria_labelledby=...) name the card.
See repos/mdn/files/en-us/web/html/reference/elements/article/index.md:66
repos/mdn/files/en-us/web/accessibility/aria/reference/roles/region_role/index.md:25,29 #}
{% macro card_title(text, extra_class="", as="div", id="") -%}
<{{ as }} data-slot="card-title"{% if id %} id="{{ id }}"{% endif %} class="leading-none font-semibold {{ extra_class }}">{{ text }}</{{ as }}>
{%- endmacro %}
{% macro card_description(text, extra_class="") -%}
<div data-slot="card-description" class="text-sm text-muted-foreground {{ extra_class }}">{{ text }}</div>
{%- endmacro %}
{% macro card_action_open(extra_class="") -%}
<div data-slot="card-action" class="col-start-2 row-span-2 row-start-1 self-start justify-self-end {{ extra_class }}">
{%- endmacro %}
{% macro card_action_close() %}</div>{% endmacro %}
{% macro card_content_open(extra_class="") -%}
<div data-slot="card-content" class="px-6 {{ extra_class }}">
{%- endmacro %}
{% macro card_content_close() %}</div>{% endmacro %}
{% macro card_footer_open(extra_class="") -%}
<div data-slot="card-footer" class="flex items-center px-6 [.border-t]:pt-6 {{ extra_class }}">
{%- endmacro %}
{% macro card_footer_close() %}</div>{% endmacro %}
1. Save the file
Add card.tmpl alongside button.tmpl.
2. Use it
{{template "card" (dict
"Title" "Account"
"Description" "Update your settings here."
"Content" (htmlSafe `<p>Body content…</p>`)
"Footer" (htmlSafe `<button class="…">Save</button>`)
)}}View source
{{/*
Card templates — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/card.tsx.
Each named template wraps an HTML chunk. To compose, hand-assemble the
HTML in your handler (Go html/template has no caller block syntax):
type CardArgs struct {
Title string
Description string
Content template.HTML
Footer template.HTML
Action template.HTML // optional, sits in the top-right
// Optional: render CardTitle as a real heading (h1-h6) so an
// article/section card has a child heading for the document
// outline; empty => "div" (shadcn upstream default).
// See repos/mdn/.../elements/article/index.md:66 ; section/index.md:55
TitleAs string
// Optional: id on CardTitle so a section/article card can name
// itself via aria-labelledby (region landmark naming).
// See repos/mdn/.../roles/region_role/index.md:25,29
TitleID string
}
tpl.ExecuteTemplate(w, "card", CardArgs{
Title: "Account",
Description: "Update your settings here.",
Content: template.HTML(`<p>Body content…</p>`),
Footer: template.HTML(`<button>Save</button>`),
})
*/}}
{{define "card"}}
<div data-slot="card" class="flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm">
{{- if or .Title .Description .Action}}
<div data-slot="card-header" class="@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6">
{{- if .Title}}{{$t := or .TitleAs "div"}}<{{$t}} data-slot="card-title"{{if .TitleID}} id="{{.TitleID}}"{{end}} class="leading-none font-semibold">{{.Title}}</{{$t}}>{{end}}
{{- if .Description}}<div data-slot="card-description" class="text-sm text-muted-foreground">{{.Description}}</div>{{end}}
{{- if .Action}}<div data-slot="card-action" class="col-start-2 row-span-2 row-start-1 self-start justify-self-end">{{.Action}}</div>{{end}}
</div>
{{- end}}
{{- if .Content}}
<div data-slot="card-content" class="px-6">{{.Content}}</div>
{{- end}}
{{- if .Footer}}
<div data-slot="card-footer" class="flex items-center px-6 [.border-t]:pt-6">{{.Footer}}</div>
{{- end}}
</div>
{{end}}
1. Save the file
Drop card.ex into lib/my_app_web/components/.
2. Use it
<.card>
<.card_header>
<.card_title>Account</.card_title>
<.card_description>Update your settings here.</.card_description>
</.card_header>
<.card_content>
<p>Body content…</p>
</.card_content>
<.card_footer>
<button class="…">Save</button>
</.card_footer>
</.card>View source
defmodule ShadcnHtmx.Components.Card do
@moduledoc """
Card — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.
Mirrors registry/ui/card.tsx. Structural container with
Header / Title / Description / Action / Content / Footer slots.
## Examples
<.card>
<.card_header>
<.card_title>Account</.card_title>
<.card_description>Update your settings here.</.card_description>
</.card_header>
<.card_content>
<p>Body content…</p>
</.card_content>
<.card_footer>
<button>Save</button>
</.card_footer>
</.card>
"""
use Phoenix.Component
attr :class, :string, default: nil
attr :rest, :global
slot :inner_block, required: true
def card(assigns) do
~H"""
<div
data-slot="card"
class={["flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm", @class]}
{@rest}
>
{render_slot(@inner_block)}
</div>
"""
end
attr :class, :string, default: nil
slot :inner_block, required: true
def card_header(assigns) do
~H"""
<div
data-slot="card-header"
class={[
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
@class
]}
>
{render_slot(@inner_block)}
</div>
"""
end
attr :class, :string, default: nil
# Render as a real heading (h1-h6) so an as="article"/"section" card gets a
# child heading for the document outline; default "div" keeps shadcn upstream.
# See repos/mdn/files/en-us/web/html/reference/elements/article/index.md:66
# repos/mdn/files/en-us/web/html/reference/elements/section/index.md:55
attr :as, :string, default: "div", values: ~w(div h1 h2 h3 h4 h5 h6)
# id lets a section/article card reference this title via aria-labelledby
# (region landmark naming).
# See repos/mdn/files/en-us/web/accessibility/aria/reference/roles/region_role/index.md:25,29
attr :id, :string, default: nil
slot :inner_block, required: true
def card_title(assigns) do
~H"""
<.dynamic_tag tag_name={@as} data-slot="card-title" id={@id} class={["leading-none font-semibold", @class]}>
{render_slot(@inner_block)}
</.dynamic_tag>
"""
end
attr :class, :string, default: nil
slot :inner_block, required: true
def card_description(assigns) do
~H"""
<div data-slot="card-description" class={["text-sm text-muted-foreground", @class]}>
{render_slot(@inner_block)}
</div>
"""
end
attr :class, :string, default: nil
slot :inner_block, required: true
def card_action(assigns) do
~H"""
<div
data-slot="card-action"
class={["col-start-2 row-span-2 row-start-1 self-start justify-self-end", @class]}
>
{render_slot(@inner_block)}
</div>
"""
end
attr :class, :string, default: nil
slot :inner_block, required: true
def card_content(assigns) do
~H"""
<div data-slot="card-content" class={["px-6", @class]}>
{render_slot(@inner_block)}
</div>
"""
end
attr :class, :string, default: nil
slot :inner_block, required: true
def card_footer(assigns) do
~H"""
<div
data-slot="card-footer"
class={["flex items-center px-6 [.border-t]:pt-6", @class]}
>
{render_slot(@inner_block)}
</div>
"""
end
end
1. Save the file
Tailwind utilities only. No script.
2. Use it
<div data-slot="card" class="flex flex-col gap-6 rounded-xl border
bg-card py-6 text-card-foreground shadow-sm">
<div data-slot="card-header" class="grid gap-2 px-6">
<div data-slot="card-title" class="leading-none font-semibold">Account</div>
<div data-slot="card-description" class="text-sm text-muted-foreground">
Update your settings here.
</div>
</div>
<div data-slot="card-content" class="px-6">…</div>
<div data-slot="card-footer" class="flex items-center px-6">…</div>
</div>View source
<!--
shadcn-htmx — raw HTML card snippet.
Mirrors registry/ui/card.tsx. Just divs with the right Tailwind classes —
no JS, no special CSS.
CARD: flex flex-col gap-6 rounded-xl border bg-card py-6
text-card-foreground shadow-sm
CARD HEADER: @container/card-header grid auto-rows-min
grid-rows-[auto_auto] items-start gap-2 px-6
has-data-[slot=card-action]:grid-cols-[1fr_auto]
[.border-b]:pb-6
CARD TITLE: leading-none font-semibold
CARD DESCRIPTION: text-sm text-muted-foreground
CARD ACTION: col-start-2 row-span-2 row-start-1 self-start
justify-self-end
CARD CONTENT: px-6
CARD FOOTER: flex items-center px-6 [.border-t]:pt-6
-->
<!-- Basic -->
<div data-slot="card"
class="flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm">
<div data-slot="card-header"
class="@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6">
<div data-slot="card-title" class="leading-none font-semibold">Account</div>
<div data-slot="card-description" class="text-sm text-muted-foreground">
Update your account preferences and notification settings.
</div>
</div>
<div data-slot="card-content" class="px-6">
<p class="text-sm">Body content goes here.</p>
</div>
<div data-slot="card-footer" class="flex items-center px-6 [.border-t]:pt-6">
<button class="inline-flex h-9 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground hover:bg-primary/90">
Save
</button>
</div>
</div>
<!-- With CardAction (top-right header slot) -->
<div data-slot="card"
class="flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm">
<div data-slot="card-header"
class="@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6">
<div data-slot="card-title" class="leading-none font-semibold">Untitled draft</div>
<div data-slot="card-description" class="text-sm text-muted-foreground">
Last edited 12 minutes ago.
</div>
<div data-slot="card-action" class="col-start-2 row-span-2 row-start-1 self-start justify-self-end">
<button class="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground" aria-label="More">
<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>
</button>
</div>
</div>
<div data-slot="card-content" class="px-6">
<p class="text-sm text-muted-foreground">3 unread comments.</p>
</div>
</div>
<!-- As a landmark: <section> card named by its heading.
CardTitle is rendered as a real heading (h2) with an id, and the card
references it via aria-labelledby so the region landmark gets its name
from the visible title. A <section> with an accessible name is exposed
as role=region — prefer the semantic element over an explicit role.
See repos/mdn/.../elements/section/index.md:55
repos/mdn/.../roles/region_role/index.md:25,29 -->
<section data-slot="card" aria-labelledby="card-title-billing"
class="flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm">
<div data-slot="card-header"
class="@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6">
<h2 data-slot="card-title" id="card-title-billing" class="leading-none font-semibold">Billing</h2>
<div data-slot="card-description" class="text-sm text-muted-foreground">
Manage your subscription and payment method.
</div>
</div>
<div data-slot="card-content" class="px-6">
<p class="text-sm">Your plan renews on the 1st of each month.</p>
</div>
</section>
Examples
Basic — header + content + footer
The default Card layout: a stacked column of header (title + description), content body, and footer.
Cards are the workhorse layout primitive — every shadcn surface (settings panel, dashboard tile, login form, product row) is built on top of this primitive. Compose by picking which slots you need; CardHeader / CardContent / CardFooter are all optional siblings of Card.
Currently subscribed to weekly digests and security alerts.
<Card>
<CardHeader>
<CardTitle>Account</CardTitle>
<CardDescription>Update your account preferences.</CardDescription>
</CardHeader>
<CardContent>
<p>Body content…</p>
</CardContent>
<CardFooter>
<Button>Save changes</Button>
</CardFooter>
</Card>{{ card_open() }}
{{ card_header_open() }}
{{ card_title("Account") }}
{{ card_description("Update your account preferences.") }}
{{ card_header_close() }}
{{ card_content_open() }}…{{ card_content_close() }}
{{ card_footer_open() }}
{{ button("Save changes") }}
{{ card_footer_close() }}
{{ card_close() }}{{template "card" (dict
"Title" "Account"
"Description" "Update your account preferences."
"Content" (htmlSafe `…`)
"Footer" (htmlSafe `{{template "button" (dict "Label" "Save changes")}}`)
)}}<.card>
<.card_header>
<.card_title>Account</.card_title>
<.card_description>Update your account preferences.</.card_description>
</.card_header>
<.card_content>…</.card_content>
<.card_footer>
<.button>Save changes</.button>
</.card_footer>
</.card><div data-slot="card" class="flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm w-full max-w-md">
<div data-slot="card-header" class="@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6">
<div data-slot="card-title" class="leading-none font-semibold">Account</div>
<div data-slot="card-description" class="text-sm text-muted-foreground">Update your account preferences and notification settings.</div>
</div>
<div data-slot="card-content" class="px-6">
<p class="text-sm text-muted-foreground">Currently subscribed to weekly digests and security alerts.</p>
</div>
<div data-slot="card-footer" class="flex items-center px-6 [.border-t]:pt-6">
<button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70 bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 py-2 has-[>svg]:px-3" data-slot="button" data-variant="default" data-size="default">Save changes</button>
</div>
</div>Further reading
Header + action
Drop a CardAction inside the CardHeader and the header switches to a two-column grid: title/description on the left, action button on the right.
The CardHeader uses Tailwind's has-* variant — if a CardAction is present anywhere inside, the grid template automatically becomes two columns. No prop toggling required; the layout reacts to its children.
3 unread comments.
<Card>
<CardHeader>
<CardTitle>Untitled draft</CardTitle>
<CardDescription>Last edited 12 minutes ago.</CardDescription>
<CardAction>
<Button variant="ghost" size="icon-sm" ariaLabel="More">
<MoreIcon />
</Button>
</CardAction>
</CardHeader>
<CardContent>…</CardContent>
</Card>{{ card_open() }}
{{ card_header_open() }}
{{ card_title("Untitled draft") }}
{{ card_description("Last edited 12 minutes ago.") }}
{{ card_action_open() }}
{{ button("⋯", variant="ghost", size="icon-sm", aria_label="More") }}
{{ card_action_close() }}
{{ card_header_close() }}
{{ card_content_open() }}…{{ card_content_close() }}
{{ card_close() }}{{template "card" (dict
"Title" "Untitled draft" "Description" "Last edited 12 minutes ago."
"Action" (htmlSafe `<button class="…">⋯</button>`)
"Content" (htmlSafe `<p>3 unread comments.</p>`)
)}}<.card>
<.card_header>
<.card_title>Untitled draft</.card_title>
<.card_description>Last edited 12 minutes ago.</.card_description>
<.card_action>
<.button variant="ghost" size="icon-sm" aria-label="More">⋯</.button>
</.card_action>
</.card_header>
<.card_content>…</.card_content>
</.card><div data-slot="card" class="flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm w-full max-w-md">
<div data-slot="card-header" class="@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6">
<div data-slot="card-title" class="leading-none font-semibold">Untitled draft</div>
<div data-slot="card-description" class="text-sm text-muted-foreground">Last edited 12 minutes ago.</div>
<div data-slot="card-action" class="col-start-2 row-span-2 row-start-1 self-start justify-self-end">
<button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70 hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 size-8" aria-label="More" data-slot="button" data-variant="ghost" data-size="icon-sm">
<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">
<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>
</button>
</div>
</div>
<div data-slot="card-content" class="px-6">
<p class="text-sm text-muted-foreground">3 unread comments.</p>
</div>
</div>Further reading
Card-as-form
Wrap a real <form> inside the card. The Submit button in the footer triggers the form via the form attribute, so it stays semantically tied even though it lives outside the form element.
Login / signup screens almost always look like a card. The trick: <form id="…"> for the inputs, then a submit button in the footer with form="…" pointing back. The button can sit outside the form element and still submit it — the platform handles it.
<Card>
<CardHeader>
<CardTitle>Sign in</CardTitle>
<CardDescription>Enter your email to receive a magic link.</CardDescription>
</CardHeader>
<CardContent>
<form id="signin" class="grid gap-3">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" name="email" />
</form>
</CardContent>
<CardFooter class="flex justify-end">
<Button type="submit" form="signin">Send magic link</Button>
</CardFooter>
</Card>{{ card_open() }}
{{ card_header_open() }}
{{ card_title("Sign in") }}
{{ card_description("Enter your email to receive a magic link.") }}
{{ card_header_close() }}
{{ card_content_open() }}
<form id="signin" class="grid gap-3">
{{ label("Email", for_="email") }}
{{ input(id="email", type="email", name="email") }}
</form>
{{ card_content_close() }}
{{ card_footer_open(extra_class="flex justify-end") }}
{{ button("Send magic link", type="submit", form="signin") }}
{{ card_footer_close() }}
{{ card_close() }}{{template "card" (dict
"Title" "Sign in" "Description" "Enter your email to receive a magic link."
"Content" (htmlSafe `<form id="signin">…</form>`)
"Footer" (htmlSafe `{{template "button" (dict "Label" "Send" "Type" "submit" "Form" "signin")}}`)
)}}<.card>
<.card_header>
<.card_title>Sign in</.card_title>
<.card_description>Enter your email to receive a magic link.</.card_description>
</.card_header>
<.card_content>
<form id="signin">
<.label for="email">Email</.label>
<.input id="email" type="email" name="email" />
</form>
</.card_content>
<.card_footer class="flex justify-end">
<.button type="submit" form="signin">Send magic link</.button>
</.card_footer>
</.card><div data-slot="card" class="flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm w-full max-w-md">
<div data-slot="card-header" class="@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6">
<div data-slot="card-title" class="leading-none font-semibold">Sign in</div>
<div data-slot="card-description" class="text-sm text-muted-foreground">Enter your email below to receive a magic link.</div>
</div>
<div data-slot="card-content" class="px-6">
<form id="ex-card-form" class="grid gap-3">
<div class="grid gap-2">
<label for="ex-card-email" class="flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50" data-slot="label">Email</label>
<input type="email" class="flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30 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 [&.htmx-request]:opacity-70" data-slot="input" id="ex-card-email" name="email" placeholder="[email protected]"/>
</div>
</form>
</div>
<div data-slot="card-footer" class="flex items-center px-6 [.border-t]:pt-6 flex justify-end">
<button type="submit" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70 bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 py-2 has-[>svg]:px-3" data-slot="button" data-variant="default" data-size="default" form="ex-card-form">Send magic link</button>
</div>
</div>Further reading
htmx — refresh card content
Click Refresh — htmx GETs /card/recent-activity and swaps the CardContent in place.
Cards are perfect htmx targets because they describe a single unit of UI: refresh the inner content, leave the chrome (header, footer) alone. Use hx-target pointing at a specific CardContent element to swap just that.
- Click Refresh to load the latest activity.
<Card>
<CardHeader>
<CardTitle>Recent activity</CardTitle>
<CardAction>
<Button hx-get="/api/activity" hx-target="#activity" hx-swap="innerHTML">
Refresh
</Button>
</CardAction>
</CardHeader>
<CardContent>
<ul id="activity">…</ul>
</CardContent>
</Card>{{ card_open() }}
{{ card_header_open() }}
{{ card_title("Recent activity") }}
{{ card_action_open() }}
{{ button("Refresh",
hx_get="/api/activity",
hx_target="#activity",
hx_swap="innerHTML") }}
{{ card_action_close() }}
{{ card_header_close() }}
{{ card_content_open() }}
<ul id="activity">…</ul>
{{ card_content_close() }}
{{ card_close() }}{{template "card" (dict
"Title" "Recent activity"
"Action" (htmlSafe `{{template "button" (dict "Label" "Refresh" "Attrs" (dict
"hx-get" "/api/activity"
"hx-target" "#activity"
"hx-swap" "innerHTML"
))}}`)
"Content" (htmlSafe `<ul id="activity">…</ul>`)
)}}<.card>
<.card_header>
<.card_title>Recent activity</.card_title>
<.card_action>
<.button hx-get={~p"/api/activity"} hx-target="#activity" hx-swap="innerHTML">
Refresh
</.button>
</.card_action>
</.card_header>
<.card_content>
<ul id="activity">…</ul>
</.card_content>
</.card><div data-slot="card" class="flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm w-full max-w-md">
<div data-slot="card-header" class="@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6">
<div data-slot="card-title" class="leading-none font-semibold">Recent activity</div>
<div data-slot="card-description" class="text-sm text-muted-foreground">Latest events in the last 24 h.</div>
<div data-slot="card-action" class="col-start-2 row-span-2 row-start-1 self-start justify-self-end">
<button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70 border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5" data-slot="button" data-variant="outline" data-size="sm" hx-get="/card/recent-activity" hx-target="#ex-card-activity" hx-swap="innerHTML">Refresh</button>
</div>
</div>
<div data-slot="card-content" class="px-6">
<ul id="ex-card-activity" class="space-y-1 text-sm">
<li>Click Refresh to load the latest activity.</li>
</ul>
</div>
</div>Further reading
API Reference
<Card>
| Prop | Type | Default | Description |
|---|---|---|---|
CardTitle.as | "div"|"h1"|"h2"|"h3"|"h4"|"h5"|"h6" | "div" | Element CardTitle renders. Use a heading (h1-h6) so an article/section card has a child heading in the document outline.MDNsection element |
CardTitle.id | string | — | Id on CardTitle. Point Card's ariaLabelledby at it to name a section/article card from its visible heading.MDNregion role |
as | "div"|"article"|"section"|"li"|"aside" | "div" | Semantic element. Use article for self-contained content. |
ariaLabelledby | string | — | Id of a CardTitle inside to give the landmark an accessible name. |
ariaLabel | string | — | Accessible name when there's no CardTitle. |
class | string | — | Extra Tailwind classes appended to the root element. |