opsinjs
RoadmapDecisions

ADR 0013. A component preview is an iframe, not an inline render

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.

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

ADR 0004 requires that a preview render under the product theme with none of the documentation site's CSS reaching it, and ADR 0007 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 says every preview "embeds a /view/… route in an iframe", Text resizing and zoom sends a reader to the text-size control that "stamps data-text-size on the embedded /view document", and Responsive modes 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 already named the cost: a lying preview in a health design system is worse than no preview.

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

  • 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

Render inline inside a scoping class. Already rejected in ADR 0004; 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

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.

Edit this page

Last read through against the system on 2026-09-03. Due for review every 12 months; expiry is reported by pnpm run check:freshness.

On this page