Responsive blocks
Your block runs in a sandboxed iframe, and that changes what "responsive" means in a way that is easy to get backwards. This page is the contract: what you are actually measuring, the scale to measure it against, and the two traps that produce layouts which look right on your machine and wrong in production.
The box you measure is the slot, not the device
Inside the iframe, @media (max-width: …), 100vw and 100dvh resolve against the iframe's own viewport — the box the host handed you — and not against the browser window. So a media query written inside a block is already a container query against your slot. You do not need @container to get that behaviour, and you cannot use a media query to ask about the device.
With the host page fixed at 1440×900 and only the iframe box changing:
| iframe box | matchMedia('(max-width: 500px)') | 100vw | 100vh / 100dvh / 100svh / 100lvh |
|---|---|---|---|
| 430 × 320 | true | 430px | 320px |
| 900 × 640 | false | 900px | 640px |
dvh, svh, lvh and vh are the same number here — they differ only when browser chrome expands over the viewport, and an iframe has none. Neither is the height of the user's phone.
…and slot width is not monotonic in viewport width
This is the part that catches people. A wider window does not mean a wider block:
- the
model.sidebar_topslot is about 360px wide at a 360px phone viewport, and only about 430px at a 1440px desktop one; - a page app (
app.page) gets the host's full content area, edge to edge — the host imposes no maximum width on it, at any window size — so the same block can be several times wider on that same desktop, and wider still on an ultrawide monitor.
So "narrow" is not "phone", and "wide" is not "desktop". A 360px phone and a desktop model sidebar are the same layout problem, and a block that infers the device from its own width will be wrong on one of them. Design for the box.
Don't reach for the parent
There is no escape hatch to the real window size. The trust frame is the platform's primary security boundary: your code cannot reach the parent page, so the host's dimensions are not readable from inside the block (and yours are not readable from outside it). Everything below works entirely within the frame.
The scale: --civitai-bp-* and useBlockBreakpoint()
Civitai's breakpoints ship as tokens in @civitai/theme and as a hook in @civitai/blocks-react, so you never have to hard-code a number or hand-roll a ResizeObserver.
| key | token | value |
|---|---|---|
xs | --civitai-bp-xs | 480px |
sm | --civitai-bp-sm | 768px |
md | --civitai-bp-md | 1024px |
lg | --civitai-bp-lg | 1184px |
xl | --civitai-bp-xl | 1440px |
Tier semantics are Tailwind's: a tier applies at its breakpoint and above. Below xs is base, which is where both the phone and the model sidebar land.
In CSS
.layout {
display: grid;
gap: 1rem;
grid-template-columns: 1fr;
}
/* 768px is --civitai-bp-sm — see the note below on why it is written out. */
@media (min-width: 768px) {
.layout { grid-template-columns: 2fr 1fr; }
}A custom property cannot appear in a media query condition
@media (min-width: var(--civitai-bp-sm)) does not work. A media condition is evaluated outside the cascade, where no element is in scope, so var() is never substituted there — the condition is invalid and the rules inside it never apply. Nothing errors and nothing warns; the styles are simply missing.
So write the pixel value literally in a @media or @container condition, and keep the token for the places that do substitute it — width, max-width, padding, and any other property value.
In JavaScript
useBlockBreakpoint() observes an element — by default the block's own root — and reports which tier it is in.
import { useBlockBreakpoint } from '@civitai/blocks-react';
export function Layout() {
const bp = useBlockBreakpoint();
return (
<div style={{ display: 'flex', flexDirection: bp.below('sm') ? 'column' : 'row' }}>
<main>…</main>
{bp.atLeast('md') && <aside>…</aside>}
</div>
);
}tier—'base' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'.atLeast(key)/below(key)— the comparators you want at a call site.measured—falseuntil the first measurement lands. An unmeasured width resolves to'base', which is indistinguishable from a genuinely narrow block. That is the right default (the slot is narrow more often than not), but if your narrow branch is a structural DOM swap rather than a style change, gate it onmeasured && below('sm')so it does not render and immediately undo itself.
The hook stores the resolved tier, not the width, and returns a referentially stable object while the tier is unchanged — so dragging 200px inside one tier re-renders your block zero times. That is also why it does not hand you the raw width: doing so would cost a render per pixel, or be stale.
Pass a ref to measure a nested container instead of the root:
import { useRef } from 'react';
import { useBlockBreakpoint } from '@civitai/blocks-react';
const ref = useRef<HTMLDivElement>(null);
const bp = useBlockBreakpoint(ref);Need the numbers themselves — for a canvas, a virtualised list, or a non-React framework? @civitai/theme exports them:
import { breakpoints, BREAKPOINT_KEYS } from '@civitai/theme';
// breakpoints.sm === 768The trap: two breakpoint scales, agreeing on exactly one key
Civitai's scale is px. Mantine's stock scale — which civitai never overrides, and which every Mantine responsive prop uses — is em, and it is a different set of numbers:
| key | civitai (--civitai-bp-*) | Mantine stock |
|---|---|---|
xs | 480px | 576px (36em) |
sm | 768px | 768px (48em) |
md | 1024px | 992px (62em) |
lg | 1184px | 1200px (75em) |
xl | 1440px | 1408px (88em) |
They agree only on sm, so a spot-check at sm passes against the wrong scale. Mantine's hiddenFrom / visibleFrom are on the em scale; --civitai-bp-* and useBlockBreakpoint() are on the px one — don't mix them in one layout.
What the design system already reflows for you
Since @civitai/components@0.4.0 you get some of this without writing anything:
groupwraps by default. A row of controls that no longer fits reflows onto another row instead of overflowing its box, and its children may shrink (min-width: 0) rather than pushing the row past its container.- Opt out per row with
data-nowrap="true"— for a deliberately horizontally-scrolling toolbar, which is the one case where overflow was the point.
<div data-civitai-ui="group">…reflows…</div>
<div data-civitai-ui="group" data-nowrap="true">…stays on one line…</div>This applies to bare markup. The element twin <civitai-group> (bound as CivitaiGroup in @civitai/components-react) also wraps by default, but it opts out through a nowrap property, not data-nowrap — the hand-written <Group> that took this contract was superseded in @civitai/components-react@0.9.0. @civitai/blocks-react's <Group> already wrapped and is unchanged. See the components reference for the full markup contract.
The surface decides what "responsive" means
The two surfaces size your iframe differently, and the difference is not cosmetic:
page app (app.page) | model slot (model.*) | |
|---|---|---|
| iframe height | full content area; the host does not listen for RESIZE_IFRAME | sized to your content, clamped to the manifest's iframe.minHeight / iframe.maxHeight |
useBlockResize | inert — it still posts, the host ignores it | honoured |
| typical width | the full page content width — no host maximum | narrow, and roughly constant regardless of window width |
On a page app, size to the surface: let the host's box be your canvas and lay out inside it. Because the host sets no maximum width, a wide monitor hands you all of it — if your layout reads best as a centred column (long-form text, a form), set that max-width and margin-inline: auto in your own CSS. There is no manifest field or host setting for it; your stylesheet is the only control, and it is entirely yours. On a model slot, tell the host how tall you are with useBlockResize and keep the layout single-column — you are in a sidebar whether or not the window is wide.
Pattern: sidenav on wide, top strip on narrow
The usual replacement for a row of tabs: one view, with its sections in a nav that is a sidebar when there is room and a horizontally-scrolling strip above the content when there is not. It is a pure CSS reflow — the DOM is the same at every width, so there is no structural swap to gate on measured.
<div class="shell">
<nav class="shell-nav" aria-label="Sections">
<a href="#create" aria-current="page">Create</a>
<a href="#history">History</a>
<a href="#settings">Settings</a>
</nav>
<main class="shell-main">…</main>
</div>.shell {
display: grid;
grid-template-columns: 1fr;
}
/* base → xs: a strip across the top that scrolls sideways instead of wrapping */
.shell-nav {
display: flex;
gap: 0.25rem;
overflow-x: auto;
padding: 0.5rem;
background: var(--civitai-color-surface);
border-bottom: 1px solid var(--civitai-color-border);
}
.shell-nav a { flex: none; white-space: nowrap; padding: 0.375rem 0.75rem; }
.shell-nav a[aria-current] { background: var(--civitai-color-primary-light); }
.shell-main { min-width: 0; padding: 1rem; }
/* 768px is --civitai-bp-sm — literal, because var() is dead in a media condition. */
@media (min-width: 768px) {
.shell { grid-template-columns: 12rem 1fr; }
.shell-nav {
flex-direction: column;
overflow-x: visible;
border-bottom: 0;
border-right: 1px solid var(--civitai-color-border);
}
}The shell deliberately sets no height. On a page app you may add min-height: 100vh to .shell so the sidenav runs the full content area. Don't add it on a model slot: there, 100vh is the iframe's current height, so a root sized to it reports at least that height to useBlockResize. The frame could then grow but never shrink (see the surface table).
min-width: 0 on the content column is what stops a wide child (an image grid, a long prompt) from forcing the grid wider than the slot.
Checklist
- Never infer the device. The width you can see is the slot's.
- Use
--civitai-bp-*/useBlockBreakpoint()rather than invented numbers. - Write the literal px value inside a
@mediacondition; avar()there is silently dead. - If you also use Mantine props, remember they are on the em scale.
- Gate a structural narrow branch on
measured &&, not on the tier alone. - Let
groupwrap; reach fordata-nowrap="true"only when a row must not. - Check your block at
base(≈360px) as well as wide — that is the model sidebar, not just a phone. For a page app, "wide" has no upper bound: check an ultrawide window too, and cap your own column if it needs one. - Replacing tabs? Use the sidenav pattern — and run the first review checklist before you submit.