---
url: /apps/guide/responsive.md
description: >-
  Making a block fit the slot the host gives it — the --civitai-bp-* scale,
  useBlockBreakpoint(), the em-vs-px breakpoint trap, and what the design system
  reflows for you.
---

> Documentation index: https://developer.civitai.com/llms.txt — every page on this site has a markdown version at its URL + `.md`.

# 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_top` slot 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.

::: warning Don't reach for the parent
There is no escape hatch to the real window size. The [trust
frame](https://developer.civitai.com/apps/guide/concepts.md#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

```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; }
}
```

::: danger 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.

```tsx
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` — `false` until 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 on `measured && 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:

```tsx
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:

```ts
import { breakpoints, BREAKPOINT_KEYS } from '@civitai/theme';
// breakpoints.sm === 768
```

## The 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:

* **`group` wraps 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.

```html
<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](https://developer.civitai.com/apps/reference/components.md) 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`](https://developer.civitai.com/apps/reference/hooks.md) 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 {#sidenav-pattern}

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`.

```html
<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>
```

```css
.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`](https://developer.civitai.com/apps/reference/hooks.md). The frame could then grow but never
shrink (see [the surface table](#the-surface-decides-what-responsive-means)).

`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 `@media` condition; a `var()` 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 `group` wrap; reach for `data-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](#sidenav-pattern) — and run the
  [first review checklist](https://developer.civitai.com/apps/guide/first-review.md) before you submit.
