shshadcn-htmx

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.json

2. Use it

components/ui/card.tsx
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
components/ui/card.tsx
/** @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

templates/components/card.html
{% 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
templates/components/card.html
{# 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

templates/components/card.tmpl
{{template "card" (dict
  "Title" "Account"
  "Description" "Update your settings here."
  "Content" (htmlSafe `<p>Body content…</p>`)
  "Footer"  (htmlSafe `<button class="…">Save</button>`)
)}}
View source
templates/components/card.tmpl
{{/*
  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

lib/my_app_web/components/card.ex
<.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
lib/my_app_web/components/card.ex
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

index.html
<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
index.html
<!--
  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.

Account
Update your account preferences and notification settings.

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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 py-2 has-[&gt;svg]:px-3" data-slot="button" data-variant="default" data-size="default">Save changes</button>
  </div>
</div>

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.

Untitled draft
Last edited 12 minutes ago.

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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.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.

Sign in
Enter your email below to receive a magic link.
<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 [&amp;.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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 py-2 has-[&gt;svg]:px-3" data-slot="button" data-variant="default" data-size="default" form="ex-card-form">Send magic link</button>
  </div>
</div>

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.

Recent activity
Latest events in the last 24 h.
  • 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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.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-[&gt;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>

PropTypeDefaultDescription
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.idstring
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.
ariaLabelledbystring
Id of a CardTitle inside to give the landmark an accessible name.
ariaLabelstring
Accessible name when there's no CardTitle.
classstring
Extra Tailwind classes appended to the root element.