Theme and CSS
The kit's two CSS entries, how a section's own CSS reaches the page, the TokenTheme a template supplies, and the breakpoint ladder.
Your template owns its design system end to end — the token names included. You
declare the tokens your design actually has in a TokenTheme, and template-kit theme
compiles that one declaration into the Tailwind utilities that expose them. Declare a
colour named lagoon and bg-lagoon exists.
The kit owns only what is true of every template: the breakpoint ladder, the spacing base unit, the motion defaults, the reset gaps Tailwind’s preflight leaves, and the section box model. It ships no colour, font, type, radius or shadow of its own, and there is no shared file for you to edit to make a token of yours usable.
Two CSS entries ship, and they are not interchangeable.
| Entry | What it is | How it loads |
|---|---|---|
@homepages/template-kit/styles.css |
The global Tailwind preamble: breakpoint ladder, --spacing, motion defaults, and @source registration of the kit’s compiled JS |
imported into your Tailwind entry, after Tailwind |
@homepages/template-kit/base.css |
Cascade-layer order, the three reset gaps Tailwind’s preflight leaves, .tr-section, .tr-image-frame, and the motion tokens |
its own stylesheet on the page — not part of the Tailwind entry |
Your Tailwind entry
Section titled “Your Tailwind entry”/* your CSS entry */@import "tailwindcss";@import "@homepages/template-kit/styles.css";@import "./theme.css"; /* generated by `template-kit theme` from theme.ts */Three constraints, all of them silent when broken:
- Import Tailwind exactly once per build graph.
styles.cssdeliberately does not@import "tailwindcss"itself — yours does. Two double-emit Tailwind’s preflight reset (box-sizingdeclared twice, several KB of duplicate base layer), and nothing errors. - Your generated theme must be imported here, inside the Tailwind build graph. It
contains an
@themeblock, which is a Tailwind directive: anywhere outside the graph it is inert, and you get a stylesheet with no token utilities and no error explaining why. - Import it after
styles.css, so the kit’s globals are in place before your tokens land on top of them.
styles.css also carries an @source line registering the kit’s own shipped dist for
Tailwind’s class scan. That is how the utility classes baked into the kit’s primitives
(Image’s object-cover, …) reach your compiled CSS; it also overrides Tailwind’s default
exclusion of node_modules. You do not need to add a @source for the kit yourself.
TokenTheme — your template’s design system
Section titled “TokenTheme — your template’s design system”A template’s theme.ts exports a TokenTheme: six open records of tokens, plus the
document defaults that dress the page. theme.ts is the file you write; run
template-kit theme to compile it into the theme.css your Tailwind entry imports
(see Generating theme.css below).
import type { TokenTheme } from "@homepages/template-kit";
export const theme: TokenTheme = { colors: { ink: "#111418", page: "#ffffff", "surface-alt": "#f4f4f5", lagoon: "#1c6e7d" }, fonts: { sans: '"Inter", system-ui, sans-serif', display: '"Canela", Georgia, serif' }, type: { base: "1rem", "2xl": "1.5rem", display: "clamp(2.5rem, 6vw, 4.5rem)" }, radii: { md: "6px", pill: "9999px" }, shadows: { card: "0 1px 2px rgb(0 0 0 / 0.06)" }, layout: { "container-max": "1280px", "header-height": "72px" },
// Optional font-loading block, emitted ahead of everything else. fontFaces: '@import url("https://fonts.example/inter.css");',
// Which of YOUR tokens dress the document. document: { font: "sans", color: "ink", background: "page", fontSize: "base" },};Nothing is required to exist. Tailwind’s own defaults survive underneath your tokens —
text-2xl, rounded-lg, red-500 all still work — so a sparse theme is a working theme.
(The breakpoint ladder is the one exception: sm:, xl: and 2xl: are deliberately
switched off. See below.)
Namespaces and the utilities they produce
Section titled “Namespaces and the utilities they produce”Each record maps to one Tailwind theme namespace. A token’s key is emitted verbatim into both the custom property and the utility — no camelCase-to-kebab transform, so what you type is what you get.
| Record | Custom property | Tailwind utilities | Example |
|---|---|---|---|
colors |
--tr-color-<name> |
bg-, text-, border-, ring-, fill-, stroke-, … |
lagoon → bg-lagoon |
fonts |
--tr-font-<name> |
font-<name> |
display → font-display |
type |
--tr-text-<name> |
text-<name> |
2xl → text-2xl |
radii |
--tr-radius-<name> |
rounded-<name> |
pill → rounded-pill |
shadows |
--tr-shadow-<name> |
shadow-<name> |
card → shadow-card |
layout |
--tr-<name> |
(none) | header-height → var(--tr-header-height) |
layout has no Tailwind utility namespace, so its tokens get a --tr-* value and no
utility — reference them with var() from your own CSS.
Token names
Section titled “Token names”Kebab-case: [a-z0-9] groups separated by single hyphens (ink, ink-soft, bp2).
TokenNameSchema is exported if you want to validate names yourself.
One token, one utility. Two utility names cannot share a token — if you want both
bg-brand and bg-primary, declare both.
The document block
Section titled “The document block”Required. It names which of your own tokens dress the page — the body font-family,
colour, background, and font-size. font and fontSize resolve against fonts and
type; color and background resolve against colors.
Every reference is validated against your declared tokens, in its own group: a typo, or a colour name where a font belongs, fails at parse rather than rendering an unthemed page in silence.
Validation
Section titled “Validation”TokenThemeSchema validates the object at runtime, and it is .strict() — an unknown
top-level key is an error rather than a silently-dead set of tokens.
Generating theme.css
Section titled “Generating theme.css”template-kit theme loads templates/<key>/theme.ts, validates its theme export
against TokenThemeSchema, and writes templates/<key>/theme.css — the file your
Tailwind entry imports.
template-kit theme # infer the template from cwd, or the workspace's one templatetemplate-kit theme <template> # generate one named template's theme.css, by its keytemplate-kit theme --all # generate every template in the workspacetheme.css is generated — it opens with a header saying so, and it is not meant to be
hand-edited. Re-run template-kit theme after changing theme.ts and commit the
regenerated file alongside it.
A missing theme.ts, a theme.ts that doesn’t export theme, or a theme that fails
TokenThemeSchema all fail the command with a message naming the file and the problem —
nothing is written in that case.
In order, the file it writes contains:
- your
fontFacesblock, if you set one; @theme inline { --color-<name>: var(--tr-color-<name>); … }— the alias layer, which is what brings your token utilities into existence;:root { --tr-color-<name>: <value>; … }— the values behind those aliases;body { … }— yourdocumentdefaults, written asvar()indirections so a runtime:rootoverride still cascades to the page.
Deterministic: keys are sorted, so the same theme always content-hashes identically.
Colour in sections
Section titled “Colour in sections”A section carries no palette of its own, in server-rendered code or inside an island — the
template-kit/no-hex lint rule flags a hard-coded hex literal in a
colour context, and a CSS colour function anywhere, without exempting "use client" files
the way most of the preset’s other rules do. Sections spend the theme’s tokens; the theme
is where a colour is named.
For a colour the theme does not name — a hover, active, or pressed step, or a translucent
tint — derive it from the tokens that are named, with a color-mix() over theme-derived
arguments:
hover:bg-[color-mix(in_srgb,var(--tr-color-primary)_80%,var(--tr-color-surface))]. Such a
mix composes a colour out of the theme rather than stating one, and is the single colour
function no-hex allows — it carves out no other.
How a section’s CSS reaches the page
Section titled “How a section’s CSS reaches the page”A section’s compiled CSS layer is assembled from two sources, both automatic — you never write an import to make either happen:
- Hand-written CSS, collected by path. Every
.cssfile inside the section’s folder —styles.cssand any other stylesheet nested under it — is picked up by walking the folder, wherever it sits, and concatenated into@layer sections. A template’s own hand-written stylesheet is collected the same way, into@layer template. - Package CSS, harvested from the render path. The build also bundles
Renderer.tsx’s module graph — every component and island it imports — and pulls in whatever.cssthose imports resolve to. A bare specifier (import "swiper/css";) resolves intonode_modulesand lands in the same@layer sections; that is the sanctioned way for a third-party package’s stylesheet to reach the page.
Two constraints follow directly, and both are enforced by lint:
- Don’t re-import your own stylesheet. It is already collected by path —
importing it again from a
.ts/.tsxfile only double-emits it, and can smuggle a stray@layerstatement into a nested layer. Seeno-css-import-from-render-path. @importnever works inside hand-written CSS, package or local. An@importlanding inside@layer sections/@layer templateis invalid CSS, and the browser drops it silently rather than erroring — the package’s styling is simply missing from the published page. Reach a package’s stylesheet from code instead. Seeno-bare-css-import.
The page’s stylesheets
Section titled “The page’s stylesheets”A published page loads, in order:
base.css— layer order, the reset gaps,.tr-section,.tr-image-frame, motion tokens.- Your compiled Tailwind bundle — your entry:
@import "tailwindcss"+ the kit’sstyles.css+ your compiled theme + the utilities your section markup uses.
The layer order base.css declares is:
@layer theme, base, components, sections, template, utilities;Utilities come last, so a Tailwind utility always beats the reset’s element defaults.
base.css requires a Tailwind entry
Section titled “base.css requires a Tailwind entry”base.css does not ship a reset. Your entry’s @import "tailwindcss" brings
Tailwind’s preflight, and preflight is the reset — box-sizing, margins, list-style, the
button and anchor resets. base.css carries only the three declarations preflight does
not make: font-smoothing, button { cursor: pointer } (Tailwind v4 leaves buttons at
cursor: default), and svg { max-width: 100% } (preflight caps only img and
video).
Ship base.css without a Tailwind entry on the page and you get an unreset page.
base.css references no design token of yours — nothing global is opinionated about
your brand. Its one tintable surface is the placeholder fill shared by the Image and
Video frames — one class, one --tr-image-frame-bg, so you retint both at once; see
Image.
The breakpoint ladder
Section titled “The breakpoint ladder”Eight canonical widths. 350 is the mobile-first base (no prefix); the other seven are named breakpoints:
| Width | Prefix |
|---|---|
| 350 | (base — no prefix) |
| 390 | bp390: |
| 425 | bp425: |
| 512 | bp512: |
| 768 | md: |
| 1024 | lg: |
| 1440 | bp1440: |
| 1920 | bp1920: |
md and lg keep Tailwind’s own names and values. Tailwind’s off-ladder defaults
(sm:, xl:, 2xl:) are reset to initial and do not exist — a section cannot come
to depend on a width nobody verifies it at. The same widths are exported as
VERIFICATION_WIDTHS, which is the set your section is screenshotted and verified
against. A swap-heavy section may still write a raw @media query at an off-ladder width.
Recipe: content-visibility on long, image-heavy pages
Section titled “Recipe: content-visibility on long, image-heavy pages”A page that stacks many image-heavy sections can exhaust the browser’s GPU/raster
memory — enough to make it evict a live WebGL map’s layer on scroll, blanking the map.
content-visibility: auto fixes this by letting off-screen sections skip rendering, so
they stop consuming that memory.
The kit does not apply this for you — it has a real cost (below), and only you know whether your page needs it. If it does, add the rule to your Tailwind entry — the second stylesheet the page loads:
@layer template { .tr-root .tr-section:not(header):not(.my-map-section) { content-visibility: auto; contain-intrinsic-size: auto 600px; }}Layer placement matters: inside @layer template, a Tailwind utility (@layer utilities, which comes later in the layer order) can always override this rule on a
given section. Left unlayered, it would beat every layer instead, including
utilities. Neither collides today — no Tailwind utility sets content-visibility —
but wrapping it in @layer template keeps that guarantee explicit rather than
accidental.
.tr-root is set on <body> at publish time and is absent in the editor, so the rule
is published-only — and so are its consequences. You will not see them in preview.
contain-intrinsic-size: auto remembers each section’s rendered height, avoiding scroll jank.
The cost. content-visibility implies contain: paint, which clips descendants to
the section box. So:
- A full-screen overlay — a lightbox, a gallery, a modal — must be appended to
document.body, never rendered inside the section. Inside, it gets clipped. - A section that must stay painted while off-screen (a live map), or whose dropdowns
escape its box, must be excluded — add its class to the
:not()list, as.my-map-sectionis above.