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
childrenhas 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 readsmode,densityandtextonce, 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-rootso that a screenshot job CAN be made race-free;scripts/capture-registry.mtsdoes 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.cssdoes 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.tsxis 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.
Last read through against the system on 2026-09-03. Due for review every 12 months; expiry is reported by pnpm run check:freshness.
ADR 0012. Example data is synthetic and explicitly unsourced
Every demo and preview uses obviously invented numbers and one shared literal in place of a source, because a plausible example range is a fabricated citation.
ADR 0014. The material rung names are the token names
Three documents name the six material rungs three ways. The names in tokens/material.json win, because a stylesheet resolves them and a drift gate checks them.