Fluid layouts using `clamp()` and CSS Container Queries: how to forget about hundreds of `@media` breakpoints in Bitrix components once and for all.
Rus
Eng
Fluid-верстка на clamp() и CSS Container Queries: как раз и навсегда забыть про сотни @media брейкпоинтов в компонентах Битрикса
I solve complex technical challenges for Bitrix and 1C—ranging from auditing existing code to website development and configuring complex integrations—with a guarantee of results.
Recommended

Website hosting

If you are choosing hosting for your project, I recommend Timeweb. Modern infrastructure, a wide range of plans from starter options to high-load VPS, and responsive tech support that actually helps instead of sending template replies. I host some of my own projects there and recommend it to my clients.

Go to site Affiliate link. The price remains the same for you.

All English-language materials have been translated fully automatically using the Google service

Why Classic Responsiveness in Bitrix Turns to Mush

Open any commercial Bitrix template that's more than two years old. In the style.css of each component there are dozens, sometimes hundreds, of lines like @media (max-width: 768px), @media (max-width: 480px), @media (min-width: 1200px). The product card has its own rules for the catalog, another set for the "Recently viewed" slider, and a third for the mobile version. Move the card from a wide grid into a narrow sidebar — the layout falls apart, because it only knows about the viewport, not about the parent's width.

This isn't a problem of one project. It's a systemic problem of an approach where a component is designed with one eye on the viewport instead of the context it actually lives in. And as long as we keep writing media queries for every twitch, we'll keep dragging the same hacks from one component to the next.

Part 1. clamp() — Fluid Values Without a Single Breakpoint

The clamp() function takes three values: minimum, preferred, and maximum. Between min and max the value interpolates smoothly based on viewport width. No jumps, no "at 767px one thing, at 768px another."

font-size: clamp(1rem, 2.5vw, 2rem);

Here 1rem is the minimum font size. 2rem is the maximum. 2.5vw is the preferred value, scaling with the viewport. The function returns max(1rem, min(2.5vw, 2rem)), so the text never goes below 1rem or above 2rem, but between those bounds it scales smoothly.

How to Compute the Formula by Hand

Manually computing clamp() for every size is tedious. The formula: slope slope = (maxSize - minSize) / (maxVw - minVw), the vw coefficient is slope * 100, the intercept is minSize - slope * minVw. The final expression is clamp(minSize, intercept + (slope * 100)vw, maxSize).

Example: the font should be 16px at 320px wide and 24px at 1200px. Slope: (24 - 16) / (1200 - 320) = 0.00909. vw coefficient: 0.00909 * 100 = 0.909. Intercept: 16 - 0.00909 * 320 = 13.09px ≈ 0.818rem. Result: clamp(1rem, 0.818rem + 0.909vw, 1.5rem).

Hand computation is good for understanding, but at work it's better to use ready-made tools or SASS/LESS mixins that generate these formulas during the build.

Fluid Typography in the Site Template

Let's start with the overall template. In Bitrix the site template is header.php, footer.php, and template_styles.css. We'll define fluid font and spacing sizes via CSS variables so they're available to every component.

:root {
    --font-size-xs: clamp(0.75rem, 0.7rem + 0.25vw, 0.875rem);
    --font-size-sm: clamp(0.875rem, 0.8rem + 0.35vw, 1rem);
    --font-size-base: clamp(1rem, 0.95rem + 0.25vw, 1.125rem);
    --font-size-lg: clamp(1.25rem, 1.1rem + 0.75vw, 1.75rem);
    --font-size-xl: clamp(1.75rem, 1.4rem + 1.5vw, 2.75rem);
    --font-size-2xl: clamp(2.25rem, 1.5rem + 3vw, 4rem);

    --space-xs: clamp(0.25rem, 0.2rem + 0.25vw, 0.5rem);
    --space-sm: clamp(0.5rem, 0.4rem + 0.5vw, 1rem);
    --space-md: clamp(1rem, 0.8rem + 1vw, 1.5rem);
    --space-lg: clamp(1.5rem, 1rem + 2vw, 3rem);
    --space-xl: clamp(2rem, 1.5rem + 3vw, 5rem);
}

Then in template_styles.css we apply these variables to base elements.

body {
    font-size: var(--font-size-base);
    line-height: 1.6;
}

h1 { font-size: var(--font-size-2xl); }
h2 { font-size: var(--font-size-xl); }
h3 { font-size: var(--font-size-lg); }

.container {
    max-width: 1280px;
    margin-inline: auto;
    padding-inline: var(--space-md);
}

No media queries for typography. Headings, body text, and spacing scale smoothly from 320px to 1920px. Media queries remain only for structural changes: show/hide the sidebar, switch the menu to a burger.

Page Skeleton: Footer at the Bottom, Sidebar to the Side, Content First

.page {
    display: grid;
    grid-template-rows: auto 1fr auto;
    min-height: 100vh;
}

.page--has-sidebar {
    grid-template-columns: 1fr 320px;
    grid-template-areas:
        "header header"
        "content sidebar"
        "footer footer";
}

.page--has-sidebar-left {
    grid-template-columns: 320px 1fr;
    grid-template-areas:
        "header header"
        "sidebar content"
        "footer footer";
}

.page--header { grid-area: header; }
.page--content { grid-area: content; }
.page--sidebar { grid-area: sidebar; }
.page--footer { grid-area: footer; }

The key point: in the markup, the content area must come before the sidebar, so that in linear reading the main content is available first. grid-template-areas lets us position it visually to the right or left regardless of HTML order.

@media (max-width: 1024px) {
    .page--has-sidebar,
    .page--has-sidebar-left {
        grid-template-columns: 1fr;
        grid-template-areas:
            "header"
            "content"
            "sidebar"
            "footer";
    }
}

The media query controls the structure, not the sizes. Sizes inside columns are fluid, via clamp().

Spacing Between Blocks

.section {
    padding-block: var(--space-xl);
}

.section--compact {
    padding-block: var(--space-lg);
}

.grid--cards {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
    gap: var(--space-md);
}

minmax(min(100%, 280px), 1fr) — an important detail. Without min(100%, 280px), on very narrow screens the column can end up wider than the container and cause horizontal scroll.

Part 2. Container Queries — the Component Knows Where It Lives

Media queries respond to the viewport. Container queries respond to the parent's width. The difference is fundamental: the same product card can live in a wide catalog grid, in a narrow "Recently viewed" slider, and in a modal. With media queries you'll write three sets of styles. With container queries — one.

container-type: inline-size vs size

A container query only works if the element explicitly has a container-type. Two values matter in practice.

inline-size — the container measures its width; block-axis sizes continue to depend on content. This value is what you need 95% of the time: cards, filters, sliders — anything that reacts to the parent's width.

size — the container measures both width and height. Needed when the query uses cqh or cqb, or when the component must control its own height inside the parent. The danger is that size sets contain: size: the parent stops seeing the container's content, and if the height isn't explicitly set, the block collapses to zero. Don't use size without a real need.

.product-card--wrapper {
    container-type: inline-size;
    container-name: product-card;
}

Understand that container-type sets contain: layout style inline-size (or size for the second variant). This means positioned descendants start resolving against the container rather than a farther ancestor, and out-of-flow elements (like position: fixed inside) may behave differently. If the card contains an absolutely-positioned tooltip, it will position relative to the card, not the page.

The Main Methodological Mistake: The Container Isn't the Grid

The most common wrong example floating around in articles looks like this:

/* Don't do this */
.catalog--grid {
    container-type: inline-size;
    container-name: catalog-grid;
}

@container catalog-grid (min-width: 400px) {
    .product-card {
        flex-direction: row;
    }
}

The problem is that the container query looks at the width of the entire grid, not at the width of the cell a particular card lives in. If the grid is 1200px wide with three columns of 380px each, the query still fires (the grid is bigger than 400px), and every card becomes horizontal even though each one lives in a narrow cell. Worse: on mobile the grid is 360px and the cell is 360px — that happens to match. But add a sidebar or move the grid into a narrow area, and the query starts lying again.

The correct pattern: the container goes on each item's wrapper, not on the shared grid. Each card responds to the width of its own cell.

.catalog--grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
    gap: var(--space-md);
}

.catalog--item {
    container-type: inline-size;
    container-name: product-card;
}

.product-card {
    display: flex;
    flex-direction: column;
    gap: var(--space-sm);
    padding: var(--space-sm);
    border: 1px solid var(--color-border);
    border-radius: 8px;
}

@container product-card (min-width: 400px) {
    .product-card {
        flex-direction: row;
        align-items: center;
    }
}

In the HTML, each card is wrapped in .catalog--item. That wrapper is the container; the card inside is its content, styleable via a query. A container can't style itself, only its descendants, so the wrapper is mandatory.

container-name and Nested Containers

The container name isn't mandatory, but it's useful when containers are nested. Without a name, the query looks for the nearest container with a suitable container-type — and if the card sits inside a slider that's itself a container, you'll get a different result than expected.

.slider--track {
    container-type: inline-size;
    container-name: product-slider;
}

.slider--item {
    container-type: inline-size;
    container-name: product-card;
}

/* Explicitly target the card's container, not the slider's */
@container product-card (min-width: 400px) {
    .product-card {
        flex-direction: row;
    }
}

/* Explicitly target the slider's container */
@container product-slider (max-width: 500px) {
    .slider--item {
        flex-basis: 200px;
    }
}

Without names, in nested queries, the order of declaration becomes critical: the spec says that with multiple matching containers, the nearest suitable one wins. In practice, inside a slider, @container (min-width: 400px) will look at the slider's item, not the track. Explicit names remove this ambiguity.

Units cqw, cqh, cqi, cqb

Inside a container, its own units are available:

  • cqw — 1% of the container's width;
  • cqh — 1% of the container's height (requires container-type: size);
  • cqi — 1% of the container's inline size (equals cqw in horizontal writing);
  • cqb — 1% of the container's block size (equals cqh in horizontal writing).

This is the key detail missing from vw-based examples: fluid sizes inside a component can be relative to its own container, not the viewport.

@container product-card (min-width: 300px) {
    .product-card--title {
        font-size: clamp(0.875rem, 6cqi, 1.25rem);
        padding-block: clamp(0.5rem, 3cqi, 1rem);
    }

    .product-card--image {
        flex-basis: clamp(100px, 35cqi, 180px);
    }
}

In a wide cell the font grows to 1.25rem; in a narrow one it drops to 0.875rem. No viewport in the calculation — only the width of the parent the card actually lives in. Move the card from the catalog to the sidebar, and the font recalculates automatically.

The Product Card in catalog.section

Putting it all together. The markup has a wrapper on each item; styles use container queries; data is escaped.

<div class="catalog--grid">
    <?php foreach ($arResult['ITEMS'] as $arItem): ?>
        <div class="catalog--item">
            <div class="product-card">
                <div class="product-card--image">
                    <img src="<?=htmlspecialcharsbx($arItem['PREVIEW_PICTURE']['SRC'])?>"
                         alt="<?=htmlspecialcharsbx($arItem['NAME'])?>">
                </div>
                <div class="product-card--body">
                    <div class="product-card--title"><?=htmlspecialcharsbx($arItem['NAME'])?></div>
                    <div class="product-card--price"><?=htmlspecialcharsbx($arItem['PRICE'])?> ₽</div>
                    <button class="product-card--buy" type="button">Add to cart</button>
                </div>
            </div>
        </div>
    <?php endforeach; ?>
</div>

The card doesn't know where it lives. It only knows the width of its wrapper .catalog--item. Narrow wrapper — vertical layout; wide wrapper — horizontal layout. Move the card into a slider — it rebuilds automatically.

Note: link to the example

Product Slider

The slider is the classic example where container queries save hours. Previously you'd keep a separate template for the slider with stripped-down card markup. Now you can use one template and one set of styles.

The container goes on each slider item, not on the track. The track is a shared ribbon, its width is always bigger than a single item, and a query on the track would lie again.

.slider--track {
    display: flex;
    gap: var(--space-sm);
    overflow-x: auto;
    scroll-snap-type: x mandatory;
}

.slider--item {
    flex: 0 0 clamp(200px, 60vw, 280px);
    scroll-snap-align: start;
    container-type: inline-size;
    container-name: product-card;
}

In the slider, the card is always vertical, because .slider--item is narrow. In the catalog grid, it's horizontal, because the wrapper is wide. One component, different contexts.

Note: link to the example

The Catalog Filter

The filter is another component that suffers from context. In the sidebar it's vertical, in mobile it's a slide-out panel, in a horizontal layout it's a row of checkboxes.

.filter--wrapper {
    container-type: inline-size;
    container-name: filter-panel;
}

.filter--group {
    display: flex;
    flex-direction: column;
    gap: var(--space-xs);
}

@container filter-panel (min-width: 600px) {
    .filter--group {
        flex-direction: row;
        flex-wrap: wrap;
        align-items: center;
        gap: var(--space-sm);
    }

    .filter--label {
        white-space: nowrap;
    }
}

@container filter-panel (max-width: 400px) {
    .filter--group {
        padding: var(--space-xs);
    }

    .filter--label {
        font-size: var(--font-size-xs);
    }
}

Note: container-type is on .filter--wrapper, not on the group itself. A container query can't apply to the element that is itself the container — you need a parent.

Note: link to the example

@supports and Fallbacks for Older Browsers

Container Queries are supported in all current browsers as of 2023: Chrome 105+, Firefox 110+, Safari 16+. For projects that need to work somewhere older, a fallback strategy is needed.

The right approach is progressive enhancement: base styles are written for media queries, and container queries are added on top via @supports.

/* Base variant for all browsers */
.product-card {
    display: flex;
    flex-direction: column;
}

@media (min-width: 768px) {
    .product-card {
        flex-direction: row;
        align-items: center;
    }
}

/* Override for browsers with Container Queries */
@supports (container-type: inline-size) {
    .catalog--item,
    .slider--item {
        container-type: inline-size;
        container-name: product-card;
    }

    .product-card {
        flex-direction: column;
    }

    @container product-card (min-width: 400px) {
        .product-card {
            flex-direction: row;
            align-items: center;
        }
    }
}

Old browsers see only the media queries and get a fixed layout. Modern ones switch to container queries because the @supports block overrides the base rule.

clamp() is supported even more widely: Chrome 79+, Firefox 75+, Safari 13.1+. No support issues there.

Style Queries and Scroll-State Queries

Beyond size queries, the spec includes two more types. Style queries (@container style(--theme: dark)) let you react to the value of a CSS variable or custom property rather than to size. This opens the door to switching themes inside a component without JS.

@container style(--density: compact) {
    .product-card {
        padding-block: var(--space-xs);
        gap: var(--space-xs);
    }
}

Scroll-state queries (@container scroll-state(stuck: top)) let you react to scroll state: whether an element is stuck to an edge, visible in the container's viewport, or scrolled to the end. For sticky headers and lazy loading this replaces what used to be done with IntersectionObserver.

As of 2026 both are partially supported: style queries already ship in stable Chrome and Safari; scroll-state queries in Chrome 133+ and recent Safari. Use them behind @supports and don't rely on them as the only solution.

Performance and Containment

Every container sets containment — the browser isolates inner styles and layout from the outside world. This speeds up recalculation by itself: a change inside a container doesn't trigger reflow outside. But there shouldn't be too many containers.

The rules are simple. One container per reusable component, not one per element. Don't put container-type on elements that have position: fixed inside. Don't put container-type: size on elements whose height depends on content: they'll collapse. If a page has five hundred cards in a long list, a container on each card's wrapper is fine, but you shouldn't add separate containers for the title, price, and button on top.

Part 3. Integrating Into a Bitrix Project

Where to Keep Global Variables

All clamp() variables and base tokens should be declared in template_styles.css of the site template. That file loads on every page, and variables are available to any component. If a component is reused across projects, its local variables can be duplicated in the component's own style.css.

/* template_styles.css */
:root {
    --font-size-base: clamp(1rem, 0.95rem + 0.25vw, 1.125rem);
    --space-md: clamp(1rem, 0.8rem + 1vw, 1.5rem);
    --color-border: #e0e0e0;
}

The Wrapper Problem in Bitrix Templates

The key difference from pure HTML layout: in Bitrix components you can't just wrap each element in a div. The template renders in a loop, and adding intermediate wrappers means editing the template. The good news: editing the template in /local is the standard Bitrix mechanism, and you should do it.

If a component's template lives in /bitrix/components/bitrix/catalog.section/templates/.default/ and you don't want to touch it, copy it to /local/templates/<site_template>/components/bitrix/catalog.section/<template_name>/. Then edit only the copy. Bitrix updates won't touch it.

Three standard places where code can be added without editing template.php:

  • result_modifier.php — processes $arResult before output. Useful for adding classes and wrappers to the array, but not to the markup.
  • component_epilog.php — runs after the template. Convenient for adding assets via Asset::addCss() and Asset::addJs(), but not for changing markup.
  • style.css in the component's template folder — loaded automatically. All component styles live here.

Only template.php itself can add a wrapper to the markup. It's one line, and its appearance in the template isn't a "spare div" but a technical requirement for container queries.

What to Do with catalog.section, sale.basket, news.list

The pattern is the same for every standard Bitrix component: copy the template to /local, add a wrapper around each item, and put container-type on it in style.css.

catalog.section — a wrapper on .catalog--item around .product-card. Example above.

sale.basket — a wrapper on .basket--item around the basket row. On a wide screen it's a table row, on a narrow one it's a stack of blocks, and the switch is done by @container instead of media queries.

<div class="basket--list">
    <?php foreach ($arResult['BASKET_ITEMS'] as $arItem): ?>
        <div class="basket--item">
            <div class="basket-row">
                <div class="basket-row--name"><?=htmlspecialcharsbx($arItem['NAME'])?></div>
                <div class="basket-row--price"><?=htmlspecialcharsbx($arItem['PRICE_FORMATED'])?></div>
                <div class="basket-row--quantity"><?=htmlspecialcharsbx($arItem['QUANTITY'])?></div>
            </div>
        </div>
    <?php endforeach; ?>
</div>

news.list — a wrapper on .news--item around the news card. In a wide list the card is horizontal with the image on the left; in a narrow one it's vertical with the image on top. The markup is the same; the styles live in the component's style.css.

A Clean template.php Without JS Hacks

Previously, to switch a card from horizontal to vertical when it moved into a slider, you either duplicated the template or wrote JS that measured the parent's width via ResizeObserver and added a .is-narrow class. Now you don't need that.

In template.php only the markup remains. No width checks, no conditional classes. Component styles — in style.css, which Bitrix loads automatically if it's present in the component's template folder. No inline styles, no direct <link> or <script> — only Bitrix's built-in mechanisms.

Part 4. Testing

Container queries break quietly: the page looks fine on a wide screen, then falls apart in a narrow sidebar. Every place the component actually lives needs to be checked.

DevTools

Chrome DevTools has a dedicated panel for containers, opened via Elements → Layout. Enable container display — the inspector will highlight every element with container-type and show its name. If a container isn't highlighted, container-type didn't apply: most likely the element collapsed or the style was overridden.

Through Elements → Styles you can temporarily change the container's width by hand: right-click on the element → Edit as HTML, or edit style="width: 300px" directly. Faster than dragging the browser window back and forth.

How to Check the Card in Four Contexts

  1. Catalog grid. Open /catalog/, shrink the window to 320px, verify the card is vertical and nothing overflows the container.
  2. Slider. Open a page with a "Recently viewed" or similar block, verify the slide items scroll and the cards inside stay vertical.
  3. Sidebar. Open a page with a side column using the same component. Here the card may be horizontal on a wide viewport but must be vertical in a narrow sidebar. If it's horizontal — the container is in the wrong place.
  4. Modal. If the card opens in a modal (e.g., "Quick view"), its width is usually 600–800px but the modal may be resizable. Verify queries fire relative to the modal, not the page underneath.

Emulating Slow Devices and SPA Transitions

DevTools has a Rendering → Emulate CSS media feature prefers-reduced-motion mode and others. It's worth checking behavior under prefers-reduced-motion: reduce so animations inside the container don't break user expectations.

If the site runs as a SPA or uses dynamic loading (for example, via AJAX in Bitrix), remounting the component must be tested. On page transitions, containers should be recreated and old MutationObserver and ResizeObserver instances detached. Check in the Performance tab: if the observer count grows with each navigation, there's a leak.

Implementation Checklist

  • Declare global clamp() CSS variables in the site template's template_styles.css.
  • Convert typography and spacing to fluid values — first in the template, then in components.
  • Copy the needed component templates to /local/templates/<template>/components/ before editing.
  • For each reusable component, add a wrapper on the item and put container-type: inline-size on it with a meaningful container-name.
  • Write @container by container name, not anonymously, if other containers are already nested inside.
  • Use cqi/cqw for fluid sizes inside the component instead of vw.
  • Wrap all container rules in @supports (container-type: inline-size) alongside the media-query fallback.
  • Leave media queries only for structural changes: hide the sidebar, switch the menu, change block order.
  • Check the component in four contexts: grid, slider, sidebar, modal.
  • Make sure the number of containers isn't excessive, and that there are no position: fixed elements inside them.

Media queries won't disappear completely. But there will be an order of magnitude fewer of them. And components will stop breaking when moved from one place to another — that's the main win from switching to clamp() and Container Queries.

Aleksandr Tichiy
Author

Aleksandr Tichiy

Fullstack developer · 1C-Bitrix

I build and modernize online stores and corporate portals on 1C-Bitrix. Integrations with 1C, high-load performance, 152-FZ compliance. Writing about architecture and e-commerce.


Comments

There are no comments yet, you can be the first to leave it

Leave a comment

The site uses a comment pre-moderation system, so your message will be published only after approval by the moderator

You are replying to a user's comment

Send

FEEDBACK

Email me

Please message me to discuss the task. I work remotely and officially (as a self-employed professional); my hourly rate is 1,800 RUB, and I can dedicate 3–4 hours a day to the project. I respond quickly, and the consultation is free.

An error occurred while sending, please try again after a while
Message sent successfully

Phones

+7(993) 007-18-96

Email

info@tichiy.ru

Address

Россия, г. Москва

By submitting the form, you automatically confirm that you have read and accept the Privacy Policy site