Skip to content

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.

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.

You can set brand tokens in three ways. Pick one based on how much control you need.

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.

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);
}

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.

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 50900 - 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.

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.

{
"$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.

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.background and page.text / page.textMuted are filled by true WCAG contrast. Set a button fill and its text is 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 of radius.

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.

Override these to change a role across the whole system without touching the brand tier.

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%
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.625rem3.5rem
--coras-font-weight-light--coras-font-weight-bold 300700

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
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)
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

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)

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>

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-face rules); 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, once
const css = toCSS(published); // web: a full stylesheet for this brand
const tokens = resolveBrand(published); // email: { primary: "#006643", ... }
const dtcg = toDesignTokens(published); // Figma / Style Dictionary

To 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.