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.json2. Use it
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
/** @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
{% 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
{# 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
{{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
{{/*
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
<.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
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
<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
<!--
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)
Wide column
<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>Further reading
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.
Narrow cell stays stacked.
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).
No media, earlier threshold.
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>Further reading
API Reference
<ContainerCard>
| Prop | Type | Default | Description |
|---|---|---|---|
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> |
media | Child | — | 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. |
break | string | "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 |
ariaLabel | string | — | Accessible name when no visible <label>.MDNaria-label |
ariaLabelledby | string | — | Id of a visible element providing the accessible name.MDNaria-labelledby |
class | string | — | Extra Tailwind classes appended to the root element. |
hx-* | any | — | Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference |