This is the full developer documentation for Coras Embed
# Coras Embed
> Mount Coras ticketing pages with one mount() call. The same API across eleven frameworks - React, Vue, Angular, Svelte, Solid, Astro, and more.
## What you get
[Section titled “What you get”](#what-you-get)
One SDK entry point
`mount({(container, page, config)})` renders the `landing`, `search`, `details`, `payment`, or `help` page, plus `suggestion-widget`, a standalone recommendations embed you mount directly. No page tags, no per-framework wrappers.
Same API in every framework
The same `mount()` call across eleven frameworks - React, Vue, Angular, Svelte, Solid, Astro, and more. The returned handle exposes `ready`, `update()`, `navigate()`, `prefetch()`, and `unmount()`.
Routing stays in your app
Typed `onNavigate` and `onStateChange` callbacks and framework-neutral URL helpers keep routing, analytics, and URL state in your application.
Chrome and theme
Opt into the Coras navbar and footer with `chrome`, and brand the embed with container-scoped CSS variables via `config.theme`.
## Mount a page
[Section titled “Mount a page”](#mount-a-page)
```ts
import { mount } from "@coras-io/embed";
const app = mount({
container: document.querySelector("#coras")!,
page: "landing",
config: {
apiUrl: "https://api.coras.io",
distributorId: "your-distributor-id",
assetsUrl: "https://assets.coras.io/shared",
locale: "en-IE",
currency: "EUR",
},
onNavigate(detail) {
// Your app owns routing.
},
});
await app.ready;
app.update({ page: "search", params: { search: "museum" } });
app.unmount();
```
`mount()` returns synchronously and loads the page module in the background. Await `app.ready` to know when the first render is complete.
## Events
[Section titled “Events”](#events)
Every host-facing event has a matching `mount()` callback:
| Event | Callback | Purpose |
| --------------------------- | ---------------------- | ------------------------------------- |
| `coras:ready` | `onReady` | First render is ready |
| `coras:navigate` | `onNavigate` | Navigation intent, routed by your app |
| `coras:state-change` | `onStateChange` | In-page filters or selection changed |
| `coras:reservation-created` | `onReservationCreated` | Reservation created |
| `coras:payment-status` | `onPaymentStatus` | Payment lifecycle status |
| `coras:error` | `onError` | Safe, redacted error |
See [Events and callbacks](/getting-started/events/) for payloads.
# Handle events and callbacks
> Use the typed coras:* host events and matching mount() callbacks to react to the embed
The embed reports what happens inside it through a small, typed event contract. Use it to route navigation, sync URL state, and react to reservations, payments, and errors.
Every event is delivered two ways:
* A `mount()` callback, for example `onNavigate`. Use callbacks for normal integrations.
* A DOM `CustomEvent` named `coras:`, dispatched from the mounted container. Use `addEventListener` only for advanced cases, such as delegating to existing listeners.
Both carry the same `detail` payload.
## Events at a glance
[Section titled “Events at a glance”](#events-at-a-glance)
| Event | Callback | When it fires |
| --------------------------- | ---------------------- | ----------------------------------------------------- |
| `coras:ready` | `onReady` | The page mounted and the first render is ready. |
| `coras:navigate` | `onNavigate` | The embed requests navigation. The host should route. |
| `coras:state-change` | `onStateChange` | In-page state changed, such as filters or selection. |
| `coras:reservation-created` | `onReservationCreated` | A reservation was created. |
| `coras:payment-status` | `onPaymentStatus` | Payment status changed. |
| `coras:error` | `onError` | A safe, redacted error occurred. |
## Handle events with callbacks
[Section titled “Handle events with callbacks”](#handle-events-with-callbacks)
Pass callbacks to `mount()`. Each receives the event `detail` directly.
```ts
import { mount } from "@coras-io/embed";
const app = mount({
container,
page: "landing",
config,
onReady({ page }) {
// page: "landing" | "search" | "details" | "payment" | "help" | "suggestion-widget"
},
onNavigate(intent) {
// intent: { page, params?, locale?, currency?, href?, source }
router.navigate(intent);
},
onStateChange(state) {
// state: { page, params, locale?, currency? }
},
onReservationCreated({ reservationId, expiresIn, ticketDeliveryMethod }) {
// all three fields are optional
},
onPaymentStatus({ status, reference }) {
// status: "succeeded" | "failed" | "pending"
// reference is an optional public reference, never a provider secret
},
onError({ code, message, page }) {
// code: stable machine code, e.g. "invalid_page_params"
},
});
```
## Route navigation, not state changes
[Section titled “Route navigation, not state changes”](#route-navigation-not-state-changes)
`coras:navigate` and `coras:state-change` look similar but have different jobs. Keep them separate to avoid router loops.
* `coras:navigate` is a request to navigate: a cross-page action such as opening details or running a search, or an external link. The host maps the intent to its router and calls `app.update()`.
* `coras:state-change` reports in-page state such as filters or selection. The host decides whether to persist this state in the URL.
For the full pattern, see [Routing](/getting-started/routing/).
## Handle events with addEventListener
[Section titled “Handle events with addEventListener”](#handle-events-with-addeventlistener)
Listen on the same container you passed to `mount()`. The `detail` payload matches the callback argument.
```ts
container.addEventListener("coras:navigate", (event) => {
console.log(event.detail.page, event.detail.source);
});
```
When a page has multiple mounts, each event and callback reaches only its own instance.
## Observe lifecycle events
[Section titled “Observe lifecycle events”](#observe-lifecycle-events)
`onObservability` reports per-mount lifecycle and diagnostic facts, separate from the `coras:*` host events. Names are dot-namespaced, for example `coras.page-loaded`, `coras.unmounted`, and `coras.error`.
```ts
mount({
container,
page: "landing",
config,
onObservability(event) {
// event: { name, page?, data? }
},
});
```
The events emitted today, with the keys they carry in `data`:
| Name | When it fires | `data` keys |
| ------------------- | --------------------------------- | ----------- |
| `coras.page-loaded` | A page finished its first render. | `{ page }` |
| `coras.error` | A redacted error was reported. | `{ code }` |
| `coras.unmounted` | The mount was torn down. | None. |
Treat `name` and `data` as an open set: more events and keys can be added. `data` values are limited to strings, numbers, booleans, and `null`.
Payloads are minimal and redacted
Event and callback payloads never include card data, client secrets, OTP or MFA codes, wallet private data, raw provider responses, full reservation payloads, or personal data such as names, emails, phone numbers, or addresses. Payment status carries a discriminated `status` and an optional public `reference` only.
# Mount Coras in your framework
> Bind mount(), update(), and unmount() to your framework's component lifecycle, with copy-paste examples for eleven setups
This guide shows how to mount Coras Embed in your framework and tear it down cleanly. Every framework uses the same SDK contract from `@coras-io/embed`, so the only thing that changes per framework is which lifecycle hook calls each method.
## Before you start
[Section titled “Before you start”](#before-you-start)
* Install `@coras-io/embed`.
* Have your distributor ID and API URL ready for `config`.
## The contract
[Section titled “The contract”](#the-contract)
Map these four steps onto your framework’s component lifecycle:
1. Get a container DOM element.
2. Call `mount({ container, page, config, params })` when the component attaches. Keep the returned `CorasApp`.
3. Call `app.update({ ... })` when `page`, `params`, or `config` change.
4. Call `app.unmount()` when the component detaches.
Note
Coras pages need the DOM and cannot be server-side rendered. In SSR frameworks such as Next.js, Nuxt, SvelteKit, and Astro, mount inside a client-only boundary. See [Server-side rendering](#server-side-rendering) below.
## Layout & styling
[Section titled “Layout & styling”](#layout--styling)
There are two ways to lay Coras out, and they call for opposite CSS:
* **Standalone** - Coras *is* the site. Use `chrome: "managed"` so the mount renders the Coras navbar and footer, and let it own the whole viewport.
* **Embedded** - Coras mounts *into* an app that already has its own navbar, footer, and routing. Use `chrome: false` and treat the mount as one content region among many.
### Standalone
[Section titled “Standalone”](#standalone)
Coras renders a complete page - navbar, page content, and footer - into the container. Let it fill the viewport; do not wrap it in a centered, max-width, or text-aligned container, and remove any starter-template CSS (a centered `#root`/`#app`, demo `:root` colour variables, `App.css`) - that is the usual cause of clipped content and off-brand colours.
A minimal full-bleed reset is all you need (swap `#app` for your container’s selector):
```css
html,
body {
height: 100%;
margin: 0;
padding: 0;
}
body {
display: flex;
flex-direction: column;
}
#app {
flex: 1 0 auto;
display: flex;
flex-direction: column;
}
```
That is the whole of it. The mount is a flex column that grows, and so is the page inside it, so the managed footer sits at the bottom of a short page on its own. You never need a rule that names a Coras element: those names are internal and can change without a major version.
If your framework wrapper puts an element of its own between that container and the mount - a React ``, say - give it `display: contents` so the mount stays a direct flex child:
```tsx
```
### Embedded
[Section titled “Embedded”](#embedded)
To mount Coras inside an existing app, set [`chrome: false`](/reference/chrome/) so it renders the page alone, without the Coras navbar or footer, and keep the host’s own chrome around it. Mount into a content region - the host route’s ``, say - and do *not* apply the full-bleed reset above: the mount should size to its region, not take the viewport.
Scope Coras to its own part of the host’s routes with the URL strategy’s [`basePath`](/getting-started/routing/#customize-the-url-strategy) (for example `basePath: "/tickets"`), so `buildCorasUrl` and `parseCorasUrl` carry that prefix and the host’s other routes are left untouched. See the [embedded example](https://github.com/coras-io/embed-examples/tree/main/embedded) for a full host app.
Note
All colours, fonts, and component styling come from your `brand.json` via [`config.theme`](/reference/theming/), in both modes. Don’t hand-write CSS to restyle Coras content - edit the brand instead.
## Framework examples
[Section titled “Framework examples”](#framework-examples)
Each tab is copy-paste ready and produces the same runtime behavior. The examples mount on attach and tear down on detach; to react to changing `page`, `params`, or `config`, also call `app.update({ ... })` from your framework’s reactive hook (step 3 above).
* Plain JS
```js
import { mount } from "@coras-io/embed";
const app = mount({
container: document.querySelector("#coras"),
page: "landing",
config: {
apiUrl: "https://api.coras.io",
distributorId: "your-distributor-id",
assetsUrl: "https://assets.sandbox.coras.io/shared",
locale: "en-IE",
currency: "EUR"
}
});
// Tear down when you are done:
// app.unmount();
```
* Alpine.js
Register a reusable `corasEmbed` data component, then use `x-data` on the container:
```html
```
* React
Note
Under React StrictMode the mount effect runs twice in development. `unmount()` is idempotent, so the second mount cleanly replaces the first.
```tsx
import { useEffect, useRef } from "react";
import { mount, type CorasApp } from "@coras-io/embed";
export function CorasEmbed() {
const containerRef = useRef(null);
const appRef = useRef(null);
useEffect(() => {
appRef.current = mount({
container: containerRef.current!,
page: "landing",
config: {
apiUrl: "https://api.coras.io",
distributorId: "your-distributor-id",
assetsUrl: "https://assets.sandbox.coras.io/shared",
locale: "en-IE",
currency: "EUR"
}
});
return () => appRef.current?.unmount();
}, []);
return ;
}
```
* Next.js
The embed needs the DOM, so it must live in a client component:
```tsx
"use client";
import { useEffect, useRef } from "react";
import { mount, type CorasApp } from "@coras-io/embed";
export function CorasEmbed() {
const containerRef = useRef(null);
const appRef = useRef(null);
useEffect(() => {
appRef.current = mount({
container: containerRef.current!,
page: "landing",
config: {
apiUrl: "https://api.coras.io",
distributorId: "your-distributor-id",
assetsUrl: "https://assets.sandbox.coras.io/shared",
locale: "en-IE",
currency: "EUR"
}
});
return () => appRef.current?.unmount();
}, []);
return ;
}
```
Render `` from a server component like any other client component.
* Astro
Mount inside an Astro component using a client-side `
```
Note
For Astro projects using View Transitions or `ClientRouter`, re-mount on the `astro:page-load` event and call `unmount()` on `astro:before-swap` so the embed survives navigation.
* Vue
Vue 3 with `
```
* Svelte
Svelte 5 with runes:
```svelte
```
* Solid.js
```tsx
import { onMount, onCleanup } from "solid-js";
import { mount, type CorasApp } from "@coras-io/embed";
export function CorasEmbed() {
let container!: HTMLDivElement;
let app: CorasApp | undefined;
onMount(() => {
app = mount({
container: container,
page: "landing",
config: {
apiUrl: "https://api.coras.io",
distributorId: "your-distributor-id",
assetsUrl: "https://assets.sandbox.coras.io/shared",
locale: "en-IE",
currency: "EUR"
}
});
onCleanup(() => app?.unmount());
});
return ;
}
```
* Angular
Standalone component, Angular 17+:
```ts
import { Component, ElementRef, OnDestroy, OnInit, ViewChild } from "@angular/core";
import { mount, type CorasApp } from "@coras-io/embed";
@Component({
selector: "coras-embed",
standalone: true,
template: ``,
})
export class CorasEmbedComponent implements OnInit, OnDestroy {
@ViewChild("container", { static: true })
container!: ElementRef;
private app?: CorasApp;
ngOnInit(): void {
this.app = mount({
container: this.container.nativeElement,
page: "landing",
config: {
apiUrl: "https://api.coras.io",
distributorId: "your-distributor-id",
assetsUrl: "https://assets.sandbox.coras.io/shared",
locale: "en-IE",
currency: "EUR"
}
});
}
ngOnDestroy(): void {
this.app?.unmount();
}
}
```
* Lit
A Lit element that hosts the SDK. Render in light DOM (`createRenderRoot` returns `this`) so the embed sees the host page’s tokens.
```ts
import { LitElement, html } from "lit";
import { customElement, query } from "lit/decorators.js";
import { mount, type CorasApp } from "@coras-io/embed";
@customElement("my-coras-embed")
export class MyCorasEmbed extends LitElement {
@query("div") private container!: HTMLDivElement;
private app?: CorasApp;
protected createRenderRoot() {
return this;
}
protected firstUpdated() {
this.app = mount({
container: this.container,
page: "landing",
config: {
apiUrl: "https://api.coras.io",
distributorId: "your-distributor-id",
assetsUrl: "https://assets.sandbox.coras.io/shared",
locale: "en-IE",
currency: "EUR"
}
});
}
override disconnectedCallback() {
super.disconnectedCallback();
this.app?.unmount();
}
render() {
return html``;
}
}
```
* Ember.js
Glimmer component with render modifiers (`@ember/render-modifiers`):
app/components/coras-embed.ts
```ts
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { mount, type CorasApp } from "@coras-io/embed";
export default class CorasEmbedComponent extends Component {
private app?: CorasApp;
@action setup(element: HTMLDivElement) {
this.app = mount({
container: element,
page: "landing",
config: {
apiUrl: "https://api.coras.io",
distributorId: "your-distributor-id",
assetsUrl: "https://assets.sandbox.coras.io/shared",
locale: "en-IE",
currency: "EUR"
}
});
}
@action teardown() {
this.app?.unmount();
}
}
{{! app/components/coras-embed.hbs }}
```
## React to prop changes
[Section titled “React to prop changes”](#react-to-prop-changes)
The tabs above mount on attach and tear down on detach. When `page`, `params`, or `config` change while the component stays mounted, call `app.update({ ... })` from your framework’s reactive hook instead of remounting. It updates in place unless `page` changes:
```ts
// e.g. React useEffect([id]), Vue watch, Svelte $effect, Angular ngOnChanges
appRef.current?.update({ page: "details", params: { id } });
```
## Server-side rendering
[Section titled “Server-side rendering”](#server-side-rendering)
Page elements need the DOM, so render the embed in a client-only boundary:
| Framework | Client-only boundary |
| --------- | ------------------------ |
| Next.js | `"use client"` directive |
| Nuxt | `` |
| SvelteKit | `onMount` |
| Astro | `client:only` |
## Troubleshoot
[Section titled “Troubleshoot”](#troubleshoot)
* Nothing renders and an error is thrown. `mount()` validates `config` and `params` before it renders anything. Invalid input throws `CorasValidationError`. Check the error message for the failing key.
* The embed mounts twice in React development. With React StrictMode, double-mount effects run `mount()` twice. `unmount()` is idempotent and safe, and the second mount replaces the first.
* Two embeds on one page interfere. They do not. The SDK scopes theme, chrome, and events to each mount root, so multiple embeds coexist without leakage.
* Icons are missing, the brand looks unstyled, or the page is clipped. Pass `strict: true` (or wire a `logger`) and check the console: `mount()` warns when `assetsUrl` is unreachable, `config.theme` has no colours, or a managed-chrome page is constrained by an ancestor. Each points at the fix.
## Next steps
[Section titled “Next steps”](#next-steps)
* Drive page changes from the host URL: see [Routing](/getting-started/routing/) for the URL loop that pairs with `onNavigate` and `onStateChange`.
* Handle [events](/getting-started/events/) emitted by the embed.
# Get started
> Install Coras Embed, mount a ticketing page, and control its lifecycle
Coras Embed is a JavaScript SDK for ticketing pages. You call `mount()` with a container, a page name, and config. The SDK renders the page and returns an app handle you use to read state, update the page, and tear it down.
This page gets you from install to a rendered page, then shows the lifecycle methods. For the full options, see the reference pages linked under [Next steps](#next-steps).
Note
The page custom elements (`coras-landing-page` and others) are internal. Always integrate through `mount()`, not page tags or framework wrappers. Every framework uses the same contract.
## Install
[Section titled “Install”](#install)
* npm
`bash npm install @coras-io/embed`
* pnpm
`bash pnpm add @coras-io/embed`
* yarn
`bash yarn add @coras-io/embed`
Note
Contact Coras to receive a sandbox `distributorId` and API URL for development. The `your-distributor-id` placeholder below stands in for the one you are issued.
## Mount a page
[Section titled “Mount a page”](#mount-a-page)
1. Add a container element to your page:
```html
```
2. Mount a page into the container:
```ts
import { mount } from "@coras-io/embed";
const app = mount({
container: document.querySelector("#coras")!,
page: "landing",
config: {
apiUrl: "https://sandbox.coras.io",
distributorId: "a8405267cbcf4bd2b70114e618516645",
assetsUrl: "https://assets.sandbox.coras.io/shared",
locale: "en-IE",
currency: "EUR",
},
});
await app.ready;
```
Tip
This config points at the public Coras **sandbox** and a shared demo distributor, so the snippet renders a real page as-is - no account needed. Swap `apiUrl` for `https://api.coras.io` and `distributorId` for your own when you go to production.
`mount()` returns synchronously and loads the page module in the background. Await `app.ready` when you need to know that the first render is complete.
Note
`locale` and `currency` are optional but default to `en-GB` and `GBP`. Set them explicitly for other regions, for example `locale: "ja-JP"`, `currency: "JPY"`.
For every config field, see the [config reference](/reference/config/).
### Verify it worked
[Section titled “Verify it worked”](#verify-it-worked)
After `await app.ready` resolves, the first render is complete and the page is in the container. Read `app.state` to confirm the page that mounted:
```ts
await app.ready;
console.log(app.state.page); // "landing"
```
## Pages and params
[Section titled “Pages and params”](#pages-and-params)
`page` is one of `landing`, `search`, `details`, `payment`, or `help`, plus `suggestion-widget` - a standalone recommendations embed that is not routable; mount it directly. Each page accepts typed params:
```ts
mount({ container, page: "details", config, params: { id: "abc123" } });
mount({ container, page: "search", config, params: { search: "museum" } });
```
Note
The `details` `id` is a public attraction id surfaced in the params of the `coras:navigate` event from the landing and search pages, so you route straight to it - no independent lookup needed.
For the params each page accepts, see the [pages reference](/reference/pages/).
### Validation
[Section titled “Validation”](#validation)
Invalid input throws a typed `CorasValidationError` before anything renders. This includes a missing `apiUrl` or `distributorId`, a non-https URL (http is allowed only for `localhost`), an unsupported locale or currency, and, in `strict` mode, unknown config or param keys. Each error carries a stable `code` and a safe `message`.
Pass `strict: true` in development so typos in config or param keys fail loudly instead of being silently ignored.
## Lifecycle
[Section titled “Lifecycle”](#lifecycle)
The app handle exposes the methods you use after mounting:
```ts
const app = mount(options);
await app.ready; // resolves after the first render
app.state; // frozen snapshot: { page, params, locale, currency }
app.update({ params: { city: "london" } }); // update params in place
app.update({ page: "details", params: { id: "abc123" } }); // change page
app.navigate("payment", { metadata: { orderId: "42" } }); // change page by name
await app.prefetch("payment"); // load a page module ahead of time
app.unmount(); // idempotent teardown
```
| Method | Behavior |
| ----------------------------------- | --------------------------------------------------------------------------------------------------- |
| `ready` | Promise that resolves after the first render. Rejects if the mount is unmounted before it renders. |
| `state` | Frozen snapshot of the current `page`, `params`, `locale`, and `currency`. |
| `update(patch)` | Applies a patch of `page`, `params`, `config`, or `chrome`. Updates in place unless `page` changes. |
| `navigate(page, params?, options?)` | Changes the page by name. Pass `{ emitNavigate: true }` to also emit `coras:navigate`. |
| `prefetch(page)` | Loads a page module ahead of time so a later switch renders faster. |
| `unmount()` | Aborts in-flight loads and removes all SDK DOM, listeners, and timers. Safe to call more than once. |
`update()` avoids remounting unless the page changes. `unmount()` is safe before, during, and after `ready`.
## Chrome (navbar and footer)
[Section titled “Chrome (navbar and footer)”](#chrome-navbar-and-footer)
By default `mount()` renders only the page. Opt into the Coras navbar and footer with `chrome`:
```ts
// Both navbar and footer, SDK-managed:
mount({ container, page: "landing", config, chrome: "managed" });
// Per slot. Each accepts false, "managed", an element, or a factory:
mount({
container,
page: "landing",
config,
chrome: { navbar: "managed", footer: false },
});
```
For navbar feature toggles and host content slots, see the [chrome reference](/reference/chrome/).
## Theming
[Section titled “Theming”](#theming)
`config.theme` sets brand CSS variables scoped to the mounted container, so two mounts on one page can be themed independently:
```ts
mount({
container,
page: "landing",
config: {
apiUrl: "https://api.coras.io",
distributorId: "your-distributor-id",
assetsUrl: "https://assets.sandbox.coras.io/shared",
theme: { primary: "#4657d4", secondary: "#161c44" },
},
});
```
For every theme token, see the [theming reference](/reference/theming/).
## Framework integration
[Section titled “Framework integration”](#framework-integration)
Every framework uses the same contract: mount on attach, `update()` on prop change, `unmount()` on detach. See [Framework integration](/getting-started/frameworks/) for copy-paste bindings for React, Next.js, Astro, Vue, Svelte, Solid.js, Angular, Lit, Ember.js, and Alpine.js.
## Next steps
[Section titled “Next steps”](#next-steps)
* [Framework integration](/getting-started/frameworks/): per-framework bindings.
* [Events and callbacks](/getting-started/events/): the typed `coras:*` contract.
* [Routing](/getting-started/routing/): host-controlled URL helpers.
* [Events reference](/reference/events/): every event payload.
* [Changelog](https://github.com/coras-io/coras-web-components/blob/main/packages/embed/CHANGELOG.md): version history for `@coras-io/embed`.
# Host-controlled routing
> Map Coras navigation to your router with framework-neutral URL helpers
The host application owns the URL. Coras pages emit navigation intents through `onNavigate`; you turn each intent into a URL, push it to your router, and reflect URL changes back into the mount with `app.update()`.
The `@coras-io/embed/url` entry point provides framework-neutral helpers so you do not write URL parsing glue for each framework.
## Before you start
[Section titled “Before you start”](#before-you-start)
You need a working `mount()` call. See [Get started](./introduction) if you have not mounted a page yet.
Import the URL helpers from the `/url` entry point:
```ts
import {
parseCorasUrl,
buildCorasUrl,
createCorasUrlState,
isSameCorasUrlState,
} from "@coras-io/embed/url";
```
## Parse and build URLs
[Section titled “Parse and build URLs”](#parse-and-build-urls)
By default the helpers put locale and currency in the path and represent a details page as a bare id segment (for example `/en-IE/EUR/abc123`). The id is percent-encoded so characters like `/`, `?`, and `#` round-trip. A details id that equals a reserved page segment (for example `search`) is not representable in this shape and is not supported:
```ts
const state = parseCorasUrl("/en-IE/EUR/search?search=museum");
// { page: "search", params: { search: "museum" }, locale: "en-IE", currency: "EUR" }
const url = buildCorasUrl({
page: "details",
params: { id: "abc123", date: "2026-06-01" },
locale: "en-IE",
currency: "EUR",
});
// "/en-IE/EUR/abc123?date=2026-06-01"
```
Locale uses BCP 47 tags (`en-IE`) and currency uses ISO 4217 codes (`EUR`). Aliases and casing are normalized during parsing.
Caution
An unsupported locale or currency segment is not skipped. `parseCorasUrl` takes the first segment it does not recognize as the page, and any page segment that is not a reserved word is a details id, so `parseCorasUrl("/pt-BR/EUR/help")` returns the **details** page with `id: "pt-BR"`. Normalize the path to a locale and currency you support before you parse it, or a stale link renders the wrong page rather than a fallback one.
If your router validates the locale and currency path params, make that validation fall back rather than throw. A typed router runs its param parser before its route guards, so a redirect declared in a guard never sees the failure and the visitor gets the router’s error screen instead of your site. In TanStack Router that is `params.parse` running ahead of `beforeLoad`.
### Default page segments
[Section titled “Default page segments”](#default-page-segments)
| Page | URL segment |
| ------------------- | ------------------------------ |
| `landing` | none |
| `details` | bare id (for example `abc123`) |
| `search` | `search` |
| `payment` | `payment` |
| `help` | `help` |
| `suggestion-widget` | `widget` |
## Wire the routing loop
[Section titled “Wire the routing loop”](#wire-the-routing-loop)
Bind the helpers to one strategy with `createCorasUrlState`, then connect both directions: Coras intents out to your router, and router changes back into the mount.
```ts
import { mount } from "@coras-io/embed";
import { createCorasUrlState } from "@coras-io/embed/url";
const coras = createCorasUrlState();
const app = mount({
container,
page: initialState.page,
params: initialState.params,
config,
onNavigate(intent) {
router.push(coras.build(intent)); // a link/selection -> a new history entry
},
onStateChange(state) {
router.replace(coras.build(state)); // an in-page change (e.g. a filter) -> replace
},
});
// When the URL changes, reflect it into the mount.
function onRouteChange(url: string) {
const next = coras.parse(url);
if (!coras.isSame(next, app.state)) {
app.update({
page: next.page,
params: next.params,
// An update merges into the mounted config, so these two keys are enough.
config: { locale: next.locale, currency: next.currency },
});
}
}
```
`coras.isSame` skips the `app.update()` call when the parsed URL already matches the mount’s current `app.state`, avoiding a redundant re-render. `app.update()` never emits `onNavigate`, so it cannot loop back through the router on its own - the guard is purely to skip needless renders and state churn.
Caution
Pass `config` on every update when locale and currency live in the URL. The navbar’s language and currency selectors report the change through `onStateChange`, and the mount switches language and currency only through `config`. An update that carries `page` and `params` alone rewrites the address bar and leaves the page in the previous language.
### A complete example
[Section titled “A complete example”](#a-complete-example)
A full, framework-neutral integration using the History API: one persistent mount kept in sync with the URL, with `onNavigate` pushing and `onStateChange` replacing. It renders against the public sandbox, so it runs as-is. Component frameworks swap the `history.*` / `popstate` calls for their router - see the [framework examples](https://github.com/coras-io/embed-examples) for that wiring.
```ts
import {
mount,
type CorasApp,
type CorasConfig,
type CorasNavigateDetail,
type CorasStateChangeDetail,
} from "@coras-io/embed";
import { createCorasUrlState } from "@coras-io/embed/url";
const container = document.querySelector("#coras")!;
// Built once and reused. Locale and currency are patched in from the URL.
const config: CorasConfig = {
apiUrl: "https://sandbox.coras.io",
distributorId: "a8405267cbcf4bd2b70114e618516645",
assetsUrl: "https://assets.sandbox.coras.io/shared",
};
// One URL strategy shared by build and parse, so they always agree on a URL.
// The default puts locale and currency in the path: /:locale/:currency/...
const coras = createCorasUrlState();
// Reflect a URL into the mount. Locale and currency travel in the URL, so they
// are patched into config here: the mount switches language and currency only
// when config says so. update() never re-emits onNavigate, so there is no loop.
function applyUrl(href: string): void {
const next = coras.parse(href);
if (coras.isSame(next, app.state)) return;
app.update({
page: next.page,
params: next.params,
config: {
locale: next.locale ?? config.locale,
currency: next.currency ?? config.currency,
},
});
}
// A navigation (link/selection) pushes a new URL; an in-page state change (a
// filter, say) replaces the current one. An external href opens in a new tab.
function syncUrl(detail: CorasNavigateDetail | CorasStateChangeDetail,
replace: boolean,
): void {
if ("href" in detail && detail.href) {
window.open(detail.href, "_blank", "noopener,noreferrer");
return;
}
const href = coras.build({
page: detail.page,
params: detail.params ?? {},
locale: detail.locale,
currency: detail.currency,
});
history[replace ? "replaceState" : "pushState"](null, "", href);
applyUrl(href);
}
// Derive the initial page + params from the URL, so a deep link or refresh lands
// on the right page. One persistent mount for every page - route changes call
// app.update(), so the navbar, footer, and chrome stay put.
const initial = coras.parse(location.href);
const app: CorasApp = mount({
container,
page: initial.page,
params: initial.params,
config: {
...config,
locale: initial.locale ?? config.locale,
currency: initial.currency ?? config.currency,
},
onNavigate: (intent) => syncUrl(intent, false),
onStateChange: (state) => syncUrl(state, true),
});
// Back / forward (with a router: its route-change hook): re-read the URL.
addEventListener("popstate", () => applyUrl(location.href));
```
### Avoid feedback loops
[Section titled “Avoid feedback loops”](#avoid-feedback-loops)
Every `onNavigate` intent includes a `source` field:
* `"embed"`: navigation started inside a Coras page (a user clicked a result).
* `"host"`: navigation came from your own `app.navigate(page, params, { emitNavigate: true })` call. A plain `app.navigate()` call does not emit an intent.
Push to your router only for `"embed"` intents. Ignoring `"host"` intents prevents Coras from re-driving the router for navigation you already initiated:
```ts
onNavigate(intent) {
if (intent.source !== "embed") return;
router.push(coras.build(intent));
},
```
## Customize the URL strategy
[Section titled “Customize the URL strategy”](#customize-the-url-strategy)
Pass a strategy object to `createCorasUrlState` (or to `parseCorasUrl` and `buildCorasUrl` directly) to change how URLs are shaped.
| Option | Type | Default | Description |
| ------------------ | ---------------------- | --------------- | -------------------------------------------------------------------------- |
| `basePath` | `string` | `""` | Path prefix with no trailing slash, for example `/events`. |
| `localeInPath` | `boolean` | `true` | Put the locale in the path. |
| `currencyInPath` | `boolean` | `true` | Put the currency in the path. When `false`, currency is a query parameter. |
| `currencyParam` | `string` | `"currency"` | Query parameter name for currency when `currencyInPath` is `false`. |
| `reservedSegments` | `Record` | see table above | Override the path segment for each page. |
For example, `basePath: "/events"` prefixes every built URL and is stripped on parse:
```ts
const coras = createCorasUrlState({ basePath: "/events" });
coras.build({ page: "search", params: {}, locale: "en-IE", currency: "EUR" });
// "/events/en-IE/EUR/search" (default, without basePath: "/en-IE/EUR/search")
```
A non-empty `basePath` is what you reach for when Coras is [embedded](/getting-started/frameworks/#embedded) in a larger app: it confines Coras to a `/events/*`-style sub-tree of the host’s routes, leaving every other route the host owns untouched.
To keep currency as a query parameter:
```ts
const coras = createCorasUrlState({ currencyInPath: false });
coras.build({ page: "search", params: {}, locale: "en-IE", currency: "EUR" });
// "/en-IE/search?currency=EUR"
```
## Verify
[Section titled “Verify”](#verify)
* Navigating inside a Coras page updates the browser URL through your router.
* Using browser back and forward, or editing the URL, updates the Coras page.
* The page does not reload or flicker on `"host"` navigation, which confirms the `isSame` guard and the `source` check are working.
## Reference
[Section titled “Reference”](#reference)
`createCorasUrlState(strategy?)` returns helpers bound to one strategy:
| Method | Returns | Purpose |
| ------------------ | --------------- | ----------------------------------------------------------------- |
| `parse(url)` | `CorasUrlState` | Parse a relative or absolute URL into a Coras state. |
| `build(state)` | `string` | Build a canonical URL from a Coras state. |
| `normalize(state)` | `CorasUrlState` | Normalize locale and currency aliases and ensure a params object. |
| `isSame(a, b)` | `boolean` | Compare two states to skip redundant updates. |
The standalone functions `parseCorasUrl(url, strategy?)` and `buildCorasUrl(state, strategy?)` take the strategy as a second argument. `normalizeCorasUrlState(state)` and `isSameCorasUrlState(a, b)` do not take a strategy.
Use `createCorasUrlState(strategy)` for the routing loop: it binds one strategy once, so every `parse`/`build`/`isSame` call stays consistent. Reach for the standalone functions for one-off conversions where passing the strategy each time (or none) is simpler.
# Chrome
> The chrome option for mount() and update(): navbar and footer types, slot names, and behavior
The `chrome` option controls the navbar and footer that `mount()` renders around the page. By default `mount()` renders only the page. Set `chrome` to add the Coras navbar and footer, supply your own elements per slot, or mix the two.
`update({ chrome })` replaces the chrome on an already-mounted app.
## Type
[Section titled “Type”](#type)
```ts
type CorasChrome = false | "managed" | CorasChromeOptions;
type CorasChromeOptions = {
navbar?: CorasNavbar;
footer?: CorasFooter;
};
type CorasNavbar =
| false // no navbar
| "managed" // Coras default navbar
| CorasManagedNavbar // Coras navbar + host slots/props
| HTMLElement // your element
| (() => HTMLElement | Promise); // factory
type CorasFooter =
| false // no footer
| "managed" // Coras default footer
| CorasManagedFooter // Coras footer (no host slots)
| HTMLElement // your element
| (() => HTMLElement | Promise); // factory
type CorasManagedNavbar = {
use: "managed";
slots?: Partial>;
props?: CorasNavbarProps;
};
type CorasManagedFooter = {
use: "managed";
};
// Region names, RTL-safe (logical start/end).
type CorasNavbarSlotName =
| "brand"
| "nav"
| "actions-start"
| "actions-end"
| "search";
// Feature toggles; an omitted key keeps the navbar default (control shown).
type CorasNavbarProps = {
searchOnly?: boolean; // mode, not a toggle (see searchOnly)
showLanguageSelector?: boolean;
showCurrencySelector?: boolean;
showBasket?: boolean;
showSearch?: boolean;
logoHref?: string; // link the logo at your main site instead of the landing page
};
```
## Top-level values
[Section titled “Top-level values”](#top-level-values)
`chrome` accepts one of three values.
| Value | Result |
| -------------------- | -------------------------------------------------- |
| omitted or `false` | No chrome. The page renders alone. |
| `"managed"` | Coras default navbar and footer. |
| `CorasChromeOptions` | Set the `navbar` and `footer` slots independently. |
```ts
mount({ container, page, config }); // no chrome (default)
mount({ container, page, config, chrome: "managed" }); // Coras navbar + footer
mount({ container, page, config, chrome: false }); // explicit none
```
```ts
mount({
container,
page,
config,
chrome: { navbar: "managed", footer: false },
});
```
An omitted `navbar` or `footer` key inside `CorasChromeOptions` defaults to `false` (that slot is not rendered).
`chrome: false` is how you embed Coras pages into an app that already has its own navbar and footer - Coras renders the page alone, inside your layout. Use `"managed"` for a standalone Coras site, where Coras owns the whole page. See [Layout & styling](/getting-started/frameworks/#layout--styling) for the two modes.
## Slot values
[Section titled “Slot values”](#slot-values)
Each slot (`navbar`, `footer`) accepts the same shapes. The footer accepts the same shapes as the navbar except that its managed object form (`CorasManagedFooter`) takes no `slots` or `props`.
| Value | Behavior |
| ------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `false` | The slot is not rendered. |
| `"managed"` | The SDK lazy-loads and inserts the Coras element (`coras-navbar` or `coras-footer`) with default content. |
| `{ use: "managed", … }` | Coras element with host-projected slot content and, for the navbar, feature toggles. |
| `HTMLElement` | Your element is used as-is. |
| `() => HTMLElement \| Promise` | Factory called on each page mount. Return value (or resolved value) is used. |
### Managed object: slots and props
[Section titled “Managed object: slots and props”](#managed-object-slots-and-props)
`{ use: "managed", slots, props }` renders the Coras navbar with host content projected into its named slots and optional feature toggles. For each entry in `slots`, the SDK sets the child’s `slot` attribute and appends it to the chrome element. The SDK applies `props` as element properties on the navbar. A slot with no host content stays blank.
```ts
const logo = document.createElement("img");
logo.src = "/logo.svg";
mount({
container,
page,
config,
chrome: {
navbar: { use: "managed", slots: { brand: logo } },
footer: "managed",
},
});
```
The navbar sizes a brand logo by height with intrinsic width, so any aspect ratio stays crisp - you do not need to style the element yourself. Adjust the size by setting `--navbar-logo-height` (default `2rem`, smaller below `800px`) or `--navbar-logo-max-width` (default `11.25rem`) on the navbar.
If you do not slot a `brand` element but set `config.theme.logo`, the managed navbar renders that URL as the default logo. A slotted `brand` always wins, so provide one when you need a link wrapper, custom markup, or your own `alt` text.
Use `props` to hide built-in controls, for example when you replace one with your own slot content. The toggles are element properties, so default-on controls can be switched off:
```ts
mount({
container,
page,
config,
chrome: {
navbar: {
use: "managed",
props: {
showLanguageSelector: false,
showCurrencySelector: false,
showSearch: false,
},
slots: {
brand: logo,
"actions-start": accountMenu, // before the basket
search: customSearch, // replaces the built-in search
},
},
footer: "managed",
},
});
```
### Direct element
[Section titled “Direct element”](#direct-element)
```ts
const myNav = document.createElement("nav");
myNav.textContent = "Custom nav";
mount({ container, page, config, chrome: { navbar: myNav } });
```
### Factory
[Section titled “Factory”](#factory)
The factory runs on each page mount. It can be synchronous or asynchronous. Use it when the navbar or footer needs per-page state.
```ts
mount({
container,
page,
config,
chrome: {
navbar: () => buildNavbar({ currentPage: page }),
},
});
```
The SDK rebuilds chrome on each page change, so factories run again and the navbar and footer stay bound to the current page. `update({ chrome })` also rebuilds chrome, so a factory passed to `update` runs again then.
## Navbar slots
[Section titled “Navbar slots”](#navbar-slots)
`CorasNavbarSlotName` lists the regions you can fill through `{ use: "managed", slots: { … } }`. Names describe the region you own, not the internal control beside it, and use logical `start`/`end` so they stay correct under right-to-left layout.
The regions lay out from the leading to the trailing edge, with built-in controls interleaved:
```plaintext
[ brand | nav | language/currency | actions-start | basket | actions-end | search ]
```
| Slot | Purpose |
| --------------- | -------------------------------------------------------------- |
| `brand` | Top-leading brand mark, commonly an `
` or `
`. |
| `nav` | Primary navigation, after the brand. |
| `actions-start` | Leading edge of the utility cluster, before the basket. |
| `actions-end` | Trailing edge of the utility cluster, before search. |
| `search` | Replaces the built-in search. Set `props.showSearch: false`. |
Unspecified slots stay blank. Injected content stays in the top bar on mobile; only the Coras language, currency, and search controls collapse into the menu drawer.
`nav`, `actions-start`, and `actions-end` keep whatever width your element asks for: a control is sized by its own label, and squeezing one wraps that label inside its own box. When the bar runs out of room, `search` gives the width up instead, so keep the element you put there flexible and size a control you cannot afford to lose against the narrowest bar you support.
## Navbar props
[Section titled “Navbar props”](#navbar-props)
`CorasNavbarProps` configures the built-in navbar. An omitted `show*` key keeps the default, which shows the control.
| Prop | Type | Default | Effect |
| ---------------------- | --------- | ------- | ----------------------------------------------------------- |
| `searchOnly` | `boolean` | `false` | Render the minimal navbar (brand + search). See note below. |
| `showLanguageSelector` | `boolean` | `true` | Show the language selector. |
| `showCurrencySelector` | `boolean` | `true` | Show the currency selector. |
| `showBasket` | `boolean` | `true` | Show the basket. |
| `showSearch` | `boolean` | `true` | Show the built-in search. |
| `logoHref` | `string` | unset | Link the brand logo at this URL. See below. |
`logoHref` is for a booking site that sits beside a main website. Set it and the logo becomes a plain link there, the way it behaves on the main site. Leave it unset and the logo stays a button that emits a `landing` navigation for your router to handle. Only `https:` and same-origin URLs are honoured; anything else is ignored. A slotted `brand` element owns its own navigation, so `logoHref` applies to the default logo only.
```ts
mount({
container,
page,
config,
chrome: {
navbar: { use: "managed", props: { logoHref: "https://example.com" } },
footer: "managed",
},
});
```
`searchOnly` is a mode, not a toggle. When `true`, it swaps in the minimal brand and search bar, takes precedence over the `show*` flags, and ignores the `nav`, `actions-start`, and `actions-end` slots. The `brand` slot is still rendered (and `search`, being the mode itself). The basket is not shown, so `showBasket` has no effect in this mode.
## Footer
[Section titled “Footer”](#footer)
`coras-footer` has no named slots. The managed footer always renders the Coras footer menu. Supply your own element through the direct or factory shape if you need a custom footer.
```ts
// Direct element.
const myFooter = document.createElement("footer");
myFooter.textContent = "© Your Company";
mount({
container,
page,
config,
chrome: { navbar: "managed", footer: myFooter },
});
// Factory, rebuilt on each page change.
mount({
container,
page,
config,
chrome: {
navbar: "managed",
footer: () => buildFooter({ currentPage: page }),
},
});
```
# Config
> Every CorasConfig field with its validation rules and error codes
`CorasConfig` is the `config` object you pass to `mount()`. This page lists every field, its validation rules, and the error codes validation can throw.
Validation runs before anything renders. Invalid input throws `CorasValidationError`. The same rules apply to the partial `config` you pass to `app.update()`.
```ts
type CorasConfig = {
apiUrl: string;
distributorId: string;
assetsUrl: string;
locale?: SupportedLocales;
allowedLocales?: SupportedLocales[];
currency?: SupportedCurrencies;
allowedCurrencies?: SupportedCurrencies[];
loyaltyPointsEnabled?: boolean;
theme?: CorasTheme;
colorScheme?: "light" | "dark" | "inherit";
};
```
## Required fields
[Section titled “Required fields”](#required-fields)
### `apiUrl`
[Section titled “apiUrl”](#apiurl)
The Coras API base URL. Non-empty string.
| Rule | Result |
| ---------------------------------------------------------------------------- | ----------------------- |
| `https://` URL | Accepted |
| `http://localhost`, `http://127.0.0.1`, `http://[::1]`, `http://*.localhost` | Accepted (dev only) |
| Missing, empty, or not a string | Throws `invalid_config` |
| Any other value | Throws `invalid_url` |
```ts
apiUrl: "https://api.coras.io";
```
### `distributorId`
[Section titled “distributorId”](#distributorid)
Your distributor identifier. Non-empty string. Missing, empty, or non-string values throw `invalid_config`.
```ts
distributorId: "your-distributor-id";
```
### `assetsUrl`
[Section titled “assetsUrl”](#assetsurl)
Where static assets (icons, payment-provider logos) are served from. The host must provide it: the SDK ships no default, so nothing ties the package to a specific deployment. Point it at your own CDN, at assets you self-host, or - for a test integration - at the sandbox origin (`https://assets.sandbox.coras.io/shared`). Accepts one of the forms below.
| Form | Example |
| ------------------------------------------------ | ---------------------------------- |
| `https://` URL (http allowed only for localhost) | `"https://assets.coras.io/shared"` |
| Root-relative path | `"/assets"` |
Rejected with `invalid_url`: empty string, a bare hostname (`images.coras.io`), protocol-relative URLs (`//host/x`), `javascript:` or `data:` URIs, and paths containing `..`.
```ts
assetsUrl: "https://assets.sandbox.coras.io/shared";
```
## Optional fields
[Section titled “Optional fields”](#optional-fields)
### `locale`
[Section titled “locale”](#locale)
Active BCP 47 locale. Casing and separators are normalized, so `en_ie` and `EN-IE` both become `en-IE`. Must resolve to a supported locale, or it throws `invalid_locale`.
```ts
locale: "en-IE";
```
### `allowedLocales`
[Section titled “allowedLocales”](#allowedlocales)
Locales the navbar language switcher offers. Each entry is normalized the same way as `locale`. Any unsupported entry throws `invalid_locale`.
```ts
allowedLocales: ["en-IE", "pl-PL", "de-DE"];
```
### `currency`
[Section titled “currency”](#currency)
Active ISO 4217 currency. Casing is normalized, so `eur` becomes `EUR`. Must resolve to a supported currency, or it throws `invalid_currency`.
```ts
currency: "EUR";
```
### `allowedCurrencies`
[Section titled “allowedCurrencies”](#allowedcurrencies)
Currencies the navbar currency switcher offers. Each entry is normalized the same way as `currency`. Any unsupported entry throws `invalid_currency`.
```ts
allowedCurrencies: ["EUR", "GBP", "USD"];
```
Note
`allowedLocales` and `allowedCurrencies` only take effect with the managed navbar chrome, which renders the switchers. Without it there is no built-in UI for them.
### `loyaltyPointsEnabled`
[Section titled “loyaltyPointsEnabled”](#loyaltypointsenabled)
Enable the loyalty-points UI on the details and payment pages, so customers can earn and spend points during checkout. Defaults to `false`. Requires the loyalty programme to be activated on your distributor account - contact Coras.
```ts
loyaltyPointsEnabled: true;
```
### `theme`
[Section titled “theme”](#theme)
A `brand.json` (the shared branding contract), grouped by surface or as flat keys, at any supported `schemaVersion`, scoped to the mounted container. See [Theming and branding](/reference/theming/).
```ts
theme: { primary: "#4657d4", radius: "1rem", navbar: { background: "#161c44" } }
```
### `colorScheme`
[Section titled “colorScheme”](#colorscheme)
Selects which side of the brand’s `light-dark()` token pairs renders. Flippable live via `app.update({ config: { colorScheme } })` with no remount. See [Dark mode](/reference/theming/#dark-mode).
| Value | Behaviour |
| ----------- | --------------------------------------------------------------- |
| `"light"` | Default. Always light, ignoring the host page. |
| `"dark"` | Always dark. |
| `"inherit"` | Follows the host page’s own `color-scheme` (the SDK sets none). |
```ts
colorScheme: "inherit";
```
## Page param validation
[Section titled “Page param validation”](#page-param-validation)
`mount()` also validates the `params` object against the page you mount. These checks throw `invalid_page_params`.
| Page | Param | Rule |
| ------------------- | ---------- | ------------------------------------------------------------ |
| `details` | `id` | When present, must be a non-empty string |
| `payment` | `metadata` | When present, must be an object whose values are all strings |
| `suggestion-widget` | `limit` | When present, must be a positive number |
## Strict mode
[Section titled “Strict mode”](#strict-mode)
Strict mode is off by default. Pass `strict: true` to `mount()` to reject unknown keys.
| `strict` | Unknown `config` or `params` keys |
| ----------------- | --------------------------------- |
| `false` (default) | Ignored |
| `true` | Throw `unknown_key` |
The `strict` flag you pass to `mount()` also applies to later `app.update()` calls.
## Logging and diagnostics
[Section titled “Logging and diagnostics”](#logging-and-diagnostics)
`mount()` never writes to the console on its own. Pass a `logger` to receive what it would have said:
```ts
type CorasLogger = {
warn?: (message: string, data?: unknown) => void;
error?: (error: unknown, data?: unknown) => void;
};
```
`error` receives the original error, with its stack, for every failure the SDK also reports through [`onError`](/reference/events/). Wire it to your error reporter.
`warn` is the opt-in to the SDK’s diagnostics, which is why it is separate. Wiring it turns on the checks that catch a misconfigured mount: an `assetsUrl` that does not serve the icons, a `config.theme` with no brand colours, and a managed-chrome page an ancestor is constraining. Each warns once. `strict: true` turns the same checks on and additionally prints them to the console.
That makes `warn` a development channel. Wiring it in production runs the assets probe on every mount, so pass it only where you want the checks:
```ts
mount({
container,
page,
config,
logger: { error: (error) => reportToSentry(error) },
strict: import.meta.env.DEV,
});
```
## Validation errors
[Section titled “Validation errors”](#validation-errors)
`CorasValidationError` carries a stable `code`, an optional `field` path, and a redacted `message`.
```ts
import { mount, CorasValidationError } from "@coras-io/embed";
try {
mount({ container, page: "landing", config: { apiUrl: "ftp://x" } as never });
} catch (error) {
if (error instanceof CorasValidationError) {
error.code; // CorasValidationErrorCode, e.g. "invalid_url"
error.field; // "config.apiUrl"
error.message; // safe, human-readable message
}
}
```
### Error codes
[Section titled “Error codes”](#error-codes)
```ts
type CorasValidationErrorCode =
| "missing_container"
| "invalid_page"
| "invalid_config"
| "invalid_url"
| "invalid_locale"
| "invalid_currency"
| "invalid_page_params"
| "unknown_key";
```
| Code | When |
| --------------------- | -------------------------------------------------------------------------------------------- |
| `missing_container` | `container` is not a DOM `Element` |
| `invalid_page` | `page` is not one of the six public pages |
| `invalid_config` | `config` is missing, not an object, or `apiUrl` / `distributorId` is missing or not a string |
| `invalid_url` | `apiUrl` or `assetsUrl` fails its URL rules |
| `invalid_locale` | `locale` or any `allowedLocales` entry is unsupported |
| `invalid_currency` | `currency` or any `allowedCurrencies` entry is unsupported |
| `invalid_page_params` | A page param has the wrong shape (see [Page param validation](#page-param-validation)) |
| `unknown_key` | Strict mode found an unknown key in `config` or `params` |
Note
Error messages are safe to surface to end users. The SDK redacts them so they never leak secrets, raw values, or internal paths.
# Events reference
> Every coras:* event payload and its matching mount() callback.
The host-facing contract is six `coras:*` events. Each event has a matching `mount()` callback. Internal component events (form fields, calendar, seats.io, overlay coordination, and others) are not part of this contract and do not cross the SDK boundary.
You can receive each event two ways:
* A `mount()` callback (recommended). See [Receive events](#receive-events).
* A DOM listener on the mounted container (advanced).
All payloads are minimal and redacted. They never carry secrets, payment provider data, or personal data.
## Events
[Section titled “Events”](#events)
| Event | Callback | Fired when |
| --------------------------- | ---------------------- | --------------------------------------------------------------------------- |
| `coras:ready` | `onReady` | The page is mounted and its first render is complete. |
| `coras:navigate` | `onNavigate` | The embed signals a navigation intent (cross-page action or external link). |
| `coras:state-change` | `onStateChange` | In-page state changed, such as filters or selection. |
| `coras:reservation-created` | `onReservationCreated` | A reservation was created. |
| `coras:payment-status` | `onPaymentStatus` | A payment succeeded, failed, or is still awaiting confirmation. |
| `coras:error` | `onError` | The SDK produced a safe, redacted error. |
`PageName` below is the `CorasPageName` union: `"landing"`, `"search"`, `"details"`, `"payment"`, `"help"`, or `"suggestion-widget"`. `CorasPageParams` is the per-page params type. `SupportedLocales` and `SupportedCurrencies` are the locale and currency unions. All four types are exported from `@coras-io/embed`.
## `coras:ready`
[Section titled “coras:ready”](#corasready)
Fired when the page is mounted and its first render is complete.
```ts
type CorasReadyDetail = {
page: CorasPageName;
};
```
| Field | Type | Notes |
| ------ | --------------- | ----------------- |
| `page` | `CorasPageName` | The mounted page. |
## `coras:navigate`
[Section titled “coras:navigate”](#corasnavigate)
A navigation intent: a cross-page action or an external link. The host maps it to its own router.
```ts
type CorasNavigateDetail = {
page: CorasPageName;
params?: CorasPageParams;
locale?: SupportedLocales;
currency?: SupportedCurrencies;
href?: string;
source: "embed" | "host";
};
```
| Field | Type | Notes |
| ---------- | --------------------- | -------------------------------------- |
| `page` | `CorasPageName` | Target page for the navigation intent. |
| `params` | `CorasPageParams` | Optional params for the target page. |
| `locale` | `SupportedLocales` | Optional locale for the target page. |
| `currency` | `SupportedCurrencies` | Optional currency for the target page. |
| `href` | `string` | Present for external links. |
| `source` | `"embed" \| "host"` | Origin of the intent. |
## `coras:state-change`
[Section titled “coras:state-change”](#corasstate-change)
In-page state changed, such as filters or selection. This is not necessarily a route change. The host decides whether to persist it in the URL.
```ts
type CorasStateChangeDetail = {
page: CorasPageName;
params: CorasPageParams;
locale?: SupportedLocales;
currency?: SupportedCurrencies;
};
```
| Field | Type | Notes |
| ---------- | --------------------- | ------------------------------------- |
| `page` | `CorasPageName` | The page whose state changed. |
| `params` | `CorasPageParams` | Current page params after the change. |
| `locale` | `SupportedLocales` | Optional current locale. |
| `currency` | `SupportedCurrencies` | Optional current currency. |
## `coras:reservation-created`
[Section titled “coras:reservation-created”](#corasreservation-created)
A minimal, redacted reservation summary. It never contains the raw API response.
```ts
type CorasReservationCreatedDetail = {
reservationId?: string;
expiresIn?: number;
ticketDeliveryMethod?: number;
};
```
| Field | Type | Notes |
| ---------------------- | -------- | --------------------------------------- |
| `reservationId` | `string` | Public reservation id. |
| `expiresIn` | `number` | Seconds until the reservation expires. |
| `ticketDeliveryMethod` | `number` | Ticket delivery method code (see note). |
Note
`ticketDeliveryMethod` is a numeric code from the Coras API, not an enum the SDK defines. For the current code-to-method mapping, see the API catalogue or contact Coras.
## `coras:payment-status`
[Section titled “coras:payment-status”](#coraspayment-status)
A payment status with an optional public reference.
```ts
type CorasPaymentStatusDetail = {
status: "succeeded" | "failed" | "pending";
reference?: string;
};
```
| Field | Type | Notes |
| ----------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `status` | `"succeeded" \| "failed" \| "pending"` | `pending` means the charge succeeded but the API had not confirmed the transaction within the polling budget; use `reference` to reconcile it later. |
| `reference` | `string` | Public reference only. Never provider secrets or card data. |
## `coras:error`
[Section titled “coras:error”](#coraserror)
A safe, redacted error.
```ts
type CorasErrorDetail = {
code: string;
message: string;
page?: CorasPageName;
};
```
| Field | Type | Notes |
| --------- | --------------- | ------------------------------------------------------- |
| `code` | `string` | Stable machine code, for example `invalid_page_params`. |
| `message` | `string` | Safe, redacted, human-readable message. |
| `page` | `CorasPageName` | Page the error relates to, when known. |
`code` is an open string so new codes can be added without breaking your integration. Match on the codes you handle and treat any unknown code as a generic error. These are the codes the SDK emits today.
| Code | When |
| --------------------- | ------------------------------------------------------------------------------------------------- |
| `missing_container` | `container` is not a DOM element. |
| `invalid_page` | `page` is not one of the six public pages. |
| `invalid_config` | `config` is missing or a required field is absent. |
| `invalid_url` | `apiUrl` or `assetsUrl` fails its validation rules. |
| `invalid_locale` | `locale` or an `allowedLocales` entry is unknown. |
| `invalid_currency` | `currency` or an `allowedCurrencies` entry is unknown. |
| `invalid_page_params` | A per-page param has the wrong shape. |
| `unknown_key` | Strict mode found an unknown key in `config` or `params`. |
| `mount_error` | Any other error during mount or page load. The message is generic so internal details never leak. |
| `callback_error` | A host callback threw. The SDK reports it through `onError`, unless `onError` itself threw. |
The first eight are `CorasValidationError` codes (see [Config validation errors](/reference/config/#validation-errors)). `mount_error` and `callback_error` are runtime fallbacks.
Wrap your callbacks
`callback_error` means one of your `mount()` callbacks threw. Wrap callback bodies in `try/catch` and handle failures yourself; the SDK only reports the throw, it cannot recover your callback’s work.
### Recover from errors
[Section titled “Recover from errors”](#recover-from-errors)
Split the codes by how you respond:
* **Recoverable** - `invalid_config`, `invalid_url`, `invalid_locale`, `invalid_currency`, `invalid_page_params`, `unknown_key`. Fix the offending input and retry with a corrected `mount()` or `app.update()`.
* **Terminal** - `missing_container`, `invalid_page`, `mount_error`. The page cannot render; show your own fallback UI instead of retrying blindly.
## Receive events
[Section titled “Receive events”](#receive-events)
Use `mount()` callbacks for normal integrations.
```ts
import { mount } from "@coras-io/embed";
const app = mount({
container: document.getElementById("coras")!,
page: "search",
config,
onNavigate(detail) {
router.push(detail.page, detail.params);
},
onPaymentStatus(detail) {
if (detail.status === "succeeded") showConfirmation();
},
});
```
For advanced cases, listen for the DOM events on the mounted container. The events bubble and are composed, so they reach the container even when the page renders inside a shadow root.
```ts
container.addEventListener("coras:payment-status", (event) => {
console.log(event.detail.status);
});
```
The `CorasEvent` type, exported from `@coras-io/embed`, gives typed `event.detail` for DOM listeners. It is a `CustomEvent` keyed by event name:
```ts
import type { CorasEvent } from "@coras-io/embed";
container.addEventListener(
"coras:navigate",
(event: CorasEvent<"coras:navigate">) => {
event.detail.page; // typed CorasNavigateDetail
event.detail.source; // "embed" | "host"
},
);
```
## Multiple mounts on one page
[Section titled “Multiple mounts on one page”](#multiple-mounts-on-one-page)
Callbacks are scoped to their own mount. Listeners attach to each mount’s own element, so an event from one mount never reaches another mount’s callbacks.
A DOM listener on a shared ancestor receives bubbling events from every mount below it. Listen on the specific container to scope DOM events to one mount.
## Observability
[Section titled “Observability”](#observability)
Separate from the six `coras:*` host events, the `onObservability` callback reports per-mount lifecycle and diagnostic facts (dot-namespaced names such as `coras.page-loaded`). It has no matching DOM event.
```ts
type CorasObservabilityEvent = {
name: string; // e.g. "coras.page-loaded", "coras.error", "coras.unmounted"
page?: CorasPageName;
data?: Record;
};
```
See [Observe lifecycle events](/getting-started/events/#observe-lifecycle-events) for the emitted names and their `data` keys.
# LLMs
> Plain-text documentation files for AI assistants and LLMs
This site publishes its documentation as plain-text files for use as context in AI assistants and AI coding tools. The files follow the [llms.txt specification](https://llmstxt.org/).
## Available files
[Section titled “Available files”](#available-files)
| File | URL | Contents |
| -------- | ------------------------------------ | ---------------------------------------------- |
| Index | [`/llms.txt`](/llms.txt) | Links to the documentation sections. |
| Full | [`/llms-full.txt`](/llms-full.txt) | Every page, concatenated. |
| Abridged | [`/llms-small.txt`](/llms-small.txt) | Full content with non-essential parts removed. |
All three files are generated at build time from the same source pages as this site, so they stay in sync with the published documentation.
## Use the files
[Section titled “Use the files”](#use-the-files)
Add one of these URLs to your AI assistant’s context when you work with Coras Embed:
* `https://embed.coras.io/llms.txt`
* `https://embed.coras.io/llms-full.txt`
* `https://embed.coras.io/llms-small.txt`
Use `llms.txt` when the tool follows links itself. Use `llms-full.txt` when the tool needs the complete content in one file. Use `llms-small.txt` when you need to fit the documentation into a smaller context window.
# Pages and params
> Every page you can mount and its typed params
You can mount six pages. Each page has a typed param shape. All params are optional; the SDK applies only the keys you pass. Provide params through `mount({ params })` or `app.update({ params })`.
Dropping a param, or passing it as an empty string, clears it back to the page default. This matters when you echo `coras:state-change` back into `app.update()`: a page reports its whole param set, including the fields the visitor has just cleared.
```ts
import { mount, type CorasPageParamsByPage } from "@coras-io/embed";
mount({ container, page: "landing", config, params: { city: "dublin" } });
```
Note
`mount()` ignores unknown param keys by default. Pass `strict: true` to reject them: an unknown key then throws `CorasValidationError` with code `unknown_key`. See [Strict mode](/reference/config/#strict-mode).
## `landing`
[Section titled “landing”](#landing)
```ts
type CorasLandingPageParams = {
country?: string;
city?: string;
startDate?: string;
endDate?: string;
sort?: string;
duration?: string;
price?: string;
categories?: string;
};
```
| Param | Format | Notes |
| ----------------------- | ------------ | ---------------------------------------------------------- |
| `country` | slug | For example `"ireland"`. Filters listings to this country. |
| `city` | slug | For example `"dublin"`. Narrower than `country`. |
| `startDate` / `endDate` | `YYYY-MM-DD` | ISO date strings. Range filter. |
| `sort` | string | Distributor-defined sort key. |
| `duration` | string | Distributor-defined duration bucket. |
| `price` | string | Distributor-defined price bucket. |
| `categories` | string | Comma-separated category slugs. |
Changing any of these in-page fires `coras:state-change`. Selecting a result fires `coras:navigate` to `details`.
Note
Valid values for `sort`, `duration`, `price`, and `categories` depend on the inventory configured for your `distributorId`. Read them from the filters the landing page renders rather than hard-coding them.
## `search`
[Section titled “search”](#search)
```ts
type CorasSearchPageParams = {
search?: string;
startDate?: string;
};
```
| Param | Format | Notes |
| ----------- | ------------ | --------------------- |
| `search` | string | Free-text query. |
| `startDate` | `YYYY-MM-DD` | Optional date filter. |
Changing `search` or `startDate` in-page fires `coras:state-change`. Selecting a result fires `coras:navigate` to `details`.
## `details`
[Section titled “details”](#details)
```ts
type CorasDetailsPageParams = {
id?: string; // public attraction id (maps to internal `attraction-id`)
date?: string; // YYYY-MM-DD
};
```
| Param | Format | Notes |
| ------ | ------------ | ------------------------------------------------------------------------------------------------------- |
| `id` | string | Public attraction id. Required for the page to render content. Must be a non-empty string when present. |
| `date` | `YYYY-MM-DD` | Selected date. Defaults to today. |
Date changes fire `coras:state-change`. A successful booking fires `coras:reservation-created`, and the page navigates to `payment`.
Note
You get the `id` from the params of the `coras:navigate` event emitted when a user selects a result on the landing or search page - route straight to it, no independent lookup needed.
## `payment`
[Section titled “payment”](#payment)
```ts
type CorasPaymentPageParams = {
metadata?: Record; // arbitrary client data; stored and echoed back
};
```
| Param | Format | Notes |
| ---------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `metadata` | `Record` | Arbitrary client key/value data forwarded to and stored by the API, then returned when the order is read back (for example a user id, analytics ids, or a loyalty-card number). Coras does not interpret it. Every value must be a string. This is host context, not shareable URL state, so the `@coras-io/embed/url` helpers do not round-trip it. Supply it to `mount()` directly. |
The page fires `coras:payment-status` with `succeeded` or `failed`, or with `pending` when the charge succeeded but the API had not confirmed the transaction within the polling budget (the customer is shown a “confirming your payment” state and the `reference` lets you reconcile it later). There is no cancelled status: cancelling returns the form to its editable state rather than emitting.
## `help`
[Section titled “help”](#help)
```ts
type CorasHelpPageParams = {
content?: string; // e.g. "faq-attractions", "contact", "privacy-policy"
};
```
| Param | Format | Notes |
| --------- | ------ | -------------------------------------------------------------------------------------------------- |
| `content` | string | Help content identifier. Switching tabs in-page fires `coras:state-change` with the new `content`. |
The `content` values shown above (`faq-attractions`, `contact`, `privacy-policy`) are illustrative, not exhaustive - for example there are further FAQ topics such as `faq-music`. `content` is an open string; the available identifiers depend on the help content configured for your `distributorId`.
External links inside help content fire `coras:navigate` with `href`.
## `suggestion-widget`
[Section titled “suggestion-widget”](#suggestion-widget)
A standalone recommendations embed. It is not a routable page; mount it directly in the host page.
```ts
type CorasSuggestionWidgetParams = {
limit?: number; // > 0
showExploreCard?: boolean;
country?: string;
city?: string;
};
```
| Param | Format | Notes |
| ------------------ | ---------- | ---------------------------------------------------------------------------------------------------- |
| `limit` | number > 0 | Maximum number of suggestions to render. Must be a positive number when present. |
| `showExploreCard` | boolean | Append an “explore” card that links to the full landing page. On by default; set `false` to hide it. |
| `country` / `city` | slug | Geographic scope for suggestions, and the explore card’s image and target. |
Selecting a suggestion fires `coras:navigate` to `details` or `landing`.
## `chrome-only`
[Section titled “chrome-only”](#chrome-only)
The managed chrome around a page you own, with no Coras page inside it. Mount it for your own routes so they carry the same navbar, footer and basket as the embedded ones, instead of a second navbar you keep in step by hand. It takes no params and is not routable: your router owns the URL, and `buildCorasUrl` rejects it.
Your markup goes in `content`, an element you create and render into. Hold one element for the life of the mount and the SDK re-parents it as pages change, so whatever you have drawn into it survives.
```ts
const content = document.createElement("div");
const app = mount({
container,
page: "chrome-only",
config,
chrome: { navbar: { use: "managed" }, footer: "managed" },
content,
});
// Your own route later, in the same mount: the chrome is untouched.
app.update({ page: "details", params: { id } });
```
Unlike a Coras page, the content region carries no width, padding or spacing of its own: it is yours to lay out, full-bleed if you want it.
`content` is ignored by every other page, so one mount can alternate between your routes and embedded ones without rebuilding the chrome’s configuration.
## Validation
[Section titled “Validation”](#validation)
Pass `strict: true` to reject unknown param keys. Without it, `mount()` ignores them.
```ts
import { CorasValidationError } from "@coras-io/embed";
try {
mount({
container,
page: "details",
config,
params: { id: "abc", oops: 1 },
strict: true,
});
} catch (e) {
if (e instanceof CorasValidationError) {
e.code; // "unknown_key"
e.field; // "params.oops"
}
}
```
Shape errors throw regardless of strict mode. For example, an empty `details.id` or a non-positive `suggestion-widget.limit` throws `CorasValidationError` with code `invalid_page_params`.
See [Config](/reference/config/#error-codes) for the full error-code list.
# Theming and branding
> Reference for the four-tier CSS variable system, the brand.json contract and its keys, gradients, dark mode, versioning, container scoping, and the full token catalog.
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](#set-the-brand).
## Token tiers
[Section titled “Token tiers”](#token-tiers)
Tokens resolve from raw primitives down to per-component values. Later tiers reference earlier ones.
```plaintext
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](#authored-and-published), so a rebrand stays coherent while every colour remains directly settable.
CSS cascade layers keep tier precedence stable, declared in this order:
```css
@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”](#set-the-brand)
You can set brand tokens in three ways. Pick one based on how much control you need.
Tip
Use [Coras Studio](https://branding.coras.io) to pick colours, a logo, and fonts, preview live, and export a `brand.json` you can pass as `config.theme`.
### config.theme (typed)
[Section titled “config.theme (typed)”](#configtheme-typed)
Pass `theme` in the mount config. `theme` takes a `brand.json` - the shared, portable branding contract from [`@coras-io/brand-tokens`](#shared-branding-engine) 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.
```ts
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:
```ts
theme: {
primary: "#4657d4",
secondary: "#161c44",
radius: "1rem",
navbar: { background: "#161c44", text: "#ffffff" },
}
```
See [brand.json keys](#brandjson-keys) for the full list, and [The brand.json file](#the-brandjson-file) for the schema, versioning and what the file holds.
### Raw CSS
[Section titled “Raw CSS”](#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.
```css
[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”](#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.
```ts
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()`](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/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`.
```ts
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.
Note
`colorScheme` is the only switch you need - there is no `data-coras-theme` attribute to set.
Caution
Embedded pages are transparent and inherit the host page’s background. Setting `colorScheme: "dark"` without darkening your own page leaves a white panel behind the dark embed. Give the host a dark background in dark mode too.
## brand.json keys
[Section titled “brand.json keys”](#brandjson-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](#gradients), `none`), `text` (`#000000`), `icon` (follows `navbar.text`), `border` (follows `navbar.background`), `shadow`, `logo` |
| `footer` | `background` (`#e6e6e6`), `backgroundImage` (a [gradient](#gradients), `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] [ []] `, the colour last. A `backgroundImage` is `none` or a [gradient](#gradients). 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](/reference/chrome/#managed-object-slots-and-props).
Note
Several brand colors and `--coras-brand-radius` are registered as `@property` rules with a typed syntax (`` and ``), so transitions on them animate correctly. The gradients are not: a `` property cannot hold an image.
### Gradients
[Section titled “Gradients”](#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.
```json
"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 `, or `radial-gradient()` with a shape, size and `at `, 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”](#the-brandjson-file)
```json
{
"$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`](#dark-mode), never from the brand.
**Schema.** [`brand.schema.json`](https://embed.coras.io/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”](#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.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.
## Semantic tier
[Section titled “Semantic tier”](#semantic-tier)
Override these to change a role across the whole system without touching the brand tier.
### Colors
[Section titled “Colors”](#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”](#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` |
### Radii
[Section titled “Radii”](#radii)
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”](#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”](#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”](#components-tier)
Override these to change one component without affecting others.
```css
[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”](#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.
```html
...
...
```
## Shared branding engine
[Section titled “Shared branding engine”](#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-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](https://tr.designtokens.org/format/) 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.
```ts
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`.