Theming and branding
Coras styling is a CSS-first, four-tier token system. Every token is a CSS custom property, scoped to the mount container, so two mounts on one page can be themed independently. Nothing is compiled into JavaScript.
This page is a reference for the token catalog and the brand keys. To apply a brand, see Set the brand.
Token tiers
Section titled “Token tiers”Tokens resolve from raw primitives down to per-component values. Later tiers reference earlier ones.
palette -> brand -> semantic -> components| Tier | Holds | Override to |
|---|---|---|
| palette | Raw color primitives, for example --coras-palette-primary-500. |
Replace the whole color system. |
| brand | The brand tokens, one per brand.json key, for example --coras-brand-primary, --coras-brand-radius. |
Apply a brand identity. |
| semantic | Role tokens, for example --coras-color-primary-button, --coras-color-primary-tint, --coras-radius-lg. |
Repurpose a role across the system. |
| components | Per-element tokens, for example --coras-button-primary-background, --coras-navbar-shadow. |
Change one component. |
Each colour family emits exactly two tones: the base (--coras-color-primary, --coras-color-error, …) and one pale fill (--coras-color-primary-tint; --coras-color-error-background for the status families, shared by alerts and tags). Both are explicit brand values - nothing is derived at render time. The fill is derived from the base once, when a brand is published, so a rebrand stays coherent while every colour remains directly settable.
CSS cascade layers keep tier precedence stable, declared in this order:
@layer coras.palette, coras.brand, coras.semantic, coras.components;All tiers are defined on the selector :where([data-coras-root], .coras-theme). mount() sets data-coras-root on the element it creates. The .coras-theme class scope is used by the branding plugin and standalone usage.
Set the brand
Section titled “Set the brand”You can set brand tokens in three ways. Pick one based on how much control you need.
config.theme (typed)
Section titled “config.theme (typed)”Pass theme in the mount config. theme takes a brand.json - the shared,
portable branding contract from @coras-io/brand-tokens
that also drives the Coras microsites and emails. The file is grouped by the
surface each value colours, and the SDK writes each value as a scoped
--coras-brand-* variable on the mount container.
import { mount } from "@coras-io/embed";import brand from "./brand.json";
const app = mount({ container: document.querySelector("#coras"), page: "landing", config: { apiUrl: "https://api.example.com", distributorId: "your-distributor-id", assetsUrl: "https://assets.coras.io/shared", theme: brand, },});An inline object works the same way. Either the grouped shape or the engine’s
flat keys are accepted, and any supported schemaVersion is read:
theme: { primary: "#4657d4", secondary: "#161c44", radius: "1rem", navbar: { background: "#161c44", text: "#ffffff" },}See brand.json keys for the full list, and The brand.json file for the schema, versioning and what the file holds.
Raw CSS
Section titled “Raw CSS”Scope any token to [data-coras-root] to override that token and everything downstream of it. Use this to reach the semantic and component tiers, which config.theme does not expose.
[data-coras-root] { --coras-brand-primary: #4657d4; --coras-navbar-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);}Dark mode
Section titled “Dark mode”Set config.colorScheme to "light" (the default), "dark", or "inherit". The SDK sets the mount root’s color-scheme; "inherit" sets none, so the embed follows the host page’s own color-scheme and behaves like a native element. A page that declares color-scheme: light dark passes the visitor’s OS preference on to the embed that way.
const app = mount({ container: document.querySelector("#coras"), page: "landing", config: { apiUrl, distributorId, assetsUrl, theme: brand, colorScheme: "inherit", },});
// Flip it live - no remount; the light-dark() pairs already carry both modes.app.update({ config: { colorScheme: "dark" } });Every mode-varying token ships as a light-dark() pair, so the switch is just the root’s color-scheme. Coras supplies a complete dark theme out of the box (dark surfaces, light text, dark navbar/footer, contrast-tuned status colours).
To customise the dark values, add a dark block, grouped the same way as the rest of the file. Each key you set replaces that token in dark mode, and anything you omit falls back to the Coras dark default. Colours and gradients only: light-dark() is colour-only, so radius and the font keys cannot vary by mode and are rejected here. The dark logo is the top-level logoDark, not dark.logo.
theme: { primary: "#4657d4", page: { background: "#ffffff" }, // light dark: { page: { background: "#0b1020" }, // dark override; cards, text, etc. use defaults },}A gradient cannot sit inside light-dark(), so its dark value travels as a
second variable (--coras-brand-navbar-background-image-dark) and the navbar
and footer pick it themselves from the scheme they resolve, reflected on the
element as data-color-scheme. A brand that sets only the light gradient keeps
it in dark mode.
brand.json keys
Section titled “brand.json keys”The file is grouped by surface. The brand’s identity (colours, logos, type,
corner radius) sits at the top level; every surface is a group whose members
use one vocabulary:
background, backgroundImage, text, icon, link, border, shadow,
radius, logo, and the button states hover and pressed. Every value is
optional and falls back to the Coras default.
Each value also has a flat name, the key, which is what the engine uses:
navbar.background is the key navbarBackground, the CSS variable
--coras-brand-navbar-background, and the navbarBackground property of a
resolved brand in email. config.theme accepts either form.
| Group | brand.json paths |
|---|---|
| Identity | primary (#4657d4), secondary (#161c44), primaryTint / secondaryTint (pale fills, derived), logo, logoDark, logoHeight (2rem; the logo keeps its own aspect ratio), font (Inter + system sans stack), headingFont (follows font), headingScale (1, a multiplier on the heading sizes), fontFaces, radius (1.5rem, capped at 96px) |
page |
background (#ffffff), text (#161c44), textMuted (#888888), link (follows button.textButton), icon (follows page.text), divider (#e6e6e6), focusRing (follows primary) and scrim (#000000, the overlay dim) |
card |
background (#fcfcff), border (#e6e6e6), shadow, softShadow (the container shadow) |
field |
background (follows page.background in light mode; in dark a field stays on the dark page), border (#cccccc), radius (0.5rem) |
menu |
background (follows page.background; in dark it takes the card surface), border (follows card.border) |
primaryButton |
background (follows primary), text (#ffffff), border (transparent), hover, pressed |
secondaryButton |
background (follows secondary), text (#ffffff), border (transparent), hover, pressed |
button |
textButton (the text-only button, follows primary), disabled (#aaaaaa), shadow, radius (follows radius), hoverOnLightFill / hoverOnDarkFill (20%, the overlay strength for the hover and pressed states) |
chip, search |
chip.radius (follows radius), search.radius (100px, a pill) |
navbar |
background (#ffffff), backgroundImage (a gradient, none), text (#000000), icon (follows navbar.text), border (follows navbar.background), shadow, logo |
footer |
background (#e6e6e6), backgroundImage (a gradient, none), text (#000000), link (follows footer.text), border (follows footer.background), paddingBottom (2rem), logo |
status |
error / success / warning / info + one *Background each (errorBackground etc., the pale fill behind alerts and tags) |
neutral |
50 … 900 - the grey ramp behind hairlines, skeletons and disabled fills. The ramp inverts in dark mode, so neutral.50 drives the darkest dark-mode grey |
The button hover and pressed values are settable, but you normally should
not: they are derived from the button’s own fill and the two
button.hoverOn*Fill strengths. A fill lighter than mid-grey darkens under a
black overlay; a darker fill lightens under a white one. That choice follows the
fill, not the page’s colour scheme, so a navy button still lightens on a light
page.
All colour values accept hex, rgb(), or hsl(). A shadow is none or
comma-separated layers of [inset] <x> <y> [<blur> [<spread>]] <colour>, the
colour last. A backgroundImage is none or a gradient. Lengths
are CSS lengths.
page.text defaults to navy (#161c44), the brand’s body-text colour; pure black is reserved for chrome (navbar.text / footer.text). The navbar and footer groups are a dedicated chrome surface that distributors customise heavily, so they are settable independently of the rest of the theme.
The logos are paths or URLs, not CSS variables. logo is the mark for light backgrounds (the e-ticket PDF is always light, so every brand needs one), logoDark for dark backgrounds, and navbar.logo / footer.logo are keyed to their surface for a chrome that is dark on a light page. fontFaces is an array of { weight, url, style? } self-hosted webfonts that the SDK turns into @font-face rules. When the managed navbar has no host-slotted brand element, it renders the logo at logoHeight - see Chrome.
Gradients
Section titled “Gradients”navbar.backgroundImage and footer.backgroundImage paint a gradient over the
surface’s solid background. The solid stays authored and stays in charge of
everything that cannot paint a gradient: the mobile menu, the borders that
follow the background, the contrast check, and the email footer. So a gradient
brand sets both, and the Studio keeps the solid in step with the first stop.
"navbar": { "background": "#250854", "backgroundImage": "linear-gradient(90deg, #250854 0%, #503974 50%, #7d6d97 100%)"}The grammar is deliberately small: linear-gradient() with an angle or
to <side>, or radial-gradient() with a shape, size and at <position>,
and stops that are a hex, rgb() or hsl() colour with an optional percentage.
Anything else (url(), var(), conic-gradient()) is refused and the surface
falls back to none. Text on a gradient surface is checked against every stop.
The gradient is physical, not logical: 90deg runs left to right in a
right-to-left locale too.
The brand.json file
Section titled “The brand.json file”{ "$schema": "https://embed.coras.io/brand.schema.json", "schemaVersion": 1, "primary": "#250854", "navbar": { "background": "#250854", "text": "#ffffff" }, "footer": { "background": "#503974", "text": "#ffffff" }, "logo": "/assets/images/logo.svg"}colorScheme. A brand may also declare "colorScheme": "light" | "dark" | "inherit" - the scheme it was authored for, for surfaces that have no mount config to read one from (the transactional emails are sent from a brand key alone). Coras Studio writes it into every export. The SDK accepts it and ignores it: what the embed renders in comes from config.colorScheme, never from the brand.
Schema. brand.schema.json is
the contract as a JSON Schema. Cite it as $schema and your editor validates
and completes the file as you type. It is stricter than the engine: an unknown
key is flagged there because for an author it is a typo, while the engine
ignores it so a newer file still reads on an older release.
Versions. schemaVersion names the shape the file was written for; this
is version 1, the first shipped. An added optional field never changes it. A
rename or a change of meaning raises it and ships a migration in
@coras-io/brand-tokens, so every reader (the web SDK, the email service, the
Studio, CI) accepts every version up to its own and reads the file as if it had
been written today. A file newer than the reader is refused rather than misread.
Authored and published
Section titled “Authored and published”A brand.json you write, or export from the Studio, holds only what you chose.
The values the engine derives from those choices are filled in once, when the
brand is published, so both the web and the email render the same complete
brand and a later change to primary can never leave a stale hover colour
behind in the file:
- Readable text. Set
page.backgroundandpage.text/page.textMutedare filled by true WCAG contrast. Set a button fill and itstextis filled the same way. - Brand tints (
primaryTint,secondaryTint): the colour mixed 8% into the page background, in oklab so a yellow brand’s tint stays yellow. - Status backgrounds: the status colour mixed 10% into the card background.
- Button states (
hover,pressed): the fill mixed toward black or white. - Input corners (
field.radius): a third ofradius.
Coras-managed brands are published by the deploy; the Studio’s preview and
email preview show the published brand while its export stays authored. If you
host a brand yourself and pass it as config.theme, publish it first with
publishBrand() from @coras-io/brand-tokens/authoring, or set the derived
values yourself: the SDK reads the file as it is and falls back to the Coras
defaults for anything missing, not to a computed value.
The only render-time mixes are the neutral surface state layers
(--coras-color-card-hover, --coras-color-card-active): a translucent
black/white overlay on a surface’s own colour, taking no brand colour as input.
Semantic tier
Section titled “Semantic tier”Override these to change a role across the whole system without touching the brand tier.
Colors
Section titled “Colors”| Variable | Default |
|---|---|
--coras-color-background |
var(--coras-brand-page-background) |
--coras-color-card |
var(--coras-brand-card-background) |
--coras-color-card-sunken |
var(--coras-brand-neutral-50) (dark: #0b0d10) |
--coras-color-card-hover |
the card mixed with --coras-state-overlay at --coras-state-hover |
--coras-color-card-active |
the card mixed with the overlay at --coras-state-active |
--coras-color-card-border |
var(--coras-brand-card-border) |
--coras-color-divider |
var(--coras-brand-divider) |
--coras-color-field-border |
var(--coras-brand-field-border) |
--coras-color-text |
var(--coras-brand-text) |
--coras-color-text-muted |
var(--coras-brand-text-muted) |
--coras-color-text-disabled |
var(--coras-brand-disabled) |
--coras-color-primary-button |
var(--coras-brand-primary-button) |
--coras-color-primary-button-hover |
var(--coras-brand-primary-button-hover) |
--coras-color-primary-button-pressed |
var(--coras-brand-primary-button-pressed) |
--coras-color-primary-button-text |
var(--coras-brand-primary-button-text) |
--coras-color-secondary-button-* |
the secondary button’s text/hover/pressed brand vars |
--coras-color-text-button |
var(--coras-brand-text-button) |
--coras-color-link |
var(--coras-brand-link) |
--coras-color-icon |
var(--coras-brand-icon) |
--coras-color-focus-ring |
var(--coras-brand-focus-ring) |
--coras-color-scrim |
var(--coras-brand-scrim) |
--coras-color-primary / -tint |
var(--coras-brand-primary) / var(--coras-brand-primary-tint) |
--coras-color-error … -info |
the status brand vars |
--coras-color-error-background … |
the status *Background brand vars |
--coras-color-neutral-50 … -900 |
the brand grey ramp, inverted in dark mode |
--coras-state-overlay |
light-dark(#000, #fff) (overlay colour for surface states) |
--coras-state-hover / -active |
10% / 18% |
Typography
Section titled “Typography”| Variable | Default |
|---|---|
--coras-font-family-sans |
var(--coras-brand-font) |
--coras-font-family-heading |
var(--coras-brand-heading-font) |
--coras-font-size-title / --coras-font-size-heading |
3xl / 2xl, each times --coras-brand-heading-scale |
--coras-font-size-xxs … --coras-font-size-5xl |
0.625rem … 3.5rem |
--coras-font-weight-light … --coras-font-weight-bold |
300 … 700 |
Every step except control derives from the brand radius fields, so one
radius scales the whole scale (xs = radius/6 … 2xl = radius×4/3).
| Variable | Default |
|---|---|
--coras-radius-xs…-2xl |
proportions of --coras-brand-radius |
--coras-radius-lg |
var(--coras-brand-radius) |
--coras-radius-button |
var(--coras-brand-button-radius) |
--coras-radius-chip |
var(--coras-brand-chip-radius) |
--coras-radius-field |
var(--coras-brand-field-radius) |
--coras-radius-control |
0.25rem (fixed: checkboxes) |
--coras-radius-full |
9999px |
Shadows
Section titled “Shadows”| Variable | Default |
|---|---|
--coras-shadow-sm |
var(--coras-brand-soft-shadow) |
--coras-shadow-md |
var(--coras-brand-button-shadow) |
--coras-shadow-lg |
var(--coras-brand-card-shadow) |
--coras-shadow-top |
1px -4px 8px 4px rgba(0, 0, 0, 0.03) |
--coras-shadow-strong |
0px 6px 10px 4px rgba(0, 0, 0, 0.15), 0px 2px 3px rgba(0, 0, 0, 0.3) |
Layout
Section titled “Layout”| Variable | Default |
|---|---|
--coras-page-max-width |
950px |
--coras-container-max-width |
1368px |
--coras-footer-max-width |
1073px |
--coras-z-index-overlay |
1000 |
--coras-navbar-padding |
1rem |
--coras-content-padding |
0 1rem |
--coras-footer-padding |
4rem 2rem |
Components tier
Section titled “Components tier”Override these to change one component without affecting others.
[data-coras-root] { --coras-navbar-background: #f7f7fb; --coras-navbar-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); --coras-button-primary-background: #1c2355; --coras-button-primary-text-color: #ffffff;}| Variable | Default |
|---|---|
--coras-base-text-color |
var(--coras-color-text) |
--coras-navbar-background |
var(--coras-brand-navbar-background) |
--coras-navbar-background-image |
var(--coras-brand-navbar-background-image); -dark holds the dark gradient |
--coras-navbar-text-color |
var(--coras-brand-navbar-text) |
--coras-navbar-border-bottom |
1px solid var(--coras-brand-navbar-border) |
--coras-navbar-shadow |
var(--coras-brand-navbar-shadow) |
--coras-navbar-icon-color |
var(--coras-brand-navbar-icon) |
--coras-navbar-logo-height |
var(--coras-brand-logo-height) |
--coras-footer-background-color |
var(--coras-brand-footer-background) |
--coras-footer-background-image |
var(--coras-brand-footer-background-image); -dark holds the dark gradient |
--coras-footer-text-color |
var(--coras-brand-footer-text) |
--coras-footer-link-color |
var(--coras-brand-footer-link) |
--coras-button-primary-background |
var(--coras-color-primary-button) |
--coras-button-primary-text-color |
var(--coras-color-primary-button-text) |
--coras-button-primary-background-hover |
var(--coras-color-primary-button-hover) |
--coras-button-primary-background-active |
var(--coras-color-primary-button-pressed) |
--coras-input-field-background-color |
var(--coras-color-background) |
--coras-basket-accent-color |
var(--coras-brand-secondary) |
Container scoping
Section titled “Container scoping”Every token lives under [data-coras-root]. mount() sets that attribute on the element it creates and sets container: coras-page / inline-size on it, so embedded pages respond to the container width, not the viewport width. Two mounts on one page can be themed and sized independently.
<div data-coras-root style="--coras-brand-primary: red">...</div><div data-coras-root style="--coras-brand-primary: blue">...</div>Shared branding engine
Section titled “Shared branding engine”brand.json is the contract defined by the open-source @coras-io/brand-tokens package. The same file is the single source of truth across every surface, so a brand defined once looks identical on the web and in email:
- Web components -
toCSS(config)emits the four-tier CSS (including@font-facerules); the SDK applies it per mount. - Emails -
resolveBrand(config)returns concrete literal values to inline into HTML email, since most email clients ignore CSS variables. - Design tools -
toDesignTokens(config)exports the published brand as W3C Design Tokens for Tokens Studio, Style Dictionary and the like. An export, not the contract.
Load a file however you like - import a local brand.json, or loadBrand(url). Every reader accepts the grouped or the flat shape at any supported version; migrateBrandConfig is the boundary that turns either into the engine’s flat keys.
import { toCSS, resolveBrand } from "@coras-io/brand-tokens";import { publishBrand, toDesignTokens } from "@coras-io/brand-tokens/authoring";
const published = publishBrand(brand); // fill in the derived values, onceconst css = toCSS(published); // web: a full stylesheet for this brandconst tokens = resolveBrand(published); // email: { primary: "#006643", ... }const dtcg = toDesignTokens(published); // Figma / Style DictionaryTo author a config visually, use Coras Studio (branding.coras.io): pick colours, a logo, and fonts, preview live, then copy or download the brand.json.