shshadcn-htmx

Components

Container Card

A self-adapting card that restyles based on its own inline width, not the viewport. The same markup stacks media above text in a narrow sidebar and lays them side-by-side in a wide column — built on CSS container-type: inline-size and @container queries. Pure CSS, zero JavaScript.

Installation

1. Install via the shadcn CLI

npx shadcn@latest add http://localhost/r/container-card.json

2. Use it

components/ui/container-card.tsx
import {
  ContainerCard,
  ContainerCardTitle,
  ContainerCardDescription,
  ContainerCardFooter,
} from "@/components/ui/container-card"

// Same markup adapts to whatever column it lands in — no per-call breakpoints.
<ContainerCard
  ariaLabelledby="cc-1"
  media={<img src="/cover.jpg" alt="" class="size-full object-cover" />}
>
  <ContainerCardTitle id="cc-1">Card title</ContainerCardTitle>
  <ContainerCardDescription>Supporting copy.</ContainerCardDescription>
  <ContainerCardFooter>
    <a href="/more">Read more</a>
  </ContainerCardFooter>
</ContainerCard>

// Text-only card, custom flip threshold, semantic <section>.
<ContainerCard as="section" break="20rem">
  <ContainerCardTitle>No media</ContainerCardTitle>
</ContainerCard>
Or copy the source manually
components/ui/container-card.tsx
/** @jsxImportSource hono/jsx */
import type { Child, PropsWithChildren } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"

// Container Card — shadcn-htmx, htmx v4 + Tailwind v4.
//
// A self-adapting card that restyles based on its OWN inline width rather than
// the viewport. The same markup renders stacked (media above text) when it sits
// in a narrow column or sidebar, and side-by-side (media beside text) when it
// has room — so one component drops into a sidebar, a wide content column, or a
// grid cell with no per-call breakpoints. Pure CSS; zero JavaScript.
//
// Built on (read before editing):
//   - CSS `container-type: inline-size` — establishes the card as a size query
//     container so its descendants can be styled against the card's own inline
//     width (computed in isolation, with inline-size containment to avoid query
//     loops). We name the container so the threshold variant targets THIS card
//     and not some ancestor container.
//       repos/mdn/files/en-us/web/css/reference/properties/container-type/index.md
//   - The web.dev "Container query card" pattern — base styles are single
//     column / centred; an `@container (min-width: …)` rule flips to a
//     two-column grid and reveals the description at wider container sizes.
//       repos/web.dev/src/site/content/en/patterns/layout/container-query-card/index.md
//       repos/web.dev/src/site/content/en/patterns/layout/container-query-card/assets/style.css
//
// shadcn/ui's Card is a static container with no self-adapting behaviour, so
// there is no upstream class string to mirror 1:1 — we keep the same visual
// shell (rounded border, bg-card, shadow) as registry/ui/card.tsx and add the
// container-query layout.
//   Card shell mirrored from: registry/ui/card.tsx
//
// Tailwind v4 container queries (verified against the engine):
//   - `@container/container-card` → container-type: inline-size + container-name
//     (repos/tailwindcss/packages/tailwindcss/src/utilities.ts: the `@container`
//      functional utility emits `container-type` and, with a modifier, the
//      `container-name`).
//   - `@min-[28rem]/container-card:<util>` → wraps the utility in
//     `@container container-card (min-width: 28rem)` so it only fires when THIS
//     named card is at least the threshold wide
//     (repos/tailwindcss/packages/tailwindcss/src/variants.ts: the `@container`
//      variant supports an optional name then the size query).
//
// The threshold is published as the `--container-card-break` custom property so
// it is documented/inspectable, but the actual query lives in the arbitrary
// `@min-[…]` variant (container queries can't read a custom property in the
// query condition itself — that is a platform limitation, not a hack).

type ContainerCardAs = "article" | "section" | "div" | "li" | "aside"

// The query-container root. `@container/container-card` is the whole point:
// container-type: inline-size + the name `container-card`. The visual shell
// matches registry/ui/card.tsx (rounded, bordered, bg-card, shadow).
const ROOT =
  "@container/container-card overflow-hidden rounded-xl border bg-card text-card-foreground shadow-sm"

// The layout block. Stacked by default (flex column). At >= the break width of
// THIS named container, it becomes a two-column grid with the media beside the
// body — matching the web.dev pattern's `display: grid; grid-template-columns:
// 40% 1fr` flip.
const LAYOUT =
  "flex flex-col @min-[28rem]/container-card:grid @min-[28rem]/container-card:grid-cols-[40%_1fr] @min-[28rem]/container-card:items-stretch"

// Media slot: full-bleed banner when stacked; locked column when side-by-side.
const MEDIA =
  "bg-muted aspect-video w-full @min-[28rem]/container-card:aspect-auto @min-[28rem]/container-card:h-full"

// Body: generous padding; centred text when stacked, left-aligned when wide
// (mirrors the pattern's `text-align: center` → `left` flip).
const BODY =
  "flex flex-col gap-2 p-6 text-center @min-[28rem]/container-card:text-left"

const TITLE = "leading-none font-semibold"
const DESCRIPTION = "text-sm text-muted-foreground"
// Footer actions: centred when stacked, pushed to the start when side-by-side.
const FOOTER =
  "mt-2 flex items-center justify-center gap-2 @min-[28rem]/container-card:justify-start"

type ContainerCardProps = PropsWithChildren<{
  // Semantic element. Defaults to <article> because a container card is almost
  // always self-contained, syndicatable content (product, post, comment).
  // See repos/mdn/files/en-us/web/html/reference/elements/article/index.md
  as?: ContainerCardAs
  // The media child (img / video / picture / div). Rendered in the media slot
  // ABOVE the body when stacked, BESIDE it when wide. Omit for a text-only card.
  media?: Child
  // Inline width at which the card flips from stacked to side-by-side. Any CSS
  // length the @container query understands. Published as the
  // --container-card-break custom property for inspection. Note: changing the
  // numeric threshold requires editing the @min-[…] variant too, since a
  // container query condition cannot read a custom property (platform limit).
  break?: string
  ariaLabel?: string
  // Pair with the id of the title inside so the <article>/<section> has an
  // accessible name for AT landmark navigation.
  ariaLabelledby?: string
  class?: ClassValue
  id?: string
  // Forward hx-*, data-*, aria-*, and standard attributes onto the root.
  [key: string]: unknown
}>

export function ContainerCard(props: ContainerCardProps) {
  const {
    as = "article",
    media,
    break: breakAt = "28rem",
    ariaLabel,
    ariaLabelledby,
    class: className,
    id,
    children,
    ...rest
  } = props as ContainerCardProps
  const Tag: any = as
  return (
    <Tag
      id={id}
      data-slot="container-card"
      // Documented threshold; the active query lives in the @min-[28rem] variant.
      style={`--container-card-break:${breakAt}`}
      aria-label={ariaLabel}
      aria-labelledby={ariaLabelledby}
      class={cn(ROOT, className)}
      {...rest}
    >
      <div data-slot="container-card-layout" class={LAYOUT}>
        {media ? (
          <div data-slot="container-card-media" class={MEDIA}>
            {media}
          </div>
        ) : null}
        <div data-slot="container-card-body" class={BODY}>
          {children}
        </div>
      </div>
    </Tag>
  )
}

export function ContainerCardTitle(
  props: PropsWithChildren<{ class?: ClassValue; id?: string }>,
) {
  return (
    <div data-slot="container-card-title" id={props.id} class={cn(TITLE, props.class)}>
      {props.children}
    </div>
  )
}

export function ContainerCardDescription(
  props: PropsWithChildren<{ class?: ClassValue }>,
) {
  return (
    <p data-slot="container-card-description" class={cn(DESCRIPTION, props.class)}>
      {props.children}
    </p>
  )
}

export function ContainerCardFooter(
  props: PropsWithChildren<{ class?: ClassValue }>,
) {
  return (
    <div data-slot="container-card-footer" class={cn(FOOTER, props.class)}>
      {props.children}
    </div>
  )
}

1. Save the file

Copy container-card.html into templates/components/.

2. Use it

templates/components/container-card.html
{% from "components/container-card.html" import container_card %}

{% call container_card(
     title="Card title",
     description="Supporting copy",
     media='<img src="/cover.jpg" alt="" class="size-full object-cover">'
   ) %}
  <a href="/more">Read more</a>
{% endcall %}
View source
templates/components/container-card.html
{# Container Card macro — shadcn-htmx, htmx v4 + Tailwind v4.
   Mirrors registry/ui/container-card.tsx.

   A self-adapting card that restyles based on its OWN inline width: stacked
   (media above text) when narrow, side-by-side when wide. Built on CSS
   container queries (container-type: inline-size + @container). Pure CSS; no JS.

     repos/mdn/files/en-us/web/css/reference/properties/container-type/index.md
     repos/web.dev/src/site/content/en/patterns/layout/container-query-card/index.md

   Usage:
     {% from "components/container-card.html" import container_card %}

     {% call container_card(
          title="Card title",
          description="Supporting copy",
          media='<img src="/cover.jpg" alt="" class="size-full object-cover">'
        ) %}
       <a href="/more" class="text-sm font-medium text-primary underline-offset-4 hover:underline">Read more</a>
     {% endcall %}

   Args:
     tag             article | section | div | li | aside. Default "article".
     title           card title text (optional; rendered as the title slot).
     title_id        id on the title, pair with aria_labelledby for naming.
     description     supporting copy (optional).
     media           raw HTML for the media slot (img/video/picture). Optional.
     break_at        inline width at which the card flips to side-by-side.
                     Published as --container-card-break. Default "28rem".
                     (Changing the number also needs the @min-[…] variant edited.)
     aria_label / aria_labelledby   accessible name for the root.
     extra_class     extra classes appended to the root.
     attrs           dict of extra attributes (hx-*, data-*, aria-*).
   The caller() body becomes the footer / actions row. #}

{% macro container_card(
    tag="article",
    title=none,
    title_id=none,
    description=none,
    media=none,
    break_at="28rem",
    aria_label=none,
    aria_labelledby=none,
    extra_class="",
    attrs={}
) %}
<{{ tag }}
  data-slot="container-card"
  style="--container-card-break:{{ break_at }}"
  {%- if aria_label %} aria-label="{{ aria_label }}"{% endif %}
  {%- if aria_labelledby %} aria-labelledby="{{ aria_labelledby }}"{% endif %}
  {%- for k, v in attrs.items() %} {{ k|replace('_','-') }}="{{ v }}"{% endfor %}
  class="@container/container-card overflow-hidden rounded-xl border bg-card text-card-foreground shadow-sm {{ extra_class }}">
  <div data-slot="container-card-layout" class="flex flex-col @min-[28rem]/container-card:grid @min-[28rem]/container-card:grid-cols-[40%_1fr] @min-[28rem]/container-card:items-stretch">
    {%- if media %}
    <div data-slot="container-card-media" class="bg-muted aspect-video w-full @min-[28rem]/container-card:aspect-auto @min-[28rem]/container-card:h-full">{{ media|safe }}</div>
    {%- endif %}
    <div data-slot="container-card-body" class="flex flex-col gap-2 p-6 text-center @min-[28rem]/container-card:text-left">
      {%- if title %}
      <div data-slot="container-card-title"{% if title_id %} id="{{ title_id }}"{% endif %} class="leading-none font-semibold">{{ title }}</div>
      {%- endif %}
      {%- if description %}
      <p data-slot="container-card-description" class="text-sm text-muted-foreground">{{ description }}</p>
      {%- endif %}
      <div data-slot="container-card-footer" class="mt-2 flex items-center justify-center gap-2 @min-[28rem]/container-card:justify-start">{{ caller() }}</div>
    </div>
  </div>
</{{ tag }}>
{% endmacro %}

1. Save the file

Add container-card.tmpl alongside your templates.

2. Use it

components/container-card.tmpl
{{template "container-card" (dict
    "Title" "Card title"
    "Description" "Supporting copy"
    "Media" (htmlSafe `<img src="/cover.jpg" alt="" class="size-full object-cover">`)
    "Body" (htmlSafe `<a href="/more">Read more</a>`))}}
View source
components/container-card.tmpl
{{/*
  Container Card template — shadcn-htmx, htmx v4 + Tailwind v4.
  Mirrors registry/ui/container-card.tsx.

  A self-adapting card that restyles based on its OWN inline width: stacked
  (media above text) when narrow, side-by-side when wide. Built on CSS
  container queries (container-type: inline-size + @container). Pure CSS; no JS.

    repos/mdn/files/en-us/web/css/reference/properties/container-type/index.md
    repos/web.dev/src/site/content/en/patterns/layout/container-query-card/index.md

      type ContainerCardArgs struct {
          Tag         string // article | section | div | li | aside; default "article"
          Title       string // title slot text (optional)
          TitleID     string // id on the title (pair with AriaLabelledby)
          Description string // supporting copy (optional)
          Media       string // raw HTML for the media slot (use htmlSafe); optional
          Break       string // flip threshold; default "28rem" (also edit @min-[…])
          AriaLabel   string
          AriaLabelledby string
          Class       string // extra classes appended to the root
          Body        string // footer / actions HTML (use htmlSafe)
      }

  Usage:
      {{template "container-card" (dict
          "Title" "Card title"
          "Description" "Supporting copy"
          "Media" (htmlSafe `<img src="/cover.jpg" alt="" class="size-full object-cover">`)
          "Body" (htmlSafe `<a href="/more">Read more</a>`))}}
*/}}

{{define "container-card"}}
{{- $tag := or .Tag "article" -}}
{{- $break := or .Break "28rem" -}}
<{{$tag}} data-slot="container-card" style="--container-card-break:{{$break}}"{{if .AriaLabel}} aria-label="{{.AriaLabel}}"{{end}}{{if .AriaLabelledby}} aria-labelledby="{{.AriaLabelledby}}"{{end}} class="@container/container-card overflow-hidden rounded-xl border bg-card text-card-foreground shadow-sm{{if .Class}} {{.Class}}{{end}}">
  <div data-slot="container-card-layout" class="flex flex-col @min-[28rem]/container-card:grid @min-[28rem]/container-card:grid-cols-[40%_1fr] @min-[28rem]/container-card:items-stretch">
    {{- if .Media}}
    <div data-slot="container-card-media" class="bg-muted aspect-video w-full @min-[28rem]/container-card:aspect-auto @min-[28rem]/container-card:h-full">{{htmlSafe .Media}}</div>
    {{- end}}
    <div data-slot="container-card-body" class="flex flex-col gap-2 p-6 text-center @min-[28rem]/container-card:text-left">
      {{- if .Title}}
      <div data-slot="container-card-title"{{if .TitleID}} id="{{.TitleID}}"{{end}} class="leading-none font-semibold">{{.Title}}</div>
      {{- end}}
      {{- if .Description}}
      <p data-slot="container-card-description" class="text-sm text-muted-foreground">{{.Description}}</p>
      {{- end}}
      <div data-slot="container-card-footer" class="mt-2 flex items-center justify-center gap-2 @min-[28rem]/container-card:justify-start">{{htmlSafe .Body}}</div>
    </div>
  </div>
</{{$tag}}>
{{end}}

1. Save the file

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

2. Use it

lib/my_app_web/components/container_card.ex
<.container_card>
  <:media>
    <img src="/cover.jpg" alt="" class="size-full object-cover" />
  </:media>
  <.container_card_title>Card title</.container_card_title>
  <.container_card_description>Supporting copy.</.container_card_description>
  <.container_card_footer>
    <a href="/more">Read more</a>
  </.container_card_footer>
</.container_card>
View source
lib/my_app_web/components/container_card.ex
defmodule ShadcnHtmx.Components.ContainerCard do
  @moduledoc """
  Container Card — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.

  Mirrors registry/ui/container-card.tsx.

  A self-adapting card that restyles based on its OWN inline width rather than
  the viewport: stacked (media above text) when narrow, side-by-side when wide.
  The same markup drops into a sidebar, a wide column, or a grid cell with no
  per-call breakpoints. Built on CSS container queries
  (container-type: inline-size + @container). Pure CSS; zero JavaScript.

    - repos/mdn/files/en-us/web/css/reference/properties/container-type/index.md
    - repos/web.dev/src/site/content/en/patterns/layout/container-query-card/index.md

  ## Examples

      <.container_card>
        <:media>
          <img src="/cover.jpg" alt="" class="size-full object-cover" />
        </:media>
        <.container_card_title>Card title</.container_card_title>
        <.container_card_description>Supporting copy.</.container_card_description>
        <.container_card_footer>
          <a href="/more">Read more</a>
        </.container_card_footer>
      </.container_card>
  """

  use Phoenix.Component

  attr :tag, :string, default: "article", values: ~w(article section div li aside)
  # Flip threshold; published as --container-card-break. Changing the number
  # also requires editing the @min-[…] variant (a container query condition
  # cannot read a custom property — platform limitation, not a hack).
  attr :break, :string, default: "28rem"
  attr :class, :string, default: nil
  attr :rest, :global, include: ~w(aria-label aria-labelledby)
  slot :media
  slot :inner_block, required: true

  def container_card(assigns) do
    ~H"""
    <.dynamic_tag
      tag_name={@tag}
      data-slot="container-card"
      style={"--container-card-break:#{@break}"}
      class={[
        "@container/container-card overflow-hidden rounded-xl border bg-card text-card-foreground shadow-sm",
        @class
      ]}
      {@rest}
    >
      <div
        data-slot="container-card-layout"
        class="flex flex-col @min-[28rem]/container-card:grid @min-[28rem]/container-card:grid-cols-[40%_1fr] @min-[28rem]/container-card:items-stretch"
      >
        <div
          :if={@media != []}
          data-slot="container-card-media"
          class="bg-muted aspect-video w-full @min-[28rem]/container-card:aspect-auto @min-[28rem]/container-card:h-full"
        >
          {render_slot(@media)}
        </div>
        <div
          data-slot="container-card-body"
          class="flex flex-col gap-2 p-6 text-center @min-[28rem]/container-card:text-left"
        >
          {render_slot(@inner_block)}
        </div>
      </div>
    </.dynamic_tag>
    """
  end

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

  def container_card_title(assigns) do
    ~H"""
    <div data-slot="container-card-title" id={@id} class={["leading-none font-semibold", @class]}>
      {render_slot(@inner_block)}
    </div>
    """
  end

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

  def container_card_description(assigns) do
    ~H"""
    <p data-slot="container-card-description" class={["text-sm text-muted-foreground", @class]}>
      {render_slot(@inner_block)}
    </p>
    """
  end

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

  def container_card_footer(assigns) do
    ~H"""
    <div
      data-slot="container-card-footer"
      class={[
        "mt-2 flex items-center justify-center gap-2 @min-[28rem]/container-card:justify-start",
        @class
      ]}
    >
      {render_slot(@inner_block)}
    </div>
    """
  end
end

1. Save the file

Paste the markup; relies only on theme tokens.

2. Use it

snippets/container-card.html
<article data-slot="container-card"
         style="--container-card-break:28rem"
         class="@container/container-card overflow-hidden rounded-xl border bg-card text-card-foreground shadow-sm">
  <div data-slot="container-card-layout"
       class="flex flex-col @min-[28rem]/container-card:grid @min-[28rem]/container-card:grid-cols-[40%_1fr]">
    <div data-slot="container-card-media" class="bg-muted aspect-video w-full">…</div>
    <div data-slot="container-card-body" class="flex flex-col gap-2 p-6 text-center @min-[28rem]/container-card:text-left">…</div>
  </div>
</article>
View source
snippets/container-card.html
<!--
  shadcn-htmx — raw HTML Container Card snippet.
  Mirrors registry/ui/container-card.tsx.

  A self-adapting card that restyles based on its OWN inline width: stacked
  (media above text) when narrow, side-by-side when wide. Built on CSS
  container queries (container-type: inline-size + @container). Pure CSS;
  no script. Relies only on theme tokens.

  How it works:
    - The root carries `@container/container-card`, which sets
      `container-type: inline-size` and names the container `container-card`.
    - Descendants use `@min-[28rem]/container-card:` variants, which only fire
      when THIS named card is at least 28rem wide — independent of the viewport.
    - The same markup therefore adapts to whatever column it lands in.

    repos/mdn/files/en-us/web/css/reference/properties/container-type/index.md
    repos/web.dev/src/site/content/en/patterns/layout/container-query-card/index.md
-->

<!-- Card with media: stacked under ~28rem, media-beside-text at or above it. -->
<article data-slot="container-card"
         style="--container-card-break:28rem"
         aria-labelledby="cc-title"
         class="@container/container-card overflow-hidden rounded-xl border bg-card text-card-foreground shadow-sm">
  <div data-slot="container-card-layout"
       class="flex flex-col @min-[28rem]/container-card:grid @min-[28rem]/container-card:grid-cols-[40%_1fr] @min-[28rem]/container-card:items-stretch">
    <div data-slot="container-card-media"
         class="bg-muted aspect-video w-full @min-[28rem]/container-card:aspect-auto @min-[28rem]/container-card:h-full">
      <img src="/cover.jpg" alt="" class="size-full object-cover" />
    </div>
    <div data-slot="container-card-body"
         class="flex flex-col gap-2 p-6 text-center @min-[28rem]/container-card:text-left">
      <div data-slot="container-card-title" id="cc-title" class="leading-none font-semibold">Card title</div>
      <p data-slot="container-card-description" class="text-sm text-muted-foreground">
        Supporting copy that adapts with the card's own width.
      </p>
      <div data-slot="container-card-footer"
           class="mt-2 flex items-center justify-center gap-2 @min-[28rem]/container-card:justify-start">
        <a href="/more" class="text-sm font-medium text-primary underline-offset-4 hover:underline">Read more</a>
      </div>
    </div>
  </div>
</article>

<!-- Text-only card: omit the media slot. Still adapts its alignment by width. -->
<article data-slot="container-card"
         style="--container-card-break:28rem"
         class="@container/container-card overflow-hidden rounded-xl border bg-card text-card-foreground shadow-sm">
  <div data-slot="container-card-layout"
       class="flex flex-col @min-[28rem]/container-card:grid @min-[28rem]/container-card:grid-cols-[40%_1fr] @min-[28rem]/container-card:items-stretch">
    <div data-slot="container-card-body"
         class="flex flex-col gap-2 p-6 text-center @min-[28rem]/container-card:text-left">
      <div data-slot="container-card-title" class="leading-none font-semibold">No media</div>
      <p data-slot="container-card-description" class="text-sm text-muted-foreground">
        Works without a media slot too.
      </p>
    </div>
  </div>
</article>

Examples

One card, two layouts

The exact same ContainerCard is rendered in a narrow 18rem column and a wide column. It stacks in the narrow one and goes side-by-side in the wide one — no viewport breakpoints involved.

The card carries @container/container-card (which sets container-type: inline-size and names the container), so its children query the card's width with @min-[28rem]/container-card: — exactly the web.dev "container query card" pattern, where base styles are single-column and an @container (min-width) rule flips to a two-column grid. Drop the same markup anywhere; it adapts to the slot it lands in.

Narrow column (14rem)

Trailhead

A loop with big views and an easy grade.

Wide column

Trailhead

A loop with big views and an easy grade.

<ContainerCard
  ariaLabelledby="cc-1"
  media={<img src="/cover.jpg" alt="" class="size-full object-cover" />}
>
  <ContainerCardTitle id="cc-1">Trailhead</ContainerCardTitle>
  <ContainerCardDescription>A loop with big views and an easy grade.</ContainerCardDescription>
  <ContainerCardFooter>
    <a href="#">View route</a>
  </ContainerCardFooter>
</ContainerCard>
{% call container_card(
     title="Trailhead",
     description="A loop with big views and an easy grade.",
     media='<img src="/cover.jpg" alt="" class="size-full object-cover">'
   ) %}
  <a href="#">View route</a>
{% endcall %}
{{template "container-card" (dict
    "Title" "Trailhead"
    "Description" "A loop with big views and an easy grade."
    "Media" (htmlSafe `<img src="/cover.jpg" alt="" class="size-full object-cover">`)
    "Body" (htmlSafe `<a href="#">View route</a>`))}}
<.container_card>
  <:media><img src="/cover.jpg" alt="" class="size-full object-cover" /></:media>
  <.container_card_title>Trailhead</.container_card_title>
  <.container_card_description>A loop with big views and an easy grade.</.container_card_description>
  <.container_card_footer><a href="#">View route</a></.container_card_footer>
</.container_card>
<div class="grid w-full gap-6 sm:grid-cols-[14rem_minmax(30rem,1fr)]" data-test="basic">
  <div class="space-y-1.5">
    <p class="text-xs font-medium text-muted-foreground">Narrow column (14rem)</p>
    <article data-slot="container-card" style="--container-card-break:28rem" aria-labelledby="cc-b-narrow" class="@container/container-card overflow-hidden rounded-xl border bg-card text-card-foreground shadow-sm">
      <div data-slot="container-card-layout" class="flex flex-col @min-[28rem]/container-card:grid @min-[28rem]/container-card:grid-cols-[40%_1fr] @min-[28rem]/container-card:items-stretch">
        <div data-slot="container-card-media" class="bg-muted aspect-video w-full @min-[28rem]/container-card:aspect-auto @min-[28rem]/container-card:h-full">
          <div class="size-full">
          </div>
        </div>
        <div data-slot="container-card-body" class="flex flex-col gap-2 p-6 text-center @min-[28rem]/container-card:text-left">
          <div data-slot="container-card-title" id="cc-b-narrow" class="leading-none font-semibold">Trailhead</div>
          <p data-slot="container-card-description" class="text-sm text-muted-foreground">A loop with big views and an easy grade.</p>
          <div data-slot="container-card-footer" class="mt-2 flex items-center justify-center gap-2 @min-[28rem]/container-card:justify-start">
            <a href="#" class="text-sm font-medium text-primary underline-offset-4 hover:underline">View route</a>
          </div>
        </div>
      </div>
    </article>
  </div>
  <div class="space-y-1.5">
    <p class="text-xs font-medium text-muted-foreground">Wide column</p>
    <article data-slot="container-card" style="--container-card-break:28rem" aria-labelledby="cc-b-wide" class="@container/container-card overflow-hidden rounded-xl border bg-card text-card-foreground shadow-sm">
      <div data-slot="container-card-layout" class="flex flex-col @min-[28rem]/container-card:grid @min-[28rem]/container-card:grid-cols-[40%_1fr] @min-[28rem]/container-card:items-stretch">
        <div data-slot="container-card-media" class="bg-muted aspect-video w-full @min-[28rem]/container-card:aspect-auto @min-[28rem]/container-card:h-full">
          <div class="size-full">
          </div>
        </div>
        <div data-slot="container-card-body" class="flex flex-col gap-2 p-6 text-center @min-[28rem]/container-card:text-left">
          <div data-slot="container-card-title" id="cc-b-wide" class="leading-none font-semibold">Trailhead</div>
          <p data-slot="container-card-description" class="text-sm text-muted-foreground">A loop with big views and an easy grade.</p>
          <div data-slot="container-card-footer" class="mt-2 flex items-center justify-center gap-2 @min-[28rem]/container-card:justify-start">
            <a href="#" class="text-sm font-medium text-primary underline-offset-4 hover:underline">View route</a>
          </div>
        </div>
      </div>
    </article>
  </div>
</div>

In a grid — each cell decides for itself

Place the same card in grid cells of different widths. Each instance queries its own cell, so the wide cells go side-by-side while the narrow ones stay stacked — the property container queries give you over a viewport-only approach.

Because the query container is the card itself, a single column count for the page no longer dictates each card's layout. This is the headline benefit web.dev calls out: components own their responsive logic and "best fit" whatever container they're given.

Compact

Narrow cell stays stacked.

Roomy

Wider cell flips to media-beside-text automatically.

<div class="grid grid-cols-[1fr_2fr] gap-4">
  <ContainerCard media={<img />}>
    <ContainerCardTitle>Compact</ContainerCardTitle>
  </ContainerCard>
  <ContainerCard media={<img />}>
    <ContainerCardTitle>Roomy</ContainerCardTitle>
  </ContainerCard>
</div>
<div class="grid grid-cols-[1fr_2fr] gap-4">
  {% call container_card(title="Compact", media='<img />') %}{% endcall %}
  {% call container_card(title="Roomy", media='<img />') %}{% endcall %}
</div>
<div class="grid grid-cols-[1fr_2fr] gap-4">
  {{template "container-card" (dict "Title" "Compact" "Media" (htmlSafe $img))}}
  {{template "container-card" (dict "Title" "Roomy" "Media" (htmlSafe $img))}}
</div>
<div class="grid grid-cols-[1fr_2fr] gap-4">
  <.container_card><:media><img … /></:media><.container_card_title>Compact</.container_card_title></.container_card>
  <.container_card><:media><img … /></:media><.container_card_title>Roomy</.container_card_title></.container_card>
</div>
<div class="grid w-full gap-4 sm:grid-cols-[1fr_2fr]" data-test="grid">
  <article data-slot="container-card" style="--container-card-break:28rem" aria-label="Compact card" class="@container/container-card overflow-hidden rounded-xl border bg-card text-card-foreground shadow-sm">
    <div data-slot="container-card-layout" class="flex flex-col @min-[28rem]/container-card:grid @min-[28rem]/container-card:grid-cols-[40%_1fr] @min-[28rem]/container-card:items-stretch">
      <div data-slot="container-card-media" class="bg-muted aspect-video w-full @min-[28rem]/container-card:aspect-auto @min-[28rem]/container-card:h-full">
        <div class="size-full">
        </div>
      </div>
      <div data-slot="container-card-body" class="flex flex-col gap-2 p-6 text-center @min-[28rem]/container-card:text-left">
        <div data-slot="container-card-title" class="leading-none font-semibold">Compact</div>
        <p data-slot="container-card-description" class="text-sm text-muted-foreground">Narrow cell stays stacked.</p>
      </div>
    </div>
  </article>
  <article data-slot="container-card" style="--container-card-break:28rem" aria-label="Roomy card" class="@container/container-card overflow-hidden rounded-xl border bg-card text-card-foreground shadow-sm">
    <div data-slot="container-card-layout" class="flex flex-col @min-[28rem]/container-card:grid @min-[28rem]/container-card:grid-cols-[40%_1fr] @min-[28rem]/container-card:items-stretch">
      <div data-slot="container-card-media" class="bg-muted aspect-video w-full @min-[28rem]/container-card:aspect-auto @min-[28rem]/container-card:h-full">
        <div class="size-full">
        </div>
      </div>
      <div data-slot="container-card-body" class="flex flex-col gap-2 p-6 text-center @min-[28rem]/container-card:text-left">
        <div data-slot="container-card-title" class="leading-none font-semibold">Roomy</div>
        <p data-slot="container-card-description" class="text-sm text-muted-foreground">Wider cell flips to media-beside-text automatically.</p>
      </div>
    </div>
  </article>
</div>

Further reading

Text-only & a tighter threshold

Omit the media slot for a text-only card. The flip threshold is configurable via the break prop (published as the --container-card-break custom property); the left card flips earlier at 20rem.

The threshold is exposed as --container-card-break for inspection, but the active query lives in the @min-[…] variant: a container query's condition cannot read a custom property, so the numeric value must match in both places (a platform limitation, not a hack).

Flips at 20rem

No media, earlier threshold.

Flips at 28rem

No media, default threshold.

// Text-only, flips at a tighter threshold.
<ContainerCard as="section" break="20rem">
  <ContainerCardTitle>Flips at 20rem</ContainerCardTitle>
  <ContainerCardDescription>No media, earlier threshold.</ContainerCardDescription>
</ContainerCard>
{% call container_card(tag="section", break_at="20rem", title="Flips at 20rem", description="No media, earlier threshold.") %}{% endcall %}
{{template "container-card" (dict "Tag" "section" "Break" "20rem" "Title" "Flips at 20rem" "Description" "No media, earlier threshold.")}}
<.container_card tag="section" break="20rem">
  <.container_card_title>Flips at 20rem</.container_card_title>
  <.container_card_description>No media, earlier threshold.</.container_card_description>
</.container_card>
<div class="grid w-full gap-4 sm:grid-cols-2" data-test="text">
  <section data-slot="container-card" style="--container-card-break:20rem" aria-label="Early flip" class="@container/container-card overflow-hidden rounded-xl border bg-card text-card-foreground shadow-sm">
    <div data-slot="container-card-layout" class="flex flex-col @min-[28rem]/container-card:grid @min-[28rem]/container-card:grid-cols-[40%_1fr] @min-[28rem]/container-card:items-stretch">
      <div data-slot="container-card-body" class="flex flex-col gap-2 p-6 text-center @min-[28rem]/container-card:text-left">
        <div data-slot="container-card-title" class="leading-none font-semibold">Flips at 20rem</div>
        <p data-slot="container-card-description" class="text-sm text-muted-foreground">No media, earlier threshold.</p>
      </div>
    </div>
  </section>
  <section data-slot="container-card" style="--container-card-break:28rem" aria-label="Default flip" class="@container/container-card overflow-hidden rounded-xl border bg-card text-card-foreground shadow-sm">
    <div data-slot="container-card-layout" class="flex flex-col @min-[28rem]/container-card:grid @min-[28rem]/container-card:grid-cols-[40%_1fr] @min-[28rem]/container-card:items-stretch">
      <div data-slot="container-card-body" class="flex flex-col gap-2 p-6 text-center @min-[28rem]/container-card:text-left">
        <div data-slot="container-card-title" class="leading-none font-semibold">Flips at 28rem</div>
        <p data-slot="container-card-description" class="text-sm text-muted-foreground">No media, default threshold.</p>
      </div>
    </div>
  </section>
</div>

API Reference

<ContainerCard>

PropTypeDefaultDescription
as"article"|"section"|"div"|"li"|"aside""article"
Semantic element for the card root. Defaults to <article> for self-contained, syndicatable content (product, post, comment).MDN<article>
mediaChild
Media element (img/video/picture/div) shown above the body when the card is stacked and beside it when wide. Omit for a text-only card.
breakstring"28rem"
Inline width at which the card flips from stacked to side-by-side. Published as the --container-card-break custom property for inspection. Note: changing the number also requires editing the @min-[28rem] variant, since a container query condition cannot read a custom property.MDNcontainer-type
ariaLabelstring
Accessible name when no visible <label>.MDNaria-label
ariaLabelledbystring
Id of a visible element providing the accessible name.MDNaria-labelledby
classstring
Extra Tailwind classes appended to the root element.
hx-*any
Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference