---
title: "ADR 0013. A component preview is an iframe, not an inline render"
description: "Every component preview embeds a /view route in a frame, because the two palettes overlap and an inline preview shows a component that is not the component."
url: "https://opsinjs.pensievelabs.org/project/decisions/0013-previews-are-iframes"
source: "https://opsinjs.pensievelabs.org/project/decisions/0013-previews-are-iframes.md"
section: "Project"
kind: "project"
reviewed: "2026-09-03"
reviewer: "design"
aliases: ["preview iframe", "inline preview", "preview isolation"]
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> Nothing is missing from this page. The data simply does not live in
> the prose.

<PageTemplate kind="project" />

## Status [#status]

**Accepted.** 2026-09-03. `components/docs/preview.tsx` now frames `/view`, and
`/components/status-pill` serves a real `<iframe>` pointed at
`/view/base/base-lyra/component/status-pill`.

## Context [#context]

[ADR 0004](./0004-lyra-docs-chrome.mdx) requires that a preview render under the
product theme with none of the documentation site's CSS reaching it, and
[ADR 0007](./0007-two-root-layouts.mdx) built the mechanism: a second root layout,
its own document, and only `app/product.css`. Three pages already tell readers
that this is what happens.
[lyra and the docs chrome](../../theming/lyra-and-the-docs-chrome.mdx) says every
preview "embeds a `/view/…` route in an iframe",
[Text resizing and zoom](../../accessibility/text-resizing-and-zoom.mdx) sends a
reader to the text-size control that "stamps `data-text-size` on the embedded
`/view` document", and
[Responsive modes](../../foundations/space/responsive-modes.mdx) tells them to
resize the frame.

The code does the opposite, and says so in its own header comment:
`<ComponentPreview>` "renders in the page, under the DOCS chrome", described as
"honest about being an approximation".

It is not an approximation in the sense that word usually carries. The two
stylesheets do not differ by a few values, and neither one contains the other.
Each declares roughly seventy custom properties the other does not. Only one of
those two directions is dangerous, and it is the one where a component can use a
property that exists in review and does not exist in the product. `--secondary`,
`--accent`, `--destructive`, `--popover`, all eight `--sidebar` roles, the five
chart colours, the three largest radii and the heading font are declared only in
`app/globals.css`. Under `app/product.css` they resolve to nothing at all, and
nothing here means neither a fallback nor a warning. A component built with
`bg-secondary` looks finished on its documentation page and renders colourless in
the product, and the docs-chrome button in this repository already uses several
of them.

The other direction is not a failure but it is not harmless either. The product
theme applies a minimum touch target on both axes to anything tappable, derives
its Tailwind spacing from a different base, uses a different radius scale,
applies a squircle corner shape, and bridges the eleven semantic type steps and
the whole motion family into Tailwind utilities that the documentation chrome
does not have. None of that reaches a component rendered inline, so an inline
preview hides layout consequences the product will have and silently accepts
utilities that will not resolve.

That is not an approximation. It is the specific failure the two previous records
exist to prevent, and [ADR 0004](./0004-lyra-docs-chrome.mdx) already named the
cost: a lying preview in a health design system is worse than no preview.

## Decision [#decision]

**`<ComponentPreview name="…">` embeds `/view/<base>/<style>/<kind>/<name>` in an
iframe.** The theme, density and text-size switches become query parameters on
the frame's URL rather than styles applied to an inline container, so operating
one reframes the preview instead of restyling it.

**`children` still render inline.** Foundations pages pass specimen content such
as a type ramp, a spacing scale or a set of swatches. It is not a component, it
has no catalogue id, and it has no `/view` route to point at. That content is not
making a claim about what a component looks like in a product, so it does not
need the isolation and should not pay for it.

## Consequences [#consequences]

* **The documentation page pays for an iframe per preview.** A component page has
  one; a foundations page passing `children` has none. That is the honest price
  of the guarantee, and it is why the switches were built to be query parameters
  from the start.
* **The switches reframe rather than re-render.** The `(view)` layout reads
  `mode`, `density` and `text` once, in a blocking script, and stamps them on its
  own document element. Changing a switch is therefore a navigation. The cost is
  a flash; the benefit is that what changes is the same mechanism that changes in
  a product, rather than a simulation of it.
* **A preview is addressable.** The frame's URL is a real page a reader can open
  in a tab, a reviewer can paste into a comment, and a script can navigate to.
  The route declares `data-opsin-view-state="ready"` on `#opsin-view-root` so
  that a screenshot job CAN be made race-free; `scripts/capture-registry.mts`
  does not yet wait on it or clip to it, and until it does, a capture from that
  script is a viewport shot that can in principle photograph a placeholder.
* **What renders is what a consumer gets, minus their own theme**, and that claim
  is only true because the two stylesheets never meet. It is the single sentence
  this whole arrangement exists to be able to say.
* **A preview is not indexed and does not print.** The frame's contents are a
  separate document, so the site's search does not see them and the print
  stylesheet in `app/globals.css` does not reach them. `<ComponentSource>`, the
  generated props table and the page's prose are what the search index and the
  printed page carry instead. That is the right division, since none of those is
  a picture.
* **The wiring note that used to sit in `components/docs/preview.tsx` is gone.**
  It said the only change the file needed was to look up the registry entry and
  render it in place of the not-built state. That is precisely the change this
  record forbids, and leaving the note in place would have invited somebody to
  make it.

## Alternatives considered [#alternatives-considered]

**Render inline inside a scoping class.** Already rejected in
[ADR 0004](./0004-lyra-docs-chrome.mdx); the token audit says why concretely. The
documentation stylesheet sets custom properties on the document element and on
`body`, and a class on a wrapper cannot un-set an inherited property. The
component would still resolve `--secondary` because the ancestor still declares
it. It also cannot reproduce the text-size demonstration, which is a change to
the root element's font size and is the mechanism two accessibility pages
promise.

**Render inline inside a shadow root.** Rejected: inherited custom properties
cross a shadow boundary, which is the entire problem, so the shadow root would
isolate the rules while keeping the variables that make the preview lie. It also
does not fit the way Tailwind's content graph and the site's stylesheets are
built here.

**Render an inline copy for search and print, and a framed one for the truth.**
Rejected: it produces two renderings of the same component that are permitted to
disagree, on a page whose only purpose is to be believed. The first time they do
disagree, the printed one is the one somebody has already taken into a meeting.

**Wait until components exist before building the mechanism.** Not an alternative
so much as a temptation, and recorded because the opposite choice was made
deliberately: the frame ships before the components, with the honest not-built
state inside it, so the switches are exercised from the first day rather than
bolted on afterwards.

## Revisiting this [#revisiting-this]

Revisit if the platform gains a way to render a subtree under a genuinely
separate style scope, with no inheritance from the host document. That would
remove the cost without weakening the guarantee, and it is the one change that
would make the inline approach correct rather than merely cheaper. Popularity of
inline previews elsewhere is not a trigger; most design systems do not have two
deliberately different palettes to be wrong about.

<LastUpdated />

<Reviewed />
