Skip to content
HomePagesHomePages template kit
Menu

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 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.css deliberately does not @import "tailwindcss" itself — yours does. Two double-emit Tailwind’s preflight reset (box-sizing declared 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 @theme block, 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.)

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-, … lagoonbg-lagoon
fonts --tr-font-<name> font-<name> displayfont-display
type --tr-text-<name> text-<name> 2xltext-2xl
radii --tr-radius-<name> rounded-<name> pillrounded-pill
shadows --tr-shadow-<name> shadow-<name> cardshadow-card
layout --tr-<name> (none) header-heightvar(--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.

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.

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.

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.

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.

Terminal window
template-kit theme # infer the template from cwd, or the workspace's one template
template-kit theme <template> # generate one named template's theme.css, by its key
template-kit theme --all # generate every template in the workspace

theme.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:

  1. your fontFaces block, if you set one;
  2. @theme inline { --color-<name>: var(--tr-color-<name>); … } — the alias layer, which is what brings your token utilities into existence;
  3. :root { --tr-color-<name>: <value>; … } — the values behind those aliases;
  4. body { … } — your document defaults, written as var() indirections so a runtime :root override still cascades to the page.

Deterministic: keys are sorted, so the same theme always content-hashes identically.

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.

A section’s compiled CSS layer is assembled from two sources, both automatic — you never write an import to make either happen:

  1. Hand-written CSS, collected by path. Every .css file inside the section’s folder — styles.css and 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.
  2. 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 .css those imports resolve to. A bare specifier (import "swiper/css";) resolves into node_modules and 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/.tsx file only double-emits it, and can smuggle a stray @layer statement into a nested layer. See no-css-import-from-render-path.
  • @import never works inside hand-written CSS, package or local. An @import landing inside @layer sections / @layer template is 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. See no-bare-css-import.

A published page loads, in order:

  1. base.css — layer order, the reset gaps, .tr-section, .tr-image-frame, motion tokens.
  2. Your compiled Tailwind bundle — your entry: @import "tailwindcss" + the kit’s styles.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 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.

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-section is above.