Site Theming
Customize your site's colors, typography, and appearance through a theme.yml file. The theming system generates CSS custom properties at build time, enabling consistent branding across all components.
Quick Start
Create site/theme.yml:
colors:
primary: "#3b82f6"
secondary: "#64748b"That's it. Your site now has a complete color palette with 11 shades for each color, semantic tokens that adapt per-section, and Tailwind utility classes like bg-primary-500 and text-heading ready to use.
How It Works
- You define colors in
theme.yml(one hex value per palette) - Build generates CSS — 11 perceptually-uniform shades per color (OKLCH), semantic tokens for light/medium/dark contexts, font variables
- CSS is injected into
<head>at runtime (works with SSG — zero FOUC) - Content authors control sections —
theme: darkin frontmatter applies a context class, and all semantic tokens resolve accordingly
The key insight: components don't manage their own colors. They use semantic tokens (text-heading, bg-section, border-border) that resolve differently depending on the section's context. A content author writes theme: dark on a hero section, and every component inside it automatically gets light text on a dark background — no conditional logic in the component.
What You Author vs What the Build Derives
theme.yml holds only authored values — your inputs. Everything else (the 11-shade palettes, the full semantic-token set per context, the generated CSS) is derived by the build from those inputs. You never write shade ramps or CSS variables by hand, and you never need to store them anywhere — they're recomputed on every build.
This distinction matters if you ever read or generate a theme programmatically (a theme editor, a migration script, a preview tool): persist only the authored keys; recompute the rest. Storing derived shades or CSS variables alongside the authored values is redundant and drifts the moment a color changes.
Authored keys (what lives in theme.yml)
Every key is optional — an empty theme.yml (or none) yields an all-defaults theme.
| Key | Shape | Notes |
|---|---|---|
colors | Flat — { primary, secondary, accent, neutral } | One set for the whole site, not per-context. A hex string, a { base, mode } object, a full shade object, or (for neutral) a preset name. |
contexts | Sparse — { light?, medium?, dark? } | Semantic-token overrides only (e.g. link: primary-500), never base colors. Omit entirely to accept all defaults; list only the tokens you change. |
fonts | { body?, heading?, code?, import?, faces? } | Font-family strings by role + optional import/faces. Also takes foundation-declared roles by name. |
appearance | string or { default, allowToggle, respectSystemPreference, schemes? } | Site-wide light/dark scheme. |
inline | { accent?, callout?, muted?, … } | Inline text-style definitions; merged over framework defaults property by property. |
vars | { <name>: value | { light, dark } } | Foundation variable overrides (e.g. header-height) — a separate concept from color palettes. |
code | { background, foreground, keyword, … } | Syntax-highlighting colors. |
background | CSS value string | Site-level page background. |
Derived at build time (never authored, never stored)
| Derived output | Computed from | Where it appears |
|---|---|---|
Palette shades — --primary-50 … --primary-950 (× every color) | colors (11-shade OKLCH generation) | CSS variables + Tailwind classes |
Full context token set — every semantic token per light/medium/dark | framework defaults ⊕ your sparse contexts overrides | .context-* CSS classes |
| The theme CSS string | all of the above | injected into <head> at runtime |
So base colors are flat and global; contexts sit on top as token overrides that reference the palettes (link: primary-600 → var(--primary-600)). There is no per-context base-color layer, and the palette/CSS variables are outputs, not inputs.
Reading a theme in code? Import the same derivation the build uses from
@uniweb/theming(generatePalettes(colors)for shade ramps,buildTheme(themeYml)for the fully-merged config + CSS) rather than re-implementing shade math. That keeps any tool you build in lockstep with what the site actually renders.
Color Palettes
Define brand colors with a single hex value — shades are auto-generated:
colors:
primary: "#3b82f6" # Main brand color
secondary: "#64748b" # Supporting color
accent: "#8b5cf6" # Highlight color
neutral: "#78716c" # Text, backgrounds, bordersGenerated Shades
Each color generates 11 shades as CSS variables (--primary-50 through --primary-950):
| Shade | Lightness | Use Case |
|---|---|---|
| 50 | 97% | Subtle backgrounds |
| 100 | 93% | Hover backgrounds |
| 200 | 87% | Active backgrounds |
| 300 | 78% | Borders |
| 400 | 68% | Placeholder text |
| 500 | (input) | Your exact color (shades redistribute around it) |
| 600 | 48% | Primary buttons, links (light context default) |
| 700 | 40% | Pressed states |
| 800 | 32% | Dark accents |
| 900 | 24% | Near-black |
| 950 | 14% | Darkest |
Generation Modes
Control how shades are generated with the mode option:
colors:
primary:
base: "#3b82f6"
mode: natural # 'fixed', 'natural', or 'vivid'| Mode | Hue | Chroma | Best For |
|---|---|---|---|
fixed | Constant | Linear scaling | Design systems, accessibility |
natural | Temperature-aware shifts | Bézier curve (1.1x boost) | Organic, artistic palettes |
vivid | Subtle shifts | High boost (1.4x) | Bold marketing, gaming |
Fixed (default): Predictable results with constant hue across all shades. Best for design systems where consistent contrast ratios matter.
Natural: Warmer colors (reds, oranges) shift cooler in light shades and warmer in dark shades. Cool colors do the opposite. Creates more organic-feeling palettes.
Vivid: Maximum saturation with dramatic chroma curves. Colors stay vibrant even at light and dark extremes.
Exact Brand Color Matching
By default, shade 500 is your exact input color. The build redistributes surrounding shades proportionally to maintain a smooth, monotonic lightness scale.
To opt out and use fixed lightness values instead (shade 500 will be adjusted to lightness 55%, which may differ from your input):
colors:
primary:
base: "#E35D25"
exactMatch: false # Use fixed lightness scaleUsing Your Brand Color on Buttons
Buttons and links in light contexts use shade 600 (not 500) for accessibility — shade 600 provides better contrast with white foreground text. To use your exact brand color on buttons, override the primary token:
colors:
primary: "#E35D25"
contexts:
light:
primary: primary-500 # Your exact color on buttons
primary-hover: primary-600 # Darker shade on hoverAccessibility note: Bright brand colors (orange, yellow, light green) at shade 500 may not meet WCAG contrast requirements (4.5:1) with white text. Test your buttons for readability.
Using Colors
Palette colors are available as both CSS variables and Tailwind classes.
CSS variables (short names, set by the build):
.my-button {
background: var(--primary-600);
color: white;
}
.my-button:hover {
background: var(--primary-700);
}Tailwind classes (via theme-tokens.css bridge):
<button className="bg-primary-600 hover:bg-primary-700 text-white">
Click me
</button>Pre-defined Shade Objects
For precise control, provide your own shade values:
colors:
brand:
50: "#fef2f2"
100: "#fee2e2"
200: "#fecaca"
300: "#fca5a5"
400: "#f87171"
500: "#ef4444"
600: "#dc2626"
700: "#b91c1c"
800: "#991b1b"
900: "#7f1d1d"
950: "#450a0a"Color Contexts
Contexts define semantic color tokens for different section backgrounds. Content authors apply them via the theme frontmatter parameter:
---
type: Hero
theme: dark
---Default Contexts
Three contexts are available by default:
| Context | Background | Text | Use Case |
|---|---|---|---|
light | White | Dark gray | Default sections |
medium | Light gray | Dark gray | Alternating sections |
dark | Dark gray | White | Hero sections, footers |
Semantic Tokens
Each context defines CSS variables that components use instead of hardcoded colors. Components using these tokens adapt automatically when a content author changes the section's theme: in frontmatter — no conditional logic needed.
Text:
| Token | Purpose | Tailwind Class |
|---|---|---|
--heading | Heading text | text-heading |
--body | Body text | text-body |
--subtle | Secondary/hint text | text-subtle |
Surfaces:
| Token | Purpose | Tailwind Class |
|---|---|---|
--section | Section background | bg-section |
--card | Card/well background | bg-card |
--muted | Hover/zebra background | bg-muted |
Borders:
| Token | Purpose | Tailwind Class |
|---|---|---|
--border | Primary borders | border-border |
--border + opacity | Subtle borders | border-border/50 |
--ring | Focus rings | ring-ring |
Links:
| Token | Purpose | Tailwind Class |
|---|---|---|
--link | Link text | text-link |
--link-hover | Link hover | hover:text-link-hover |
Buttons:
| Token | Purpose | Tailwind Class |
|---|---|---|
--primary | Primary button background | bg-primary |
--primary-foreground | Primary button text | text-primary-foreground |
--primary-hover | Primary button hover | hover:bg-primary-hover |
--primary-border | Primary button border | border-primary-border |
--secondary | Secondary button background | bg-secondary |
--secondary-foreground | Secondary button text | text-secondary-foreground |
--secondary-hover | Secondary button hover | hover:bg-secondary-hover |
--secondary-border | Secondary button border | border-secondary-border |
Customizing Contexts
Override semantic tokens per context:
contexts:
light:
section: white
link: primary-600
border: neutral-200
dark:
section: primary-900 # Use primary color instead of neutral
link: accent-300 # Use accent for links in dark sectionsPalette references like primary-600 resolve to var(--primary-600) automatically. Plain CSS values (white, #hex, rgb(...), var(...)) pass through as-is.
Context Value Mappings
The following table shows how each semantic token resolves in each context. These defaults derive from the neutral and primary palettes set in theme.yml:
| Token | context-light | context-medium | context-dark |
|---|---|---|---|
--section | neutral-50 | neutral-100 | neutral-900 |
--card | neutral-100 | neutral-200 | neutral-800 |
--body | neutral-950 | neutral-950 | neutral-50 |
--subtle | neutral-600 | neutral-700 | neutral-300 |
--heading | neutral-900 | neutral-900 | white |
--link | primary-600 | primary-600 | primary-400 |
--border | neutral-200 | neutral-300 | neutral-700 |
--primary | primary-600 | primary-600 | primary-500 |
--primary-foreground | white | white | white |
--primary-hover | primary-700 | primary-700 | primary-400 |
--primary-border | transparent | transparent | transparent |
--secondary | white | white | neutral-800 |
--secondary-foreground | neutral-900 | neutral-900 | neutral-100 |
--secondary-hover | neutral-100 | neutral-100 | neutral-700 |
--secondary-border | neutral-300 | neutral-300 | neutral-600 |
Sites can override any of these mappings under the contexts: key (see above).
A Section's Theme
A section's theme: is theme.yml for that one section — the same keys, scoped to it — plus the one thing only a section has: mode, the color context it pins.
---
type: CTA
theme: dark # the common case: pin the dark context
---theme: dark is the shorthand for theme: { mode: dark }. Left out, the section follows the site: light under a light scheme, dark under a dark one.
The object form takes theme.yml's keys:
---
type: Hero
theme:
mode: dark # light | medium | dark — leave it out to follow the site
colors: # like theme.yml `colors` — a palette for this section
primary: '#0a6'
contexts: # like theme.yml `contexts` — token overrides per context
dark: { link: primary-300 }
light: { link: primary-700 }
vars: # like theme.yml `vars` — the foundation's variables
header-height: 5rem
heading: neutral-100 # a token beside `mode` — this section's, in any context
---| Key | Like theme.yml's | What it does for the section |
|---|---|---|
mode | — (a section's own) | Pins its color context |
colors | colors | A palette, with shades generated as for the site (--primary-500, …) |
contexts | contexts | Token overrides per context. A pinned section uses its own context's; one that follows the site uses light under a light scheme and dark under a dark one |
vars | vars | The foundation's variables; a { light, dark } value follows the context |
| any other key | — | A token in any context — the short form for a section with one context |
A context's own token beats one written beside mode. Framework applies all of it, scoped to the section — the tokens in effect in the section's context inline on the section, the palette, the per-context values and the variables in the page's stylesheet — and components don't need to know: they use bg-primary, text-heading and the other tokens and get the section's values.
A component's own CSS variables — the ones its meta.js declares in vars: — are set with the section's vars: key, beside theme:.
Page Background
The page behind your sections. It defaults to var(--section) — the same token the sections use — so the canvas matches what sits on it and follows the visitor's light/dark choice without you doing anything.
You only need to set it when you want the page to differ from its sections:
# theme.yml
background: linear-gradient(180deg, var(--section), var(--muted))Any CSS background value works, including an image. Note that a literal colour will not change with the scheme — use a token, or a light-dark() value, if the page should follow it.
Why this exists as its own setting. Sections paint themselves, so a page whose sections run the full width has no visible canvas at all. A layout with columns does: a documentation shell paints its rails and its article, and the space between them is the page.
Section Backgrounds
Sections can also declare a background in frontmatter, independent of the theme context:
---
type: CTA
theme: dark
background:
color: var(--primary-600)
---This gives the section a branded background with all the dark context's text and link colors. The runtime renders the background behind the component — the component doesn't need to know about it.
Background accepts several formats:
# Solid color
background:
color: var(--primary-600)
# Gradient
background:
gradient: linear-gradient(135deg, var(--primary-600), var(--primary-800))
# Image (shorthand — a path or URL is treated as an image)
background: /images/hero.jpg
# Image with overlay
background:
image: /images/hero.jpg
overlay: 0.5
# Video (autoplays muted, loops)
background:
video: /videos/hero.mp4Video backgrounds autoplay muted and loop. If you provide an .mp4, the runtime also tries .webm for better compression where supported.
Typography
Configure font families and imports:
fonts:
body: "Inter, system-ui, sans-serif"
heading: "Poppins, system-ui, sans-serif"
code: "Fira Code, monospace"
import:
- url: "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700"
- url: "https://fonts.googleapis.com/css2?family=Poppins:wght@600;700"The framework wires these three roles onto elements for you: body → body, heading → h1, h2, h3, code → code, pre, kbd, samp. (code was formerly named mono; rename any fonts.mono to fonts.code — it is no longer an alias.)
When font imports are present, the build injects the stylesheet <link> for each import into <head>, preceded by a <link rel="preconnect"> for its origin so the DNS/TLS handshake overlaps the stylesheet request rather than following it. Multiple Google Fonts imports are merged into a single css2 request, and Google gets a second preconnect for fonts.gstatic.com (where the font files themselves are served, separate from the CSS endpoint).
Only families you actually reference in fonts: are requested — an import for a family no unused role points at is dropped rather than downloaded.
Generated CSS variables:
:root {
--font-body: Inter, system-ui, sans-serif;
--font-heading: Poppins, system-ui, sans-serif;
--font-code: Fira Code, monospace;
}Foundations may also expose additional typefaces of their own (an editorial font-serif, a display face) as font variables. Set those under vars: and load the family with import / faces exactly as above — see the foundation's theme settings for the names it offers.
Self-hosted fonts
To serve fonts from your own origin instead of a CDN, place the files under site/public/fonts/ and declare @font-face faces:
fonts:
heading: "Söhne, sans-serif"
faces:
- { family: "Söhne", src: /fonts/soehne-bold.woff2, weight: 700, style: normal }
- { family: "Söhne", src: /fonts/soehne-regular.woff2, weight: 400, style: normal }| Field | Required | Notes |
|---|---|---|
family | yes | Must match the name referenced by a font role (body/heading/code, or a foundation font var) |
src | yes | Path from the site root (public/fonts/x.woff2 → /fonts/x.woff2) |
weight | no | Defaults to 400 |
style | no | Defaults to normal |
format | no | Inferred from the file extension (.woff2 → woff2) |
The build emits one @font-face rule per face plus a <link rel="preload"> hint, and filters out any face whose family no font role references — an unused face costs nothing. Consequently a faces: block with no matching role (body/heading/code, or a foundation font var) produces no CSS at all.
Write src as a path from the site root even when the site is deployed under a subdirectory (base: /docs/, a GitHub Pages project site). The build prefixes the base for you, so the same theme.yml works at the root and under a prefix. A src that is already absolute (https://…, //…) is left alone.
Fonts beyond the three roles
A design sometimes needs a typeface beyond body/heading/code — an editorial serif for pull-quotes, a display face for a hero. A foundation declares these as font roles of its own (a type: 'font' variable; see Foundation Variables). You set them exactly like the built-in roles — by name in the fonts: block:
# site/theme.yml
fonts:
serif: "Fraunces, Georgia, serif" # a foundation-declared role, set by name
import:
- url: "https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,600"The family loads and applies wherever the foundation wired it. (vars: { font-serif: … } sets the same role — serif and font-serif are one font var; the font- spelling is the one Tailwind's font-serif utility reads. A custom name like font-display with no built-in utility is referenced by the foundation with var(--font-display).)
The three built-in roles are themselves just defaults — a foundation can retarget which elements a role paints (its applyTo) or add roles — so the fonts a given foundation exposes are part of its theme surface. Set whatever roles it offers in fonts: by name.
Long-form text
Articles, documentation and other long-form text read their size and rhythm from vars:, when the foundation styles them with kit's prose stylesheet:
# site/theme.yml
vars:
prose-size: 1.0625rem # the reading size (1rem); larger and smaller text scale from it
prose-leading: 1.7 # line height
prose-measure: 70ch # line length (65ch)
prose-flow: 1.4em # space between paragraphs and lists
prose-h2: 1.6em # a heading's size, relative to the text — prose-h1 … prose-h4Leave one out and the foundation's design decides it: each text size a component uses keeps its own line height, spacing and heading sizes until you set them. Colors need no setting here — long-form text takes the section's colors from your theme, light and dark alike.
Write each with its unit, as above — prose-flow: 1.5 is not a length, and a value the browser cannot read is dropped without a word: the spacing goes to nothing. prose-measure reaches text the foundation leaves at a line length; a component that fills its column sets its own width, and there it changes nothing.
Code Block Syntax Highlighting
Code blocks in markdown are syntax-highlighted with Shiki. Sixty-five themes ship in the box, so the usual answer is to name one:
code: draculaThat is the whole setting. Some of what is available: github-light, github-dark, dracula, nord, catppuccin-latte, catppuccin-mocha, tokyo-night, one-dark-pro, ayu-dark, vitesse-light, vitesse-dark, solarized-light, solarized-dark, monokai, min-light, min-dark. The full list is Shiki's theme catalogue.
Following light and dark
Name a theme per scheme and listings switch with the visitor's choice:
code:
light: github-light
dark: github-darkGive only one side and both use it — which is what you want when code should stay dark on a light page, the way a terminal does.
Adjusting a theme
Any of the colours below, set alongside theme:, override that part of it. "That theme, on my own surface" is the common case:
code:
theme: github-dark
background: '#0D0D0D' # the site's own terminal surfaceOmit theme: and the overrides apply to the default (github-dark).
Each side of a pair takes the same adjustments:
code:
light: { theme: github-light, background: '#FFFDF9' }
dark: { theme: github-dark, background: '#0D0D0D' }Naming every colour
The full set, for a site that wants to hand-pick. Anything left out keeps the base theme's value — you never have to specify all of them:
code:
theme: github-dark # optional base; omit for the default
background: '#1e1e2e' # the block's surface
foreground: '#cdd6f4' # text with no more specific colour
comment: '#6c7086'
string: '#a6e3a1'
keyword: '#cba6f7' # if, else, function, return
operator: '#89dceb'
function: '#89b4fa'
variable: '#f5e0dc'
number: '#fab387'
constant: '#f38ba8' # true, null, named constants
type: '#f9e2af'
property: '#94e2d5' # object keys
tag: '#89b4fa' # HTML/JSX element names
attribute: '#f9e2af'
punctuation: '#9399b2'Reach for this last. Picking a dozen syntax colours that stay legible together is a real design job, and the bundled themes have already done it.
The build names anything in code: it can't use — a misspelled key, a theme name that isn't one, a colour that isn't one — and carries on without it. A colour may be any CSS colour: a hex value, rgb(), oklch(), a named colour or var(--…).
Shiki is lazy-loaded — nothing is downloaded until a page actually contains a code block. Until it arrives, a block shows its code as plain text in the same box, so nothing on the page moves when the colors come in. The box already has its theme's background and text color when they are known without loading the theme: the default theme's, or the background and foreground you declare. A theme named without them, or a light/dark pair, shows a muted surface until it loads, so declare both beside a named theme to paint them from the start.
Appearance (Light/Dark Mode)
Control site-wide color scheme preferences:
appearance:
default: light # 'light', 'dark', or 'system'
allowToggle: true # Let visitors switch modes
respectSystemPreference: true
schemes: [light, dark] # Available schemesSimple Shorthand
For simple cases, use a string:
appearance: light # Fixed light mode, no toggle
appearance: dark # Fixed dark mode, no toggle
appearance: system # Follow system preferenceWhich scheme a visitor gets
A site has a dark scheme whenever it offers a toggle, defaults to dark or system, or lists dark in schemes: — any one is sufficient. The dark CSS is generated on the same condition, so a scheme is never applied without matching rules.
Resolution order, on every page load:
- A scheme the visitor chose previously (persisted in
localStorage). - Their OS preference, unless
respectSystemPreference: false. default:.
respectSystemPreference defaults to true. That makes default: a fallback rather than a guarantee — a dark-OS visitor gets dark on first visit even under default: light. Set respectSystemPreference: false to pin the default.
Rendering a toggle
The runtime resolves the scheme and writes scheme-dark / scheme-light onto <html> before the page paints — on prerendered pages via an inline script in <head>, and in the SPA before the first render. There is no flash of the wrong scheme, and no work for you to do.
A component only renders the button:
import { useAppearance } from '@uniweb/kit'
function DarkModeToggle() {
const { scheme, toggle, canToggle } = useAppearance()
if (!canToggle) return null // hidden unless the site enables toggling
return <button onClick={toggle}>{scheme === 'dark' ? 'Light' : 'Dark'}</button>
}Don't write the scheme class or read localStorage yourself. useAppearance() already reads the applied scheme and persists the visitor's choice. A component that also writes to document.documentElement becomes a second writer racing the runtime's — which is exactly the class of bug that produces a toggle needing two clicks, or a site stuck in one scheme. If a toggle isn't behaving, the cause is in the theme config (usually the site has no dark scheme, so canToggle is false), not something to patch around in the component.
Foundation Variables
Foundations can define customizable CSS variables that sites override:
Foundation Definition
In src/main.js:
export const vars = {
'header-height': {
default: '4rem',
description: 'Fixed header height',
},
'max-content-width': {
default: '80rem',
description: 'Maximum content width',
},
}Site Override
In site/theme.yml:
vars:
header-height: 5rem
max-content-width: 72remTailwind Integration
Foundations using Tailwind CSS v4 import a bridge file that maps theme variables to Tailwind's namespace:
@import "tailwindcss";
@import "@uniweb/kit/theme-tokens.css";This single import registers both semantic tokens and palette shades as Tailwind utilities. The build system generates short CSS variable names (--primary-600, --heading, --section), and theme-tokens.css bridges them to Tailwind's --color-* namespace so that classes like bg-primary-600, text-heading, and bg-section work.
Why is this bridge needed? Tailwind v4 builds at compile time, but theme values arrive at runtime (from theme.yml). The bridge file registers variable names with fallback defaults so Tailwind can generate the utility classes. At runtime, the real values from the theme CSS override the fallbacks.
Foundations that want different Tailwind names can skip the import and declare their own @theme inline block.
Programmatic Access
Kit provides hooks for rare cases where components need runtime access to theme data — color pickers, theme previews, or dynamic style computation:
import { useThemeData, useThemeColor, useThemeColorVar, useColorContext, useAppearance } from '@uniweb/kit'
const theme = useThemeData() // Full Theme object
const primaryColor = useThemeColor('primary', 600) // Actual oklch value
const primaryVar = useThemeColorVar('primary', 500) // 'var(--primary-500)'
const context = useColorContext(block) // 'light', 'medium', or 'dark'Most components don't need these hooks. Semantic tokens and Tailwind classes cover the common cases.
Complete Example
# site/theme.yml
# Brand colors
colors:
primary: "#0066cc"
secondary: "#475569"
accent: "#dc2626"
neutral: "#78716c"
# Section contexts
contexts:
light:
section: white
link: primary-600
dark:
section: primary-900
link: primary-300
# Typography
fonts:
body: "Inter, sans-serif"
heading: "Inter, sans-serif"
import:
- url: "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700"
# Dark mode support
appearance:
default: light
allowToggle: true
respectSystemPreference: true
schemes: [light, dark]
# Foundation overrides
vars:
header-height: 4.5rem
max-content-width: 76remSection Wrapper
The runtime wraps each component in a <section> element with context classes. Components don't render their own <section> — they render the inner content:
<!-- Runtime output -->
<section class="context-dark py-16 md:py-24" id="section-cta">
<!-- Your component renders here -->
<div class="max-w-6xl mx-auto px-6">
<h2 class="text-heading">...</h2>
</div>
</section>Section-level styles — vertical padding, borders — go on the wrapper via Component.className. The component's JSX only needs a content-constraint div:
function CTA({ content }) {
return (
<div className="max-w-6xl mx-auto px-6 text-center">
<h2 className="text-heading text-3xl font-bold">{content.title}</h2>
<p className="text-subtle mt-4">{content.paragraphs[0]}</p>
</div>
)
}
CTA.className = 'py-16 md:py-24 border-b border-border'
export default CTAYou can also change the wrapper element with Component.as:
Header.as = 'nav' // <nav> instead of <section>If your component also renders <section>, you get nested sections — semantically wrong and potentially competing for background/padding control.
Advanced Patterns
Most components should use semantic tokens exclusively. Two patterns legitimately need to step outside the context system.
Floating overlays
A Header doesn't live inside a context — it floats across them. When it's transparent over a dark hero, the text must be white. When the user scrolls and it gets a white background, the text must be dark. When a dropdown opens, the panel is always light.
These are absolute visual requirements, not contextual ones:
// Floating header — defines its own visual context
const getHeaderStyles = () => {
if (isFloating && !scrolled && isDarkBackground) {
return 'bg-transparent text-white'
}
return 'bg-white shadow-sm text-gray-900'
}
// Dropdown — always its own light panel
<div className="bg-white rounded-lg shadow-lg border border-gray-100">Even in the Header, normal-state nav links can use semantic tokens:
// Non-floating nav links — these DO live in a context
return 'text-subtle hover:text-body'The principle: use semantic tokens for elements that inherit from their surroundings. Use hardcoded colors for elements that define their surroundings.
Self-contexting components
A component that renders its own background — like a Hero with a gradient variant — creates a dark visual context that the runtime doesn't know about. The solution is to set the context directly:
const isGradient = variant === 'gradient' && !block.hasBackground
return (
<div className={cn(
'relative py-20 px-6',
isGradient && 'bg-gradient-to-br from-primary-700 via-primary-800 to-primary-950 context-dark'
)}>
<h1 className="text-heading">{title}</h1>
<p className="text-subtle">{description}</p>
</div>
)Adding context-dark on the component's own root element overrides the wrapper's context class via CSS proximity. This is justified but exceptional — most components should never set their own context. When the content author sets a background via frontmatter, block.hasBackground is true and the runtime handles it normally.
Developing with Semantic Tokens
Components use tokens like text-heading and bg-card, and those tokens need a site to activate the theming pipeline:
site/theme.yml → build generates palette + context CSS → tokens resolve → components have colorsNo site, no theme.yml, no colors. This is why even the simplest project starts with two packages — a site and a foundation. The site might be purely a testbed — throwaway content, a quick theme.yml with a single primary color. Its job during development is to give the build system something to generate CSS from.
To verify your components truly adapt, change the primary color in theme.yml and rebuild. Everything branded — buttons, links, accents, badges — should shift without touching any component code. If something doesn't change, you've found a hardcoded color that should be a token.
Best Practices
Start with primary: Define at least a
primarycolor — it's the foundation of your paletteUse semantic tokens: Reference context tokens (
text-heading,bg-section,border-border) in components instead of hardcoded colors. They adapt automatically to section themes and site-wide appearance.Leverage frontmatter: Section appearance is controlled by content authors through
theme:andbackground:in frontmatter, not by component params. Components render; the runtime applies context.Keep fonts minimal: Load only weights you actually use to optimize performance
See Also
- Site Configuration — Full site.yml reference
- Page Configuration — Section theme parameter
- Thinking in Contexts — How section contexts and site schemes work together
- Component Metadata — Full meta.js schema