defineTheme
Every defineTheme option: color tokens, dark mode, fonts, radius and spacing, theme components, contrast checks and the --of-* CSS variables.
A theme's index.ts — index.tsx when it adds components — default-exports
defineTheme({...}). It lives in one of two places:
forms/<id>/theme/— the form's own look, written byopen-form create(from a reference's tokens with--theme) and then by you or your agent. The form imports it from./theme/index.ts, orindex.tsxonce it adds components, and passes it to<Form theme>. Only that form uses it.themes/<id>/— a reference for new forms, with aTHEME.mdbeside it that describes the look for people and agents. No form imports it, so editing it never changes a form.
import { defineTheme, SplitLayout } from '@open-form-build/core';
import './brand.css';
export default defineTheme({
font: '"Inter", system-ui, var(--of-cjk), sans-serif',
headingFont: 'Georgia, var(--of-cjk-serif), serif',
radius: '12px',
buttonRadius: '999px',
spacing: 'comfortable',
width: '640px',
colors: {
primary: '#0f766e', primaryForeground: '#ffffff',
background: '#f5f7f6', surface: '#ffffff',
foreground: '#0b1f1c', muted: '#5b6b68',
border: '#dbe3e1', control: '#7d8f8b', input: '#ffffff',
error: '#c0362c', success: '#15803d',
},
dark: {
primary: '#5eead4', primaryForeground: '#042f2e',
background: '#071311', surface: '#0d1d1a',
foreground: '#ecf5f3', muted: '#9bb0ab',
border: '#1f3330', control: '#5d746f', input: '#0a1816',
error: '#f97066', success: '#4ade80',
},
components: { Layout: SplitLayout },
});Tokens
Prop
Type
Colors
Every color is any CSS color — hex, rgb(), hsl(), oklch(). Keep them as
literal values: open-form theme check can't resolve var() or
color-mix(), and Studio's Adjust panel edits literals in place.
| Key | CSS variable | Used for |
|---|---|---|
primary | --of-primary | Buttons, selection, focus rings |
primaryForeground | --of-primary-fg | Text on primary |
background | --of-bg | The page behind the card |
surface | --of-surface | The card |
foreground | --of-fg | Text |
muted | --of-muted | Help text and secondary copy |
border | --of-border | Decorative edges: card, dividers, choice tiles |
control | --of-control | Outlines that identify inputs, checkboxes, radios, ratings |
input | --of-input | Background of inputs, selects and choice tiles |
error | --of-error | Error text and outlines |
success | --of-success | Success states |
Dark mode
- Without
dark, thecolorsyou set apply in dark mode too, and any color you leave out takes the runtime's dark default, so your light colors mix with built-in dark ones.open-form theme checkwarns about this. Always givedark, ordark: falsefor a light-only theme. colorScheme: 'dark'makes a dark-only brand: put the dark palette incolors.
Contrast
open-form theme check <id> measures every pair below in light and
dark (WCAG); add --form to check a form's own look, <id> being the form id.
Run it after any color change.
| Pair | Minimum |
|---|---|
foreground on surface, background and input | 4.5:1 |
muted on surface | 4.5:1 |
primaryForeground on primary | 4.5:1 |
error on surface | 4.5:1 |
control on input | 3:1 |
primary on surface | 3:1 |
border is decorative and exempt, which is why inputs are outlined with
control: keep control at 3:1 or more against input so people can find
the fields.
CSS variables
Tokens become CSS variables on the form root. Use them in theme CSS, form CSS and custom fields so every theme and dark mode keeps working.
| Variable | Default |
|---|---|
--of-font, --of-heading-font | System sans-serif |
--of-radius | 10px |
--of-button-radius | var(--of-radius) |
--of-gap | 24px |
--of-width | 640px |
--of-aside-width | 340px |
--of-primary … --of-success | The built-in palette, the same as the classic starter's |
--of-ring | primary at 24%, for soft focus halos |
--of-hover-border, --of-hover-control | Hover shades of border and control |
--of-ease | The runtime's easing curve |
--of-cjk, --of-cjk-serif | CJK fallback fonts matching the form's language |
Han characters are drawn differently in Taiwan, mainland China and Japan, so
--of-cjk follows the form's language. Put it after your Latin fonts instead
of naming one region's font. To use a web font, see
Web fonts.
Theme CSS
Import a stylesheet from the theme's index file for details tokens can't
express. Scope everything with [data-of-theme="<id>"]: a form's root carries
data-of-theme="<form-id>", so in a form's own theme the scope is the form id,
and in a reference it's the theme id. If you rename a form's folder, rename the
selectors too.
[data-of-theme="meetup"] .of-button-primary {
font-weight: 600;
letter-spacing: 0.01em;
}Most of a look can be CSS on the runtime's own markup, before any component:
| Part | Classes |
|---|---|
| Root, card, header | .of-root, .of-form, .of-header, .of-title, .of-description |
| Steps | .of-progress, .of-progress-label, .of-progress-track, .of-progress-bar, .of-page-title, .of-page-description |
| Field | .of-field (data-invalid when it has an error), .of-label, .of-required, .of-help, .of-error |
| Inputs | .of-input, .of-textarea, .of-counter, .of-input-group, .of-unit, .of-select-wrap |
| Choices | .of-choices (data-variant), .of-choice, .of-radio, .of-checkbox, .of-choice-single, .of-choice-text |
| Rating | .of-rating, .of-rating-item, .of-rating-labels |
| Buttons | .of-nav, .of-button, .of-button-primary, .of-button-secondary |
| Endings | .of-success, .of-success-icon, .of-notice, .of-eyebrow |
Glyphs belong in pseudo-elements with empty alt text, so screen readers skip
them: .of-label::before { content: "? " / ""; }. Reach for a
component only when the look needs markup or
behavior CSS can't give.
Prefixed classes such as .brand-tile aren't scoped. That only matters when
several forms share a page and style the same class differently; give one of
them a new prefix.
A form's theme is bundled with the form, on the client and in the server
bundle (without its CSS), so don't touch window or document at the top level
of theme files.