Responsive Components for Layouts You'll Never See

Want more of these in your Google results?
Imagine you need to build a product card component that shows a cover image, category, name, price, and an “Add to cart” button.
You design it so that when there is enough space, the layout uses two columns: the cover image on the left and everything else on the right.
You make this work by using @media queries to check the browser window’s width.
You place the component inside the main content container. When the window is 1440 pixels wide, the card splits into two columns as expected. On mobile, everything stacks in a single column. At first, everything looks like it’s working well. But then someone tries to use the same component in a narrow 200px sidebar. Because the code still checks the full browser window, it forces the split layout, and all the content gets squeezed together. Even though it looks broken, the CSS is technically doing exactly what you told it to do.
The card uses a grid layout. By default, the card uses one column. When the window is at least 25rem wide, it switches to two columns:
css
.card {
display: grid;
gap: 0.75rem;
}
@media ( width >= 25rem ) {
.card {
grid-template-columns: 1fr 3fr;
}
}The width in that @media rule refers to the browser window’s width.
@media queries can only check the browser window, the output medium, and the user’s settings, so you can’t use them to test the card itself.
The .card rule inside the block only changes styles when the query matches.
So when the window is 1440 pixels wide, the query matches and every card on the page switches to two columns, even the one in a 200-pixel sidebar.
Using the window width as a stand-in for the card’s width works only if the card appears in one column on one page. But once others reuse the card, this approach breaks. A card in a 200-pixel sidebar can be in a 1440-pixel window, and a full-width card on a tablet might have more space than a card in a desktop grid cell.
You can see how this works in the example below.
// index.ts
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js';
/**
* Formats a price for display, dropping the decimals when the amount is whole.
*/
const money = ( amount: number ): string =>
`$${ Number.isInteger( amount ) ? amount : amount.toFixed( 2 ) }`;
/**
* The naive product card, kept exactly as most of these get written the first time.
*
* Its layout hangs on a `@media` query, so the test is the browser window's width and the result is whether this card splits.
* Those two only match while the card takes up most of the window, so this card goes wrong the moment a page puts it in a sidebar.
*
* It exists to fail. The fixed version is `product-card`.
*
* @element viewport-card
*/
@customElement( 'viewport-card' )
export class ViewportCard extends LitElement {
/** The product name, rendered as the card's heading and used as its accessible name. */
@property( { type: String } )
name = '';
/** The category label shown above the name. */
@property( { type: String } )
category = '';
/** The price before any discount, in whole currency units. */
@property( { type: Number } )
price = 0;
/** The percentage off the list price, or 0 when the product isn't discounted. */
@property( { type: Number, attribute: 'discount-percent' } )
discountPercent = 0;
/** What the shopper actually pays, derived from the list price and the discount. */
private get finalPrice(): number {
return this.price * ( 1 - this.discountPercent / 100 );
}
static styles = css`
/* The page's global reset never pierces the shadow root, so the component carries its own box-sizing floor. */
*,
*::before,
*::after {
box-sizing: border-box;
}
:host {
display: block;
color: var( --ink, #17171a );
font-family: system-ui, sans-serif;
}
.card {
display: grid;
gap: 0.75rem;
padding: 0.75rem;
background: var( --surface, #ffffff );
border: 1px solid var( --line, #e7e6e2 );
border-radius: 0.875rem;
}
.cover {
width: 100%;
min-width: 0;
aspect-ratio: 5 / 2;
border-radius: 0.625rem;
background: linear-gradient( 135deg, var( --accent, #2257e6 ), var( --accent-2, #5b8bff ) );
}
.body {
display: grid;
gap: 0.5rem;
min-width: 0;
align-content: start;
}
.category {
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var( --accent, #2257e6 );
}
.name {
margin: 0;
font-size: 1rem;
line-height: 1.3;
}
.footer {
display: grid;
gap: 0.5rem;
}
/* The three price parts read as one sentence, so they stay on a single line. */
.price {
display: flex;
gap: 0.5rem;
align-items: baseline;
min-width: 0;
font-size: 1rem;
/* The now price, the old price, and the discount read as one price, so the row never breaks. */
white-space: nowrap;
}
.price-now {
font-weight: 600;
}
.price-was {
color: var( --ink-soft, #56565c );
font-size: 0.75rem;
}
.price-off {
font-size: 0.75rem;
font-weight: 600;
color: var( --accent, #2257e6 );
}
.buy {
justify-self: start;
padding: 0.375rem 0.75rem;
font: inherit;
font-size: 0.75rem;
color: var( --surface, #ffffff );
background: var( --accent, #2257e6 );
border: 1px solid var( --accent, #2257e6 );
border-radius: 999px;
cursor: pointer;
}
/* This is the bug. The test is the window's width, and the result is this card's layout. */
@media ( width >= 25rem ) {
.card {
grid-template-columns: 1fr 3fr;
}
.cover {
aspect-ratio: 1;
}
}
`;
render() {
return html`
<article class="card" aria-labelledby=${this.name ? 'name' : nothing}>
<div class="cover" role="presentation"></div>
<div class="body">
${ this.category ? html`<span class="category">${this.category}</span>` : null }
${ this.name ? html`<h3 class="name" id="name">${this.name}</h3>` : null }
<div class="footer">
<span class="price">
<span class="price-now">${money( this.finalPrice )}</span>
${ this.discountPercent > 0
? html`
<s class="price-was">${money( this.price )}</s>
<span class="price-off">${this.discountPercent}% off</span>
`
: null }
</span>
<button class="buy" type="button">Add to cart</button>
</div>
</div>
</article>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'viewport-card': ViewportCard;
}
}Use a container query instead
You can fix this with container queries. A container query is a CSS rule that checks the size of an element on the page instead of the browser window. This way, you can see how much space the card really has and adjust its layout as needed. You choose which element to check by declaring it as a container.
Container queries don’t replace media queries. Before you replace every @media with a @container, make sure you understand what each one is meant for.
Use media queries to test the environment the page runs in, and use container queries to check how much space your component has.
The third input is intent (what this specific instance is meant to represent).
Size doesn’t affect intent, so you set intent through your component’s API.
Most responsive bugs happen when you send one of these three inputs to the wrong place.
| Input | What changed | Mechanism | Who owns what |
|---|---|---|---|
| Environment | The user’s preferences, the device, the output medium | @media | The browser reports it, the component responds |
| Allocated space | The box the consumer’s layout gave the host | @container on the host | The consumer allocates, the component adapts |
| Product intent | What this instance means, featured, compact, read-only | A public attribute or property | The consumer states it, the component renders it |
The first row focuses on the person using your site and the device they have.
I covered motion, contrast, forced colors, and pointer capability in detail in The four user-preference media queries your CSS should honor.
All of that still matters here, and those remain as @media queries even if you move all layout decisions elsewhere.
The second row is the one you’re going to build in this article. The page using your card sets its width. The card’s host element makes that width available for CSS to test, and the card’s internal rules use it.
You can rely on container queries today.
They’ve been Baseline Widely available since August 2025, shipped in all major browsers since February 2023, and caniuse shows that over 90% of users have support.
If a browser doesn’t support @container, it simply ignores the rule. Those users will see the single-column layout you’ll build next, which still works well.
Why the media query gives the wrong answer
The window’s width and the card’s width only match when the card fills most of the window. A reusable component can’t rely on that always being true.
Media queries have another limitation that’s even more important for reusable components. A media query applies to the entire document, so every card gets the same result at the same time, no matter how much space each one actually has. If you have two cards on a page, one in the main column and one in the sidebar, they can’t have different layouts.
During code review, check if the browser window’s width actually controls how much space the component receives. For elements like your app’s navigation bar, the window width usually does matter, so a viewport breakpoint works well. But for components that others might use in layouts you haven’t seen, the window width doesn’t matter. The component needs to measure its own size.
Building the narrow layout first
You’ll build this using two files: put the public types in types.ts and the component code in index.ts.
Start by creating the shell: the element itself, a short documentation block, and the tag-map entry. This lets you use document.querySelector('product-card') and get the correct type without casting.
The demos below include the complete file with full documentation. What you’re building here is just the basic structure.
index.ts
// index.ts
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import type { AddToCartDetail } from './types';
/**
* A product card that adapts to the width its page gives it, rather than to the width of the browser window.
*
* @element product-card
* @fires add-to-cart - Fired when the Add to cart button is pressed, carrying an `AddToCartDetail` with the product id.
*/
@customElement( 'product-card' )
export class ProductCard extends LitElement {
}
declare global {
interface HTMLElementTagNameMap {
'product-card': ProductCard;
}
}Next, add the content for the card: a name, a category, and the two numbers used for the price line.
ts
/**
* The product name, rendered as the card's heading and used as its accessible name.
* When it's empty, the heading and the `aria-labelledby` wiring are both omitted, so the card never exposes an empty heading as its name.
*/
@property( { type: String } )
name = '';
/**
* The category label shown above the name, for example `Keyboards` or `Audio`.
* Rendered only when set, so a card without a category carries no empty row.
*/
@property( { type: String } )
category = '';
/**
* The price before any discount, in whole currency units.
* It stays visible in every layout; a narrow card moves it rather than hiding it.
*/
@property( { type: Number } )
price = 0;
/**
* The percentage off the list price, or 0 when the product isn't discounted.
* The card derives what the shopper pays from this rather than being handed two prices that can disagree.
*/
@property( { type: Number, attribute: 'discount-percent' } )
discountPercent = 0;There’s also a productId string, which is sent back in the add-to-cart event. This lets a listing page react without needing to look it up in the DOM.
The card receives both the list price and the discount, then calculates the final price itself. This way, the two numbers always match.
ts
/**
* Formats a price for display, dropping the decimals when the amount is whole.
*/
const money = ( amount: number ): string =>
`$${ Number.isInteger( amount ) ? amount : amount.toFixed( 2 ) }`;
/**
* What the shopper actually pays, derived from the list price and the discount.
*/
private get finalPrice(): number {
return this.price * ( 1 - this.discountPercent / 100 );
}Now, add styles for the narrowest version: place the cover image on top, the text below it, and the footer at the bottom.
Any CSS reset on your page won’t affect the shadow root, so you need to set box-sizing inside the component.
The footer always shows the price and the button, even when space is tight. If you run out of room, rearrange the layout instead of removing features.
index.ts
/* index.ts, inside static styles */
/* The page's global reset never pierces the shadow root, so the component carries its own box-sizing floor. */
*,
*::before,
*::after {
box-sizing: border-box;
}
/* The base composition, complete before any query matches, with the cover on top, the text under it, and the footer stacked. */
.card {
display: grid;
gap: 0.75rem;
padding: 0.75rem;
background: #ffffff;
border: 1px solid #e7e6e2;
border-radius: 0.875rem;
}
/* The cover is a gradient stand-in for the product image, so the component ships no binary asset. */
/* Its proportions are a composition decision, and the container queries below change them at wider widths. */
.cover {
width: 100%;
min-width: 0;
aspect-ratio: 5 / 2;
border-radius: 0.625rem;
background: linear-gradient( 135deg, #2257e6, #5b8bff );
}
/* The stacked footer puts the price above the button, for the narrowest cards. */
/* Nothing hides here, because a narrow card moves the button instead of removing it. */
.footer {
display: grid;
gap: 0.5rem;
}
/* The button is sized by its label, so it sits at the start of the row rather than stretching across it. */
.buy {
justify-self: start;
}Wrap all content except the cover image in a single .body element.
You’ll need this later when the cover moves next to the text. The browser can only treat all the text as one column if your markup groups it together.
ts
/**
* Renders the cover, the category row, the name, and a footer whose price and button stay visible at every width.
*
* The category row renders only when there is a category or a featured flag to show, so a plain card carries no empty spacer.
*/
render() {
return html`
<article class="card" aria-labelledby=${this.name ? 'name' : nothing}>
<div class="cover" role="presentation"></div>
<div class="body">
${ this.category || this.featured
? html`
<div class="category-row">
${ this.category ? html`<span class="category">${this.category}</span>` : null }
${ this.featured ? html`<span class="flag">Featured</span>` : null }
</div>
`
: null }
${ this.name ? html`<h3 class="name" id="name">${this.name}</h3>` : null }
<div class="footer">
<span class="price">
<span class="price-now">${money( this.finalPrice )}</span>
${ this.discountPercent > 0
? html`
<s class="price-was">${money( this.price )}</s>
<span class="price-off">${this.discountPercent}% off</span>
`
: null }
</span>
<button class="buy" type="button" @click=${this.handleAddToCart}>Add to cart</button>
</div>
</div>
</article>
`;
}The person using your card decides where it fits in the page outline, so the hardcoded <h3> is just a shortcut here.
A real component would let users choose the heading level, but here it’s fixed so we can focus on layout.
It’s easy to think of the narrow version as an afterthought, just a squeezed version of the main layout. Browsers that don’t support container queries will always use the narrow layout. Since your card might often appear in narrow spaces, give this version as much attention as the wide one.
Making the host the container
With just one line of code, you can make the card pay attention to its own size instead of the window.
index.ts
/* index.ts, inside static styles */
/* The host is the query container, so the consumer sizes this box through normal layout and the private rules below test it instead of the viewport. */
/* The name pins each rule to this box, so a wrapper added inside the shadow root later cannot capture the query. */
:host {
display: block;
container: product-card / inline-size;
color: #17171a;
font-family: system-ui, sans-serif;
}The container shorthand puts the name first, then the type after a slash. This is the same as writing container-name: product-card and container-type: inline-size.
You add it to :host because that’s the element the page controls for sizing.
Whether it’s a grid track, a fixed-width sidebar, or a padded dialog, each one sets a width on the custom element. The card then uses exactly that space.
Rules inside your shadow root can check the container because container queries use the flat tree. The spec says everything inside the shadow root counts as part of the container’s flat tree descendants.
This means the page sets a width, your .card rules check it, and neither side depends on the other’s CSS.
Use inline-size instead of size here, because the card only needs one axis and including both is less efficient.
inline-size refers to the size along the inline axis. For this card, which uses horizontal writing, that means the width.
If you use vertical writing mode, the inline axis runs vertically, so the same property will measure the card’s height instead.
To fully support this, you would need to use logical properties throughout the layout. However, this card uses physical properties instead.
It’s also a good idea to give the container a name.
If you use @container ( inline-size >= 25rem ) without a name, it matches the nearest ancestor container that can respond to it.
A container that the page wraps around your card isn’t that ancestor, because the host is closer.
The risk is that a wrapper you add inside the shadow root later could end up between the host and the rule, taking the query with it.
A query for a feature the host doesn’t contain will skip it too. For example, a rule that tests height never matches an inline-size container, so the nearest match is further up.
By naming the container and querying product-card, every rule inside the component refers to the box the component creates.
You could leave out that declaration and rely on the page to provide a container. Some libraries take this approach. But this creates an unwritten requirement for your component. If someone using it doesn’t know, the query might match the wrong ancestor or not match anything. If you want the page to handle the container, make sure to document that requirement.
Finding the card’s own breakpoints
With the container in place, you can now use a @container query instead of the old @media query.
It’s tempting to just reuse your existing breakpoint or pick the tablet size from your design system, but both options use thresholds based on something else.
To find the right breakpoints for the card, shrink it step by step until the layout starts to look off, just like you would when checking a table’s minimum width. There are two points where the layout breaks, each at a different width. When the layout is about 19rem wide, the stacked footer uses more vertical space than needed. At this size, the price and button fit side by side in one row. This marks your first breakpoint. The next breakpoint is at 25rem. At this width, the banner cover is taller than the text below it, and the 3fr column from the split still has enough space for the one-row footer you created earlier.
These breakpoints are based on this card’s content, type size, and cover shape. That means the component should define them, not a global breakpoint file.
Set the thresholds in rem, and use rem for the card’s type and spacing too. The query measures text, which gets bigger if someone increases the browser’s font size. For this card, the one-row footer needs 17.84rem at a 16px root, 17.46rem at 20px, and 17.29rem at 24px. So, one rem-based threshold works for all three. Both units need to match. If you use rem for the type but keep the threshold in px, the price gets cut off by 37px when the root is 20px. The opposite is just as problematic. A rem threshold changes the switch for type set in px, even though its size stays the same.
index.ts
/* index.ts, inside static styles */
/* Allocated space: from 19rem the price and the button share one row, found by measuring what they need. */
/* That measurement peaks at 17.84rem, at the default font size, and drops slightly in rem terms as the reader's font size goes up. */
@container product-card ( inline-size >= 19rem ) {
.footer {
grid-template-columns: 1fr auto;
align-items: center;
}
}
/* Allocated space: from 25rem the cover moves beside the text, found where the 3fr text column is still wide enough to keep the footer on one row. */
/* The cover turns square to suit a side column. */
@container product-card ( inline-size >= 25rem ) {
.card {
grid-template-columns: 1fr 3fr;
}
.cover {
aspect-ratio: 1;
}
}The grid and cover behavior stay the same: when the card is narrow, the cover is a wide banner at the top. When it’s wide, the cover becomes a square thumbnail on the side.
Now, the query checks the card’s size instead of the window’s size.
Since this card changes layout at specific breakpoints, it doesn’t need container query length units like cqi, which scale smoothly with the container instead of switching at set points.
Below, you can see the same page with the fixed card. Now, the layout responds to the card’s placement, not the window size.
// index.ts
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import type { AddToCartDetail } from './types';
/**
* Formats a price for display, dropping the decimals when the amount is whole.
*/
const money = ( amount: number ): string =>
`$${ Number.isInteger( amount ) ? amount : amount.toFixed( 2 ) }`;
/**
* A product card that measures the width its page gave it, instead of the width of the browser window.
*
* The host declares a named inline-size query container, so the rules inside the shadow root test the card's own box.
* Two thresholds come out of that measurement, a one-row price and button from 19rem and a cover beside the text from 25rem, both found by narrowing this card until its own content stopped working.
* They are in rem because what they measure is text, so a reader who raises the browser's font size moves both thresholds with it.
* Settings that describe the person stay in media queries (`hover`, `prefers-reduced-motion`), and what the card means arrives through the API (`featured`), where no width can reach it.
*
* Switching on `container-type: inline-size` removes the host's intrinsic width, so a page that puts the card in a flex row, a float, or any other shrink-to-fit context has to supply the width from the layout (`flex: 1` in a row, a stated `inline-size` on a float); normal block flow sizes it for free.
* Below 19rem the footer goes back to stacking, so a grid of these cards should floor its tracks there.
*
* @element product-card
* @fires add-to-cart - Fired when the Add to cart button is pressed, carrying an `AddToCartDetail` with the product id.
*/
@customElement( 'product-card' )
export class ProductCard extends LitElement {
/**
* The product name, rendered as the card's heading and used as its accessible name.
* When it's empty, the heading and the `aria-labelledby` wiring are both omitted, so the card never exposes an empty heading as its name.
*/
@property( { type: String } )
name = '';
/**
* The category label shown above the name, for example `Keyboards` or `Audio`.
* Rendered only when set, so a card without a category carries no empty row.
*/
@property( { type: String } )
category = '';
/**
* The price before any discount, in whole currency units.
* It stays visible in every layout; a narrow card moves it rather than hiding it.
*/
@property( { type: Number } )
price = 0;
/**
* The percentage off the list price, or 0 when the product isn't discounted.
* The card derives what the shopper pays from this rather than being handed two prices that can disagree.
*/
@property( { type: Number, attribute: 'discount-percent' } )
discountPercent = 0;
/**
* A stable identifier for the product, echoed back in the `add-to-cart` event.
* The `attribute` is set explicitly because Lit lowercases the default observed attribute, so `product-id` would otherwise never reach this property.
*/
@property( { type: String, attribute: 'product-id' } )
productId = '';
/**
* Whether this instance is a featured product.
*
* This is the card's meaning, so it arrives through the API and never from a width.
* A featured card in a narrow sidebar is still featured, and an ordinary card in a wide grid is still ordinary.
*/
@property( { type: Boolean, reflect: true } )
featured = false;
/**
* What the shopper actually pays, derived from the list price and the discount.
*/
private get finalPrice(): number {
return this.price * ( 1 - this.discountPercent / 100 );
}
static styles = css`
/* The page's global reset never pierces the shadow root, so the component carries its own box-sizing floor. */
*,
*::before,
*::after {
box-sizing: border-box;
}
:host {
/* One declaration names the container and measures its inline size. */
container: product-card / inline-size;
display: block;
color: var( --ink, #17171a );
font-family: system-ui, sans-serif;
}
.card {
display: grid;
gap: 0.75rem;
padding: 0.75rem;
background: var( --surface, #ffffff );
border: 1px solid var( --line, #e7e6e2 );
border-radius: 0.875rem;
}
.cover {
width: 100%;
min-width: 0;
aspect-ratio: 5 / 2;
border-radius: 0.625rem;
background: linear-gradient( 135deg, var( --accent, #2257e6 ), var( --accent-2, #5b8bff ) );
}
.body {
display: grid;
gap: 0.5rem;
min-width: 0;
align-content: start;
}
.category {
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var( --accent, #2257e6 );
}
/* The category row holds a label and, when the product is featured, a badge beside it. */
.category-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
min-width: 0;
}
/* The badge has to read as a badge rather than as more category text, so it carries its own shape and fill. */
.flag {
padding: 0.125rem 0.375rem;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
color: var( --surface, #ffffff );
background: var( --accent, #2257e6 );
border-radius: 999px;
}
.name {
margin: 0;
font-size: 1rem;
line-height: 1.3;
}
.footer {
display: grid;
gap: 0.5rem;
}
/* The three price parts read as one sentence, so they stay on a single line. */
.price {
display: flex;
gap: 0.5rem;
align-items: baseline;
min-width: 0;
font-size: 1rem;
/* The now price, the old price, and the discount read as one price, so the row never breaks. */
white-space: nowrap;
}
.price-now {
font-weight: 600;
}
.price-was {
color: var( --ink-soft, #56565c );
font-size: 0.75rem;
}
.price-off {
font-size: 0.75rem;
font-weight: 600;
color: var( --accent, #2257e6 );
}
.buy {
justify-self: start;
padding: 0.375rem 0.75rem;
font: inherit;
font-size: 0.75rem;
color: var( --surface, #ffffff );
background: var( --accent, #2257e6 );
border: 1px solid var( --accent, #2257e6 );
border-radius: 999px;
cursor: pointer;
}
.buy:focus-visible {
outline: 2px solid var( --ink, #17171a );
outline-offset: 2px;
}
/* Allocated space: from 19rem the price and the button share one row, found by measuring what they need. */
/* That measurement peaks at 17.84rem, at the default font size, and drops slightly in rem terms as the reader's font size goes up. */
@container product-card ( inline-size >= 19rem ) {
.footer {
grid-template-columns: 1fr auto;
align-items: center;
}
}
/* Allocated space: from 25rem the cover moves beside the text, found where the 3fr text column is still wide enough to keep the footer on one row. */
/* The cover turns square to suit a side column. */
@container product-card ( inline-size >= 25rem ) {
.card {
grid-template-columns: 1fr 3fr;
}
.cover {
aspect-ratio: 1;
}
}
/* Environment: hover is a capability of the pointer, not a measurement of the card, so it stays a media query. */
@media ( hover: hover ) {
.buy:hover {
background: var( --accent-2, #5b8bff );
}
}
/* Environment: the lift is motion, so it exists only for people who haven't asked for reduced motion. */
@media ( prefers-reduced-motion: no-preference ) {
.buy {
transition: translate 120ms ease, background-color 120ms ease;
}
}
@media ( hover: hover ) and ( prefers-reduced-motion: no-preference ) {
.buy:hover {
translate: 0 -1px;
}
}
`;
/**
* Renders the cover, the category row, the name, and a footer whose price and button stay visible at every width.
*
* The category row renders only when there is a category or a featured flag to show, so a plain card carries no empty spacer.
*/
render() {
return html`
<article class="card" aria-labelledby=${this.name ? 'name' : nothing}>
<div class="cover" role="presentation"></div>
<div class="body">
${ this.category || this.featured
? html`
<div class="category-row">
${ this.category ? html`<span class="category">${this.category}</span>` : null }
${ this.featured ? html`<span class="flag">Featured</span>` : null }
</div>
`
: null }
${ this.name ? html`<h3 class="name" id="name">${this.name}</h3>` : null }
<div class="footer">
<span class="price">
<span class="price-now">${money( this.finalPrice )}</span>
${ this.discountPercent > 0
? html`
<s class="price-was">${money( this.price )}</s>
<span class="price-off">${this.discountPercent}% off</span>
`
: null }
</span>
<button class="buy" type="button" @click=${this.handleAddToCart}>Add to cart</button>
</div>
</div>
</article>
`;
}
/**
* Dispatches `add-to-cart` with the product id when the button is pressed.
*
* It is `composed` so the event crosses the shadow boundary, and `bubbles` so a listener on the host or an ancestor receives it.
*/
private handleAddToCart = (): void => {
this.dispatchEvent(
new CustomEvent<AddToCartDetail>( 'add-to-cart', {
detail: { productId: this.productId },
bubbles: true,
composed: true,
} ),
);
};
}
declare global {
interface HTMLElementTagNameMap {
'product-card': ProductCard;
}
}In the demo below, you can drag a card through both breakpoints. The translated-name switch adds a product name like those from localization, showing why the layout only splits at 25rem. If the name on your card gets cramped before the layout changes, your breakpoint doesn’t fit your content, no matter what your token file says. The Featured switch will be important in the next section. When it’s on, the slider moves through both breakpoints, but the badge stays the same.
// index.ts
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import type { AddToCartDetail } from './types';
/**
* Formats a price for display, dropping the decimals when the amount is whole.
*/
const money = ( amount: number ): string =>
`$${ Number.isInteger( amount ) ? amount : amount.toFixed( 2 ) }`;
/**
* A product card that measures the width its page gave it, instead of the width of the browser window.
*
* The host declares a named inline-size query container, so the rules inside the shadow root test the card's own box.
* Two thresholds come out of that measurement, a one-row price and button from 19rem and a cover beside the text from 25rem, both found by narrowing this card until its own content stopped working.
* They are in rem because what they measure is text, so a reader who raises the browser's font size moves both thresholds with it.
* Settings that describe the person stay in media queries (`hover`, `prefers-reduced-motion`), and what the card means arrives through the API (`featured`), where no width can reach it.
*
* Switching on `container-type: inline-size` removes the host's intrinsic width, so a page that puts the card in a flex row, a float, or any other shrink-to-fit context has to supply the width from the layout (`flex: 1` in a row, a stated `inline-size` on a float); normal block flow sizes it for free.
* Below 19rem the footer goes back to stacking, so a grid of these cards should floor its tracks there.
*
* @element product-card
* @fires add-to-cart - Fired when the Add to cart button is pressed, carrying an `AddToCartDetail` with the product id.
*/
@customElement( 'product-card' )
export class ProductCard extends LitElement {
/**
* The product name, rendered as the card's heading and used as its accessible name.
* When it's empty, the heading and the `aria-labelledby` wiring are both omitted, so the card never exposes an empty heading as its name.
*/
@property( { type: String } )
name = '';
/**
* The category label shown above the name, for example `Keyboards` or `Audio`.
* Rendered only when set, so a card without a category carries no empty row.
*/
@property( { type: String } )
category = '';
/**
* The price before any discount, in whole currency units.
* It stays visible in every layout; a narrow card moves it rather than hiding it.
*/
@property( { type: Number } )
price = 0;
/**
* The percentage off the list price, or 0 when the product isn't discounted.
* The card derives what the shopper pays from this rather than being handed two prices that can disagree.
*/
@property( { type: Number, attribute: 'discount-percent' } )
discountPercent = 0;
/**
* A stable identifier for the product, echoed back in the `add-to-cart` event.
* The `attribute` is set explicitly because Lit lowercases the default observed attribute, so `product-id` would otherwise never reach this property.
*/
@property( { type: String, attribute: 'product-id' } )
productId = '';
/**
* Whether this instance is a featured product.
*
* This is the card's meaning, so it arrives through the API and never from a width.
* A featured card in a narrow sidebar is still featured, and an ordinary card in a wide grid is still ordinary.
*/
@property( { type: Boolean, reflect: true } )
featured = false;
/**
* What the shopper actually pays, derived from the list price and the discount.
*/
private get finalPrice(): number {
return this.price * ( 1 - this.discountPercent / 100 );
}
static styles = css`
/* The page's global reset never pierces the shadow root, so the component carries its own box-sizing floor. */
*,
*::before,
*::after {
box-sizing: border-box;
}
:host {
/* One declaration names the container and measures its inline size. */
container: product-card / inline-size;
display: block;
color: var( --ink, #17171a );
font-family: system-ui, sans-serif;
}
.card {
display: grid;
gap: 0.75rem;
padding: 0.75rem;
background: var( --surface, #ffffff );
border: 1px solid var( --line, #e7e6e2 );
border-radius: 0.875rem;
}
.cover {
width: 100%;
min-width: 0;
aspect-ratio: 5 / 2;
border-radius: 0.625rem;
background: linear-gradient( 135deg, var( --accent, #2257e6 ), var( --accent-2, #5b8bff ) );
}
.body {
display: grid;
gap: 0.5rem;
min-width: 0;
align-content: start;
}
.category {
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var( --accent, #2257e6 );
}
/* The category row holds a label and, when the product is featured, a badge beside it. */
.category-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
min-width: 0;
}
/* The badge has to read as a badge rather than as more category text, so it carries its own shape and fill. */
.flag {
padding: 0.125rem 0.375rem;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
color: var( --surface, #ffffff );
background: var( --accent, #2257e6 );
border-radius: 999px;
}
.name {
margin: 0;
font-size: 1rem;
line-height: 1.3;
}
.footer {
display: grid;
gap: 0.5rem;
}
/* The three price parts read as one sentence, so they stay on a single line. */
.price {
display: flex;
gap: 0.5rem;
align-items: baseline;
min-width: 0;
font-size: 1rem;
/* The now price, the old price, and the discount read as one price, so the row never breaks. */
white-space: nowrap;
}
.price-now {
font-weight: 600;
}
.price-was {
color: var( --ink-soft, #56565c );
font-size: 0.75rem;
}
.price-off {
font-size: 0.75rem;
font-weight: 600;
color: var( --accent, #2257e6 );
}
.buy {
justify-self: start;
padding: 0.375rem 0.75rem;
font: inherit;
font-size: 0.75rem;
color: var( --surface, #ffffff );
background: var( --accent, #2257e6 );
border: 1px solid var( --accent, #2257e6 );
border-radius: 999px;
cursor: pointer;
}
.buy:focus-visible {
outline: 2px solid var( --ink, #17171a );
outline-offset: 2px;
}
/* Allocated space: from 19rem the price and the button share one row, found by measuring what they need. */
/* That measurement peaks at 17.84rem, at the default font size, and drops slightly in rem terms as the reader's font size goes up. */
@container product-card ( inline-size >= 19rem ) {
.footer {
grid-template-columns: 1fr auto;
align-items: center;
}
}
/* Allocated space: from 25rem the cover moves beside the text, found where the 3fr text column is still wide enough to keep the footer on one row. */
/* The cover turns square to suit a side column. */
@container product-card ( inline-size >= 25rem ) {
.card {
grid-template-columns: 1fr 3fr;
}
.cover {
aspect-ratio: 1;
}
}
/* Environment: hover is a capability of the pointer, not a measurement of the card, so it stays a media query. */
@media ( hover: hover ) {
.buy:hover {
background: var( --accent-2, #5b8bff );
}
}
/* Environment: the lift is motion, so it exists only for people who haven't asked for reduced motion. */
@media ( prefers-reduced-motion: no-preference ) {
.buy {
transition: translate 120ms ease, background-color 120ms ease;
}
}
@media ( hover: hover ) and ( prefers-reduced-motion: no-preference ) {
.buy:hover {
translate: 0 -1px;
}
}
`;
/**
* Renders the cover, the category row, the name, and a footer whose price and button stay visible at every width.
*
* The category row renders only when there is a category or a featured flag to show, so a plain card carries no empty spacer.
*/
render() {
return html`
<article class="card" aria-labelledby=${this.name ? 'name' : nothing}>
<div class="cover" role="presentation"></div>
<div class="body">
${ this.category || this.featured
? html`
<div class="category-row">
${ this.category ? html`<span class="category">${this.category}</span>` : null }
${ this.featured ? html`<span class="flag">Featured</span>` : null }
</div>
`
: null }
${ this.name ? html`<h3 class="name" id="name">${this.name}</h3>` : null }
<div class="footer">
<span class="price">
<span class="price-now">${money( this.finalPrice )}</span>
${ this.discountPercent > 0
? html`
<s class="price-was">${money( this.price )}</s>
<span class="price-off">${this.discountPercent}% off</span>
`
: null }
</span>
<button class="buy" type="button" @click=${this.handleAddToCart}>Add to cart</button>
</div>
</div>
</article>
`;
}
/**
* Dispatches `add-to-cart` with the product id when the button is pressed.
*
* It is `composed` so the event crosses the shadow boundary, and `bubbles` so a listener on the host or an ancestor receives it.
*/
private handleAddToCart = (): void => {
this.dispatchEvent(
new CustomEvent<AddToCartDetail>( 'add-to-cart', {
detail: { productId: this.productId },
bubbles: true,
composed: true,
} ),
);
};
}
declare global {
interface HTMLElementTagNameMap {
'product-card': ProductCard;
}
}What container-type costs you
When you set a container type, it changes how the browser sizes the box. This effect can show up in layouts you haven’t tested. Turning it on applies inline-size containment, which the spec defines as stopping the box’s width from depending on what’s inside it. This rule is there to prevent a sizing loop. Without this rule, the contents would set the container’s width, and the container’s width would set the contents. The browser avoids this loop by working out the box’s width without measuring the children. Now, the width must come from outside. MDN explains that if nothing outside provides a width, the element collapses.
Normally, a block element gets its width from its parent, even if it has no content. So, you usually won’t notice this issue.
The trouble comes from contexts where the browser sizes a box by measuring what’s in it.
Watch out for an auto-sized flex item, a float, an inline-block, an absolutely positioned element, width: fit-content, and min-content or max-content grid tracks.
In these cases, the layout needs an intrinsic width from the host. But containment removes it, so the card shrinks to just its padding.
The example below shows this problem and two ways you can fix it. The card stays the same in both the broken and fixed versions. Only the layout around it changes. The layout needs to set the width. In a flex row, you should set the flex basis to zero. This way, the width comes from the row’s free space, not from the contents.
results-rail.css
/* results-rail.css, the consumer's side of the contract */
.rail {
display: flex;
gap: 0.75rem;
}
/* The default basis is auto, which measures the contents containment removed. */
/* From zero, the width comes from the row's free space instead. */
.rail product-card {
flex: 1;
min-inline-size: 0;
}A float will always adjust its size to fit its contents, and it doesn’t use a basis value. Instead, the page sets the width as a percentage of the space where the float is placed, or uses the same unit as the rest of the layout.
// cq-collapse-trap-demo.ts
import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import '../library/product-card'; // registers <product-card>
/**
* The layout contexts a page can put the card in, each of which sizes the host differently.
*/
type ContextKey = 'block' | 'flex' | 'float';
/**
* Demo harness for the bill that comes with `container-type: inline-size`.
*
* Inline-size containment stops the host's width from depending on its contents, so any context that sizes a box by measuring what's in it has nothing left to measure.
* Block flow is unaffected, because it takes its width from the containing block, and the flex row and the float both collapse the card down to its own padding.
* The sizing switch applies the consumer's side of the contract, a `flex` that starts from a basis of zero in the row and a percentage `inline-size` on the float, so both widths come from the layout rather than from a number somebody picked.
*
* The card is byte-for-byte the same in every state here, and only the CSS the harness puts around it changes.
*
* @element demo-responsive-components-for-layouts-youll-never-see-cq-collapse-trap
*/
@customElement( 'demo-responsive-components-for-layouts-youll-never-see-cq-collapse-trap' )
export class CqCollapseTrapDemo extends LitElement {
/**
* The context the card currently sits in.
*/
@state()
private context: ContextKey = 'flex';
/**
* Whether the consumer's sizing rule is applied.
*/
@state()
private sized = false;
static styles = css`
*,
*::before,
*::after {
box-sizing: border-box;
}
:host {
display: block;
font-family: system-ui, sans-serif;
color: var( --ink, #17171a );
}
/* Controls sit across the top so the stage below gets the full width. */
.controls {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
padding: 12px;
border-bottom: 1px solid var( --line, #e7e6e2 );
font-size: 13px;
}
.switcher {
display: flex;
gap: 4px;
padding: 3px;
background: var( --tint, #eef2fe );
border-radius: 999px;
}
.switcher button {
padding: 5px 12px;
font: inherit;
font-size: 13px;
color: var( --ink, #17171a );
background: none;
border: none;
border-radius: 999px;
cursor: pointer;
}
.switcher button[aria-pressed='true'] {
color: var( --surface, #ffffff );
background: var( --accent, #2257e6 );
}
.toggle {
padding: 5px 12px;
font: inherit;
font-size: 13px;
color: var( --ink, #17171a );
background: var( --surface, #ffffff );
border: 1px solid var( --line, #e7e6e2 );
border-radius: 999px;
cursor: pointer;
}
.toggle[aria-pressed='true'] {
color: var( --surface, #ffffff );
background: var( --accent, #2257e6 );
border-color: var( --accent, #2257e6 );
}
.switcher button:focus-visible,
.toggle:focus-visible {
outline: 2px solid var( --accent, #2257e6 );
outline-offset: 2px;
}
.stage {
padding: 16px;
}
/* The dashed box is the consumer's layout, and the card inside it never changes. */
.host {
padding: 12px;
border: 1px dashed var( --line, #e7e6e2 );
border-radius: 16px;
}
.host.flex {
display: flex;
gap: 12px;
}
.host.flex.sized product-card {
flex: 1;
min-inline-size: 0;
}
.host.float product-card {
float: inline-start;
}
.host.float.sized product-card {
inline-size: 50%;
}
/* The float needs something to flow beside it, the way a real page would have text there. */
.host.float p {
margin: 0;
color: var( --ink-soft, #56565c );
font-size: 13px;
line-height: 1.55;
}
.host.float::after {
content: '';
display: block;
clear: both;
}
`;
render() {
const classes = [ 'host', this.context, this.sized ? 'sized' : '' ].filter( Boolean ).join( ' ' );
return html`
<div class="controls">
<span id="context-label">Layout context</span>
<div class="switcher" role="group" aria-labelledby="context-label">
${ ( [ [ 'block', 'Block flow' ], [ 'flex', 'Flex row' ], [ 'float', 'Float' ] ] as const ).map(
( [ key, label ] ) => html`
<button
type="button"
aria-pressed=${this.context === key}
@click=${() => { this.context = key; }}
>${label}</button>
`,
) }
</div>
<button
type="button"
class="toggle"
aria-pressed=${this.sized}
@click=${() => { this.sized = !this.sized; }}
>Consumer sizing rule</button>
</div>
<div class="stage">
<div class=${classes}>
<product-card
name="Magic Keyboard with Touch ID and Numeric Keypad"
category="Keyboards"
.price=${133.20}
.discountPercent=${25}
product-id="kb-100"
></product-card>
${ this.context === 'float'
? html`<p>A float shrink-to-fits its contents, and this paragraph is the page text that flows around it.</p>`
: null }
</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'demo-responsive-components-for-layouts-youll-never-see-cq-collapse-trap': CqCollapseTrapDemo;
}
}The host isn’t always the best place for the container because of containment. Most of the time, the host is the right place because its box is the main outside constraint. The main rule is to put the container on the box that acts as the constraint and be clear about the kind of containment you are setting up. If you want the host to size itself to its contents, you can move the container onto a wrapper inside the shadow root. But just moving it there will not restore the sizing. The wrapper still has the same containment and doesn’t add any intrinsic width to the host. A flex row or a float can still collapse the host just like before, only one level deeper. The host gets its width back only when something outside the wrapper gives it one, either through content that isn’t contained or by setting a size on the wrapper. This card doesn’t need that setup, so it uses a simpler approach and explains the page’s responsibility in its documentation.
What stays out of the container
You have two kinds of decisions left, and neither one should go anywhere near the container.
The first type belongs in @media queries, in the same stylesheet, where both at-rules can sit together without interfering.
The ability to hover the pointer and the user’s preference for less motion both depend on the person using the page. These factors are not connected to the card’s width.
index.ts
/* index.ts, inside static styles */
/* Environment: hover is a capability of the pointer, not a measurement of the card, so it stays a media query. */
@media ( hover: hover ) {
.buy:hover {
background: #5b8bff;
}
}
/* Environment: the lift is motion, so it exists only for people who haven't asked for reduced motion. */
@media ( prefers-reduced-motion: no-preference ) {
.buy {
transition: translate 120ms ease, background-color 120ms ease;
}
}
@media ( hover: hover ) and ( prefers-reduced-motion: no-preference ) {
.buy:hover {
translate: 0 -1px;
}
}I’ll stop there on the environment side, because the earlier article covers all of it, including why you must never put the Add to cart button behind hover alone.
The second type is set as a property on the component.
Once you get comfortable with container queries, it becomes easier to avoid that mistake.
You may notice that featured cards are usually wide, so you write @container product-card ( inline-size >= 25rem ) { .flag { display: block; } } and continue.
This approach fails both ways: a featured product in the sidebar loses its badge for no clear reason, and a regular product in a wide grid cell gets a badge it should not have.
Width only shows how much space the card has, and it isn’t connected to what the page says about the product.
index.ts
// index.ts, the intent surface and the one event
/**
* Whether this instance is a featured product.
*
* This is the card's meaning, so it arrives through the API and never from a width.
* A featured card in a narrow sidebar is still featured, and an ordinary card in a wide grid is still ordinary.
* It reflects so a page stylesheet can target `product-card[featured]` as an intentional extension point.
*/
@property( { type: Boolean, reflect: true } )
featured = false;
/**
* Dispatches `add-to-cart` with the product id when the button is pressed.
*
* It is `composed` so the event crosses the shadow boundary, and `bubbles` so a listener on the host or an ancestor receives it.
*/
private handleAddToCart = (): void => {
this.dispatchEvent(
new CustomEvent<AddToCartDetail>( 'add-to-cart', {
detail: { productId: this.productId },
bubbles: true,
composed: true,
} ),
);
};types.ts
// types.ts
/**
* The detail payload carried by the `add-to-cart` event of `product-card`.
*/
export interface AddToCartDetail {
/**
* The stable identifier of the product the shopper added.
* Echoed from the card's `product-id` attribute, so a listing page can react without going into the DOM for it.
*/
productId: string;
}If you sort some real decisions into two columns, you can see where the boundary is.
| Measured from the container, geometry | Stated through the API, intent |
|---|---|
| Stack or split the composition | Featured versus standard |
| Move the price onto one row | Editorial versus promotional |
| Tighten gaps and padding | Compact versus comfortable density |
| Change the cover’s proportions | Selectable versus read-only |
| Reflow actions without hiding them | Whether an optional capability shows |
This same boundary also prevents the repair pattern from the other direction.
If a page uses .sidebar product-card::part( card ) { grid-template-columns: 1fr; }, then every placement must understand the component’s internals. This means the component has already failed.
::part(), custom properties, and attributes are for customizations you choose to support, like a theme accent or density mode. This card doesn’t include any of those until a real user needs one.
What the consumer’s side looks like
You have now seen every part, and the code panels in the demos include the complete index.ts, so I will not repeat it here.
A consumer only needs to copy two files. All the responsive parts are the narrow layout, one container declaration, and two named @container rules.
You can use it without writing any special code.
On a results page, the card appears just like any other item.
Each track has a minimum size set to the card’s first breakpoint, 19rem. This way, every card in the grid has enough space to keep its footer from stacking.
This number comes from measuring the card, and it’s also listed in the component’s documentation.
Users should copy this value from the documentation, not from a general app-wide scale.
Wrapping it in min() makes sure the card fits inside a parent that’s narrower than 19rem, preventing overflow.
results-page.css
/* results-page.css, the consumer's whole responsive job */
.results {
display: grid;
gap: 1rem;
grid-template-columns: repeat( auto-fit, minmax( min( 100%, 19rem ), 1fr ) );
}If the page using it is also a Lit component, nothing changes. It sets intent through the attribute and listens for the event as it would for any other element.
The page stylesheet above cannot reach into the card’s shadow root, so it must include the grid in its own styles.
That’s the same boundary that made you set box-sizing inside the card earlier:
product-results.ts
// product-results.ts, a Lit page region that consumes <product-card>
import { LitElement, html, css } from 'lit';
import { customElement } from 'lit/decorators.js';
import './product-card'; // registers <product-card>
import type { AddToCartDetail } from './product-card/types';
@customElement( 'product-results' )
export class ProductResults extends LitElement {
/**
* The results grid lives in this shadow root because no page stylesheet can pierce it.
* The tracks allocate space for the cards; nothing here reaches into one.
*/
static styles = css`
.results {
display: grid;
gap: 1rem;
grid-template-columns: repeat( auto-fit, minmax( min( 100%, 19rem ), 1fr ) );
}
`;
/**
* Renders the results grid; the card's own container query handles its layout at whatever track width the grid gives it, so this region carries no card layout rules.
*/
render() {
return html`
<div class="results" @add-to-cart=${this.handleAddToCart}>
<product-card name="Magic Keyboard with Touch ID and Numeric Keypad" price="133.20" discount-percent="25" product-id="kb-100" featured></product-card>
<product-card name="Desk microphone, cardioid" price="89" product-id="mic-card"></product-card>
</div>
`;
}
/**
* Reacts to an add from any card in the grid, typed without a cast.
*
* @param event - The card's `add-to-cart` event.
*/
private handleAddToCart = ( event: CustomEvent<AddToCartDetail> ): void => {
// send event.detail.productId to your cart
};
}A few ways this goes wrong
- A reusable component watching the viewport. Media queries apply to all instances at the same time, so if the component appears in places with different sizes, at least one will be incorrect. Any reusable component should measure its own container.
- Copying the app’s breakpoints into the component. Page breakpoints are for navigation changes in the app, not for this card. Instead, shrink the component until its content no longer fits, and set the query at that width.
- Unnamed containers in shadow trees. Using a plain
@container ( ... )attaches to the closest container, but a new wrapper can change this without warning. Give the container a name on the host and target it by name. container-typewithout a sizing test. Inline-size containment removes the host’s natural width, so if it’s inside an auto flex item, a float, or afit-contentparent, it can collapse. Test these situations and explain what the consumer needs to do in the documentation.- Reading meaning off the width. If you show a Featured badge just because the card is wide, or hide Add to cart because it’s narrow, you’re basing product decisions on the card’s width. Intent should come from the API, and features should be available at any width.
What separates a senior from a junior here
- They figure out what changed before choosing a solution. A junior might hear “responsive” and immediately use a breakpoint. A senior first looks at what actually changed, then picks the right approach based on that. This is why their components keep working even when moved.
- They treat breakpoints as content measurements rather than tokens. The design system’s scale describes pages. A senior derives a component’s thresholds from where its own composition fails, documents them as part of the component’s contract, and re-derives them when the content model changes.
- They pay attention to feature details. For example, container queries need containment to work. A senior checks what
inline-sizecontainment removes and tests the flex and float options a user might try. Any extra work this causes is explained in the docs, not left for support to handle. - They make sure encapsulation works both ways. The user should not have to fix the component’s layout, and the component should not try to guess what the user means. The user provides space with the container and intent with the API, while the component sends events back. If these roles are clear, the component can be moved without needing fixes.
Which question to ask first
If a component stops working in a new place, first ask what changed between where it worked and where it broke. Do this before looking at its CSS. The answer that was hard to handle before is the middle case: when the space the page gives the component changes. Now, you can handle this directly in CSS.
Add this question to your review checklist. If someone has already built something using the wrong answer, fixing it will change how the component works for everyone.
Want more of these in your Google results?