Container Query Cards
A product card that reflows itself based on the width of its own container, not the viewport, using CSS container queries to swap between stacked, horizontal, and compact layouts inside three independently resizable panels you can drag.
MCP
Code
:root {
--bg: #0c0d10;
--surface: #15161b;
--surface-2: #1d1f27;
--ink: #e9eaf0;
--muted: #9a9cab;
--accent: #8b5cf6;
--accent-2: #22d3ee;
--ok: #34d399;
--warn: #fbbf24;
--danger: #f87171;
--line: rgba(255, 255, 255, 0.1);
--line-2: rgba(255, 255, 255, 0.18);
--r-sm: 8px;
--r-md: 12px;
--r-lg: 16px;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
background:
radial-gradient(1200px 700px at 15% -10%, rgba(139, 92, 246, 0.14), transparent 60%),
radial-gradient(1000px 600px at 110% 10%, rgba(34, 211, 238, 0.1), transparent 55%),
var(--bg);
color: var(--ink);
font-family: "Inter", system-ui, -apple-system, sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
min-height: 100vh;
}
code {
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 0.86em;
color: var(--accent-2);
background: rgba(34, 211, 238, 0.08);
padding: 0.1em 0.4em;
border-radius: 6px;
}
.page {
max-width: 1180px;
margin: 0 auto;
padding: clamp(24px, 5vw, 56px) clamp(16px, 4vw, 40px) 48px;
}
.page__head {
max-width: 720px;
}
.eyebrow {
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 12px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent-2);
margin: 0 0 12px;
}
.page__head h1 {
margin: 0 0 12px;
font-size: clamp(28px, 5vw, 44px);
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.1;
}
.lede {
margin: 0 0 20px;
color: var(--muted);
font-size: clamp(15px, 2.2vw, 17px);
max-width: 62ch;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 8px 18px;
align-items: center;
}
.legend__item {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.dot {
width: 10px;
height: 10px;
border-radius: 999px;
display: inline-block;
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.04);
}
.dot--compact {
background: var(--warn);
}
.dot--stacked {
background: var(--accent);
}
.dot--wide {
background: var(--accent-2);
}
.nosupport {
margin: 18px 0 0;
padding: 12px 14px;
border: 1px solid rgba(251, 191, 36, 0.35);
background: rgba(251, 191, 36, 0.08);
border-radius: var(--r-md);
color: var(--warn);
font-size: 13.5px;
}
/* ---- Stage & panels ---- */
.stage {
margin-top: clamp(28px, 4vw, 44px);
display: flex;
flex-direction: column;
gap: 22px;
}
.panel {
position: relative;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: 12px 12px 14px;
box-shadow: 0 24px 60px -40px rgba(0, 0, 0, 0.9);
/* width is set inline by JS; this is the constrained resizable box */
max-width: 100%;
transition: border-color 0.18s ease;
}
.panel:focus-within {
border-color: var(--line-2);
}
.panel__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 2px 4px 10px;
font-size: 12px;
}
.panel__name {
font-family: "JetBrains Mono", ui-monospace, monospace;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted);
}
.panel__w {
font-family: "JetBrains Mono", ui-monospace, monospace;
color: var(--ink);
background: var(--surface-2);
border: 1px solid var(--line);
padding: 3px 8px;
border-radius: 999px;
font-variant-numeric: tabular-nums;
}
.panel__body {
/* THE container context */
container-type: inline-size;
container-name: card;
}
/* Resize handle on the right edge */
.panel__handle {
position: absolute;
top: 44px;
bottom: 12px;
right: -7px;
width: 16px;
padding: 0;
border: 0;
background: transparent;
cursor: ew-resize;
display: grid;
place-items: center;
touch-action: none;
z-index: 3;
}
.panel__grip {
width: 6px;
align-self: stretch;
margin: 0 auto;
border-radius: 999px;
background: linear-gradient(var(--accent), var(--accent-2));
opacity: 0.55;
transition: opacity 0.15s ease, transform 0.15s ease;
}
.panel__handle:hover .panel__grip,
.panel__handle:active .panel__grip {
opacity: 1;
transform: scaleX(1.35);
}
.panel__handle:focus-visible {
outline: none;
}
.panel__handle:focus-visible .panel__grip {
opacity: 1;
outline: 2px solid var(--accent-2);
outline-offset: 3px;
}
.panel.is-dragging {
user-select: none;
}
.panel.is-dragging .panel__grip {
opacity: 1;
transform: scaleX(1.35);
}
/* ---- The card (default = stacked) ---- */
.card {
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: var(--r-md);
overflow: hidden;
display: grid;
grid-template-columns: 1fr;
outline: none;
transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
}
.card:hover {
border-color: var(--line-2);
box-shadow: 0 18px 40px -30px rgba(0, 0, 0, 0.9);
}
.card:focus-visible {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.35);
}
.card__media {
position: relative;
aspect-ratio: 16 / 9;
overflow: hidden;
}
.card__art {
position: absolute;
inset: 0;
background:
radial-gradient(120px 120px at 30% 25%, rgba(255, 255, 255, 0.22), transparent 60%),
conic-gradient(from 210deg at 65% 60%, var(--accent), var(--accent-2), var(--accent));
filter: saturate(1.05);
}
.card__art::after {
content: "";
position: absolute;
inset: 0;
background:
repeating-linear-gradient(
90deg,
rgba(0, 0, 0, 0.16) 0 2px,
transparent 2px 9px
);
mix-blend-mode: overlay;
opacity: 0.6;
}
.card__badge {
position: absolute;
top: 10px;
left: 10px;
z-index: 2;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 5px 9px;
border-radius: 999px;
background: rgba(12, 13, 16, 0.72);
backdrop-filter: blur(6px);
border: 1px solid var(--line-2);
font-size: 11px;
font-family: "JetBrains Mono", ui-monospace, monospace;
letter-spacing: 0.04em;
}
.card__layout {
color: var(--accent-2);
text-transform: uppercase;
font-weight: 600;
}
.card__cw {
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.card__body {
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
.card__eyebrow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.tag {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--ok);
background: rgba(52, 211, 153, 0.12);
border: 1px solid rgba(52, 211, 153, 0.28);
padding: 3px 8px;
border-radius: 999px;
}
.rating {
font-size: 13px;
color: var(--warn);
font-weight: 600;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.card__title {
margin: 0;
font-size: 19px;
font-weight: 700;
letter-spacing: -0.01em;
line-height: 1.2;
}
.card__desc {
margin: 0;
color: var(--muted);
font-size: 14px;
}
.card__meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.chip {
font-size: 11.5px;
color: var(--ink);
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--line);
padding: 3px 8px;
border-radius: 999px;
white-space: nowrap;
}
.card__foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 2px;
}
.price {
display: inline-flex;
align-items: baseline;
gap: 8px;
}
.price__now {
font-size: 18px;
font-weight: 700;
}
.price__was {
font-size: 13px;
color: var(--muted);
text-decoration: line-through;
}
.buy {
font-family: inherit;
font-size: 13.5px;
font-weight: 600;
color: #0c0d10;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
border: 0;
border-radius: var(--r-sm);
padding: 9px 14px;
cursor: pointer;
white-space: nowrap;
transition: transform 0.12s ease, box-shadow 0.12s ease, filter 0.12s ease;
}
.buy:hover {
filter: brightness(1.06);
box-shadow: 0 10px 24px -12px rgba(139, 92, 246, 0.9);
}
.buy:active {
transform: translateY(1px) scale(0.98);
}
.buy:focus-visible {
outline: 2px solid var(--accent-2);
outline-offset: 2px;
}
.buy.is-added {
background: linear-gradient(135deg, var(--ok), #10b981);
}
.page__foot {
margin-top: 30px;
color: var(--muted);
font-size: 13px;
}
/* =========================================================
CONTAINER QUERIES — the whole point.
The card reacts to the width of .panel__body, not the page.
========================================================= */
@supports (container-type: inline-size) {
/* COMPACT: tiny containers → thumbnail + title only */
@container card (max-width: 239px) {
.card__media {
aspect-ratio: 1 / 1;
}
.card__desc,
.card__meta,
.rating,
.price__was {
display: none;
}
.card__body {
padding: 12px;
gap: 8px;
}
.card__title {
font-size: 15px;
}
.card__foot {
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.buy {
width: 100%;
text-align: center;
}
}
/* STACKED is the default (240–479px) — nothing extra needed,
but we nudge type up a touch once there is room */
@container card (min-width: 340px) {
.card__title {
font-size: 21px;
}
}
/* HORIZONTAL: wide containers → media beside copy, editorial */
@container card (min-width: 480px) {
.card {
grid-template-columns: minmax(200px, 42%) 1fr;
align-items: stretch;
}
.card__media {
aspect-ratio: auto;
min-height: 100%;
}
.card__body {
padding: 22px 24px;
gap: 14px;
justify-content: center;
}
.card__title {
font-size: 24px;
}
.card__desc {
font-size: 15px;
}
}
}
/* ---- Responsive page chrome ---- */
@media (max-width: 520px) {
.panel__handle {
right: -6px;
}
.legend {
gap: 6px 12px;
}
}(function () {
"use strict";
// Feature detection for CSS container queries.
var supportsCQ =
typeof CSS !== "undefined" &&
CSS.supports &&
CSS.supports("container-type", "inline-size");
if (!supportsCQ) {
var note = document.querySelector(".nosupport");
if (note) note.hidden = false;
}
var stage = document.querySelector(".stage");
var tpl = document.getElementById("panel-tpl");
if (!stage || !tpl) return;
// Three presets: label, starting width, min, max.
var PRESETS = [
{ name: "Sidebar", width: 210, min: 150, max: 900 },
{ name: "Card grid cell", width: 360, min: 150, max: 900 },
{ name: "Full-bleed", width: 720, min: 150, max: 980 }
];
// Breakpoints must mirror the @container rules in style.css.
function layoutFor(w) {
if (w < 240) return "compact";
if (w < 480) return "stacked";
return "horizontal";
}
function clamp(v, lo, hi) {
return Math.max(lo, Math.min(hi, v));
}
function buildPanel(preset) {
var node = tpl.content.firstElementChild.cloneNode(true);
node.dataset.preset = preset.name;
var nameEl = node.querySelector(".panel__name");
var body = node.querySelector(".panel__body");
var handle = node.querySelector(".panel__handle");
var wNum = node.querySelector(".panel__w-num");
var cwNum = node.querySelector(".card__cw-num");
var layoutEl = node.querySelector(".card__layout");
var buy = node.querySelector(".buy");
nameEl.textContent = preset.name;
// Cap the initial width so it never overflows a narrow screen.
var maxAvail = Math.min(preset.max, stage.clientWidth || 900);
var startW = clamp(preset.width, preset.min, maxAvail);
node.style.width = startW + "px";
function report() {
// Read the real rendered width of the container context.
var cw = Math.round(body.clientWidth);
var full = Math.round(node.getBoundingClientRect().width);
wNum.textContent = full;
cwNum.textContent = cw;
var name = supportsCQ ? layoutFor(cw) : "stacked";
layoutEl.textContent = name;
}
// ResizeObserver keeps the readout in sync with whatever CSS does.
if (typeof ResizeObserver !== "undefined") {
var ro = new ResizeObserver(report);
ro.observe(body);
}
report();
// ---- Pointer drag to resize ----
var dragging = false;
var startX = 0;
var startWidth = 0;
function curMax() {
// Don't let a panel grow wider than the stage.
var stageW = stage.clientWidth || preset.max;
return Math.min(preset.max, stageW);
}
function onPointerDown(e) {
dragging = true;
startX = e.clientX;
startWidth = node.getBoundingClientRect().width;
node.classList.add("is-dragging");
handle.setPointerCapture && handle.setPointerCapture(e.pointerId);
e.preventDefault();
}
function onPointerMove(e) {
if (!dragging) return;
var next = clamp(startWidth + (e.clientX - startX), preset.min, curMax());
node.style.width = next + "px";
report();
}
function onPointerUp(e) {
if (!dragging) return;
dragging = false;
node.classList.remove("is-dragging");
if (handle.releasePointerCapture && e.pointerId != null) {
try {
handle.releasePointerCapture(e.pointerId);
} catch (_) {}
}
}
handle.addEventListener("pointerdown", onPointerDown);
handle.addEventListener("pointermove", onPointerMove);
handle.addEventListener("pointerup", onPointerUp);
handle.addEventListener("pointercancel", onPointerUp);
// ---- Keyboard resize (arrow keys) ----
handle.addEventListener("keydown", function (e) {
var step = e.shiftKey ? 40 : 12;
var cur = node.getBoundingClientRect().width;
var next = null;
if (e.key === "ArrowLeft") next = cur - step;
else if (e.key === "ArrowRight") next = cur + step;
else if (e.key === "Home") next = preset.min;
else if (e.key === "End") next = curMax();
if (next == null) return;
e.preventDefault();
node.style.width = clamp(next, preset.min, curMax()) + "px";
report();
});
// ---- Add to cart micro-interaction ----
buy.addEventListener("click", function () {
if (buy.classList.contains("is-added")) return;
var original = buy.textContent;
buy.classList.add("is-added");
buy.textContent = "Added ✓";
setTimeout(function () {
buy.classList.remove("is-added");
buy.textContent = original;
}, 1400);
});
return { node: node, report: report };
}
var panels = PRESETS.map(function (p) {
var built = buildPanel(p);
stage.appendChild(built.node);
return built;
});
// Keep widths sane if the window shrinks below a panel width.
var raf = null;
window.addEventListener("resize", function () {
if (raf) cancelAnimationFrame(raf);
raf = requestAnimationFrame(function () {
var stageW = stage.clientWidth;
panels.forEach(function (p) {
var w = p.node.getBoundingClientRect().width;
if (w > stageW) {
p.node.style.width = stageW + "px";
}
p.report();
});
});
});
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Container Query Cards</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="page">
<header class="page__head">
<p class="eyebrow">CSS · @container</p>
<h1>Container Query Cards</h1>
<p class="lede">
One card, three parents. Each card restyles from the width of its own
container — not the viewport. Drag any handle to cross a
breakpoint and watch the layout snap.
</p>
<div class="legend" role="list" aria-label="Layout breakpoints">
<span class="legend__item" role="listitem">
<i class="dot dot--compact"></i> compact · < 240px
</span>
<span class="legend__item" role="listitem">
<i class="dot dot--stacked"></i> stacked · 240–479px
</span>
<span class="legend__item" role="listitem">
<i class="dot dot--wide"></i> horizontal · ≥ 480px
</span>
</div>
<p class="nosupport" hidden>
Heads up: your browser doesn’t support CSS container queries, so
the cards fall back to a stacked layout. The resize handles still work.
</p>
</header>
<section class="stage" aria-label="Resizable containers">
<!-- Panel template is cloned by script.js -->
</section>
<footer class="page__foot">
<span class="api">
Powered by <code>container-type: inline-size</code> +
<code>@container</code>, with a <code>ResizeObserver</code> readout.
</span>
</footer>
</main>
<template id="panel-tpl">
<div class="panel" data-preset="">
<div class="panel__bar">
<span class="panel__name"></span>
<span class="panel__w"><span class="panel__w-num">0</span>px</span>
</div>
<div class="panel__body">
<article class="card" tabindex="0" aria-label="Signal Synth Kit product card">
<div class="card__media" aria-hidden="true">
<span class="card__badge">
<b class="card__layout">stacked</b>
<span class="card__cw"><span class="card__cw-num">0</span>px</span>
</span>
<div class="card__art"></div>
</div>
<div class="card__body">
<div class="card__eyebrow">
<span class="tag">New drop</span>
<span class="rating" aria-label="Rated 4.8 out of 5">
★ 4.8
</span>
</div>
<h2 class="card__title">Signal Synth Kit</h2>
<p class="card__desc">
Modular waveshaper with 12 macro voices, live morph pads, and a
zero-latency monitor path built for the studio.
</p>
<div class="card__meta">
<span class="chip">USB-C</span>
<span class="chip">MIDI 2.0</span>
<span class="chip">CV out</span>
</div>
<div class="card__foot">
<span class="price">
<span class="price__now">$249</span>
<span class="price__was">$319</span>
</span>
<button class="buy" type="button">Add to cart</button>
</div>
</div>
</article>
</div>
<button
class="panel__handle"
type="button"
aria-label="Resize container. Use left and right arrow keys."
>
<span class="panel__grip"></span>
</button>
</div>
</template>
<script src="script.js"></script>
</body>
</html>Container Query Cards
The same card markup rendered three times, each inside a container of a different width. Instead of reacting to the browser viewport, every card measures its own parent using the CSS @container rule paired with container-type: inline-size. Below roughly 240px it collapses to a compact row with just a thumbnail and title, around 340px it becomes a stacked layout with the image on top, and past 480px it snaps to a horizontal editorial layout with the media beside the copy.
Each panel has a draggable resize handle so you can watch the breakpoints fire in real time. A live badge in the corner of every card reports the current container width and the active layout name, and an inline width readout updates as you drag. The point is that these components are genuinely context-independent: drop the exact same card into a sidebar, a modal, or a full-bleed hero and it adapts to the space it is given rather than to the size of the window.
The demo relies only on container-type, container-name, and @container queries, with a @supports fallback that keeps the cards perfectly usable (stacked layout) in engines that lack container query support. Drag interactions are pointer-based, keyboard accessible via the arrow keys on each handle, and the reported metrics are driven by a ResizeObserver so the labels stay in sync with whatever the CSS decides.