Frames and formats
How an image or document template adapts to each of its declared formats — the frame as a viewport you style with geometry, per-format variants as the destination escape hatch, bleed and trim, and where the platform stops enforcing and trusts your eye.
What it is
Section titled “What it is”A website template renders into a browser window whose size you never know. An
image or document template renders into a frame: a box whose exact size you
declared. A social image is 1080×1350 and nothing else; a letter sheet is 8.5in by
11in at the dpi you named. Nothing about the machine looking at it can change that.
A frame template still adapts, because it declares more than one format. formats
is its acceptance set — the geometries a deliverable built from this template may be
produced at — and one codebase serves all of them. That is what “responsive” means
here: one template, N declared geometries, resolved while you author. It is not
what it means on a website, where one page must survive every width at once.
A frame is a page, and its window is the artwork. Wherever a frame renders, the
window it is given is exactly the format’s bleed box — so viewport units and @media
over width, height and aspect ratio read the frame’s own geometry, by construction.
Styling a frame is styling a page against its viewport, which is the vocabulary you
already have.
image and document are the rendered classes — the two whose output is a file
captured from a frame rather than a page a browser serves. Where the rest of the
corpus says “a rendered-class template”, it means one of these.
Why it works this way
Section titled “Why it works this way”Because a frame is a page, and you already know how to style a page. Content
fills the window, a width query means width, vw is one percent of it. That is the
model your muscle memory runs on, and inside a frame it is simply true: the frame is
the page and the sections within it are components — media queries for the page,
container queries for the components inside it, exactly as on the web.
Because a frame-relative measurement has to survive the sections above it.
Container units bind to the nearest ancestor declaring a container, and a section
that declares its own — an ordinary thing for a component to do — would quietly
capture every frame-relative measurement beneath it. Nothing in the markup separates
that from intent, so nothing could warn you about it. vw and vh have no such
ancestor to lose: they mean the frame from anywhere in the tree.
Because geometry generalizes and a list of format ids does not. A section styled by width and aspect ratio adapts on its own to a format declared later — including one added long after you shipped the section. A design branched per format id has to be re-audited every time the acceptance set grows.
That leaves the window itself as the one thing the whole model rests on, and it is checked rather than trusted: the window witness is the frame saying so out loud when something hands it the wrong box.
Enforcement stops there, on purpose. The platform refuses what is verifiably wrong and takes no position on what is a judgment call. It will not tell you that your headline is too near the cut, that your stack is fuller than it should be, or that a photo wants more room at story ratio. Those are design decisions and they are yours; what the platform owes you is the instrument to see them, which is what the format switcher and the trim and bleed guides are. A gate that scored fit would be guessing at intent, and a gate that guesses is one you learn to route around.
How it works
Section titled “How it works”The frame box and its tokens
Section titled “The frame box and its tokens”The kit wraps each page’s sections in the frame shell — you never write it. The shell is the bleed box: the trim size extended symmetrically by the format’s bleed, and the window every host owes the document. It sets three custom properties you can read from any section:
| Token | What it holds |
|---|---|
--frame-w |
trim width, CSS px |
--frame-h |
trim height, CSS px |
--frame-bleed |
bleed on each edge, CSS px — 0 for a format that declares none |
Only a physical document format (width/height in inches or millimetres, with a
dpi) can declare bleed. Image formats and pixel-sheet document formats resolve
to a bleed of zero, so their bleed box and trim box are the same rect and everything
below still holds, as a no-op.
Physical sizes resolve at 96 CSS px per inch, always. dpi is the capture
multiplier and never changes layout: an 8.5in sheet is 816 CSS px wide whether it
prints at 150 or 300.
The shell fills its window and clips at its own edge, exactly as capture does, and
stamps the rendering format’s id on the root as a data attribute — which is what
the per-format variants below hook onto.
The frame root is the kit’s element, and the names on it are reserved. You never
write the root, and what sits on it is the kit’s: the tr-frame class, the
data-format stamp, the three geometry tokens above, the --tr-frame-window-*
custom properties, and the root’s ::after pseudo-element, which is the window
witness. Read the geometry tokens as much as you like — that is what they are for —
and repurpose none of them. Everything else, inside your own sections, is yours.
The stack
Section titled “The stack”Sections are the frame’s direct children, in composePages order, laid out as a
flex column. Each section spans the full bleed box, and none of them may shrink: a
stack that is too tall overflows the frame visibly instead of being quietly squeezed
to fit. Seeing your content spill past the edge in the preview is the intended
feedback.
Distributing slack is ordinary flex authoring against that column — flex-1 on the
section that should absorb the leftover height of a one-section tile, mt-auto on a
footer that should sit against the bottom edge. There is no frame-specific API for
it, and a section’s own contract is unchanged: the same Section primitive, the same
slots, computes, fixtures and theme a website section uses.
Styling for geometry
Section titled “Styling for geometry”Style a frame section the way you would style a page, because that is what it is.
Viewport units are the frame-relative unit. 1vw is one percent of the frame’s
width and 1vh one percent of its height, at every declared format — so a
measurement written text-[5vw] or p-[4vw] holds its proportion across a whole
family of formats with no branch at all. Most intra-family adaptation belongs here
and needs nothing else.
Query the geometry where the design genuinely changes. Width, height and
aspect-ratio media features all read the frame. Tailwind’s own width ladder is legal
and means frame width, so md:grid-cols-2 applies at frames at least that wide; name
thresholds of your own in theme.ts’s breakpoints group beside your colours and
type, or write a one-off inline as min-[600px]:grid-cols-2. The decision no width
alone can express — is this format portrait or landscape — is an aspect-ratio query.
Base plus overrides is the ordinary mobile-first ladder. Write the design
unprefixed, flip the format switcher, and fix only what actually breaks — formats
that share the base layout carry no overrides whatsoever, which is the common case
inside one family. Its semantics are the familiar ones, including the part authors
forget: a rule written at a min-height threshold for one format also fires at every
taller format. That is ordinary responsive discipline rather than a frame quirk, and
the switcher is how you see it.
Two formats with the same dimensions render identically, and that is the intent. A 1200×630 feed image and a 1200×627 one are the same design; rendering is a function of geometry, so nothing has to be said twice to make that true.
The destination escape hatch
Section titled “The destination escape hatch”Geometry cannot express one thing: two destinations that are the same size and still want different artwork. The story family is the case — Instagram story, TikTok and Reels are all 1080×1920, and each covers a different part of that rectangle with its own interface.
For that, each format id you declare is also a variant prefix, compiled from your
declaration; there is nothing to register. A format with id: "instagram-story"
gives you instagram-story:pt-32, applying to the frame root and everything inside
it when that format is the one rendering — the root’s data-format stamp is what it
selects on.
House style is geometry first, destination second: a format variant states which destination is rendering, never how big it is. If you catch yourself reaching for one to make type smaller or a grid narrower, that is geometry — write it as geometry and it stays right at the next format too, including one you have not declared yet.
A prefix that names no format the template declares would compile to nothing and say
nothing, so unknown-format-variant fails
check on it. That is what catches both a typo and the subtler case: a section
shared by two templates, prefixed for a format only one of them declares.
Bleed and trim
Section titled “Bleed and trim”Bleed is the margin of error at the cut. A trimmed sheet is never cut exactly where the artwork says, so anything meant to reach the paper’s edge must be printed past it. Trim is where the blade is aimed; bleed is how far past that line the ink runs.
Two consequences shape how you author:
- Backgrounds run to the bleed edge by default. Sections span the whole bleed
box, so a section background already extends past trim with nothing extra from you.
This is the correct print default and a no-op where bleed is zero. A background
that must stop exactly at trim is the rare deliberate case, and it insets itself
by
--frame-bleed. - Content stays inside trim, and that is your padding’s job. Text, logos, prices
and anything else the recipient must actually receive belong inside the trim box,
with real margin between them and the cut. Pad from the token rather than from a
guessed number, so the same section is safe at a format with bleed and at one
without:
p-[calc(var(--frame-bleed)+1.5rem)].
Safe areas are the same idea for screens, and the kit does not model them. A social format’s artwork is usually seen underneath the platform’s own interface — a story’s status bar and profile row along the top, its caption and call-to-action band along the bottom, a feed post’s overlaid controls. That chrome belongs to whoever is displaying the image, it differs per surface, and it changes without telling you. Keep type and logos clear of the bands you know about, using the same padding technique, and check the current keep-out guidance from the platform you are targeting rather than trusting a number you remember. Where two same-size formats differ only in the bands they lose, that difference is exactly what a format variant is for. Nothing enforces any of this for you, which is the honest answer — a keep-out zone the kit hard-coded would be wrong for some surface the day it shipped and stale for the rest within a release.
The window witness
Section titled “The window witness”The model rests on one promise: whatever renders a frame gives that document a window that is exactly the format’s bleed box. The kit does not take it on trust. A frame document is the one place holding both halves of the answer — the geometry its format declares, and the window it was actually given — so it checks them against each other, and on a mismatch paints a panel over the whole frame naming both.
Seeing that panel means the window is wrong, not your CSS. There is nothing in your section to fix. Every viewport unit and every geometry query in the frame has resolved against a box that is not the artwork’s, so what is underneath the panel is not a preview of anything and must not read as one — which is why it covers the frame rather than sitting in a corner. Note the two geometries it names and raise it against whatever rendered the frame; do not restyle around it.
It costs a correct frame nothing. When the two agree the witness is fully transparent and outside the layout, so a properly windowed page renders exactly as it would if the witness did not exist. Its mechanics are in the CSS pipeline.
The inner loop
Section titled “The inner loop”template-kit dev is where frame authoring happens. A rendered class defaults to one
editing canvas over its whole page set, so the canvas shows every page at once — one
frame per page, stacked and labeled — and you judge the artifact whole rather than a
page at a time. Beyond that, the canvas gives a frame template two controls the
website canvas does not have:
- Format — the declared acceptance set, each option naming the format’s id, its output pixels and, for a physical format, its trim size. Switching re-renders every page at that format’s exact bleed geometry. This is the home of the base-plus-overrides workflow; the platform’s own editor has no such control, because a real deliverable has exactly one chosen format.
- Trim & bleed guides — a toggle that overlays the trim line and shades the band between it and the frame edge, over every page on the canvas. It is host chrome drawn over the page, never markup, so turning it on changes nothing about the bytes rendered. This is the instrument the enforcement posture above leaves you: turn it on, look at where your content sits, decide.
The panel’s footer hands over the real artifact, through Download — the one
action a rendered class offers, here as in the platform. For an image template
it gives you every page of the canvas as one zip, each captured at the active
format’s exact pixel geometry — the archive a user receives, not a single slide
of it. For a document template it gives you the whole page set printed to a
single PDF at trim-plus-bleed geometry. Both go through the same capture
constants the platform’s own renderer uses, so page count, sheet size and where a
page breaks are all answerable before submission. Both need Playwright installed
in your workspace.
Islands, and what capture keeps
Section titled “Islands, and what capture keeps”The artifact has no runtime; the page that produced it did. An island runs
wherever a browser exists — the dev canvas, the platform’s editor canvas, and the
headless browser that takes the capture — and capture flattens whatever it has
painted into the pixels or the PDF. So a rendered class composes islands exactly as a
website does: same "use client" module, same default export, same
export const editor, same serializable props. There is no capture tier and no
second island contract to learn.
What changes is not the contract but the audience. A rendered-class island paints its final state — there is no viewer. Nobody will click it, hover it, or scroll it, so an affordance is a dead control printed onto the artifact; write the island for the one frame it gets. That is authoring judgment, not a rule: nothing gates it, because nothing can tell a deliberate final state from an unreached one.
Motion is the exception that is still gated. rendered-no-motion refuses CSS
animation and transition, and it takes no position on motion an island drives —
JS-driven movement is not statically visible, so the capture preview is the instrument
that shows you which frame flattens.
What check refuses
Section titled “What check refuses”Everything that applies to a website section applies unchanged — marker coverage, determinism, purity lint, CSS budgets. On top of that, a rendered-class template answers to four rules, and each one refuses a defect rather than a preference:
| Rule | Refuses |
|---|---|
rendered-no-fixed |
position: fixed and sticky, both of which bind wrongly in the stacked print document |
rendered-no-motion |
keyframes, animation and transition in a class that captures one still frame |
rendered-class-conflict |
one section composed by both a website template and a rendered-class one |
unknown-format-variant |
a variant prefix naming no declared format |
The CSS rules are graded over your compiled stylesheet, so a Tailwind utility and
a hand-written declaration are judged identically — a lg: prefix cannot slip
through by never appearing in a .css file.
What it is not
Section titled “What it is not”Not a container. The frame declares no size container of its own, so cqw, cqh
and container queries inside a section resolve against a container that section
declares — ordinary component styling, and the right tool when you mean this
component’s width rather than the frame’s. With no container ancestor at all they
fall back to the viewport, which is the frame; do not lean on that, because a
container declared above them later would take them without a word. Write vw and
vh when you mean the frame.
Not a fit gate. Nothing measures whether your content fits the frame, whether a headline crowds the trim line, or whether a photo is cropped well. The rigid stack makes an overflow visible and the guides make the cut visible; the judgment is yours. Runtime overflow from a customer’s own edits is a separate concern the fill caps and the editor handle, not something you gate against at authoring time.
Not a second section contract. A frame section is written exactly like a website section — same primitive, same schema, same fixtures, same theme, and now the same responsive vocabulary. What differs is the target: a website section must survive every width there is, while a frame section is designed against a declared, finite set of geometries and may not shrink to fit. That is why a section may not be composed by both kinds of template — the same markup cannot be right for an unknown geometry and for a known one.
Not a per-format set of files. There is one section, one renderer, one stylesheet. A format is a prefix on a class, not a fork of your code — and if a design truly needs a fork, that is a second template sharing sections, not a branch inside one.
Where next
Section titled “Where next”- Sections and pages in
template.ts— declaringoutput, theformatsacceptance set, and the pages a frame template renders. - The CSS pipeline — the stylesheet mechanics behind the frame shell and its witness, and where breakpoints come from.
- Preview with
dev— the canvas, the endpoints behind it, and the rest of the preview loop. - The theme model — the tokens a frame section spends, which are the same ones a website section spends.
- Run
check— the acceptance gate the rules above run in.