# Theming

Theming is **opt-in**. Set `theme`, `themeMode`, or both on `<YodlProvider>` — or on any hosted component — to style the Yodl UI. Omit them and the hosted UI keeps its own appearance. Every field is optional.

```tsx
import type { YodlTheme } from '@yodlpay/react-native';
import { YodlPayment, YodlProvider } from '@yodlpay/react-native';

const brandTheme: YodlTheme = {
  name: 'acme',
  light: { bg: '#ffffff', surface: '#f4f4f5', text: '#18181b', accent: '#6d4fc0' },
  dark: { bg: '#18181b', surface: '#27272a', text: '#fafafa', accent: '#9662ff' },
  images: {
    light: { loading: 'https://cdn.example.com/yodl-loading-light.mp4' },
    dark: { loading: 'https://cdn.example.com/yodl-loading-dark.mp4' },
  },
};

<YodlProvider
  sdk={sdk}
  provider={provider}
  address={address}
  chainId={chainId}
  theme={brandTheme}
  themeMode="system"
>
  <YodlPayment qrData={qrData} />
</YodlProvider>;
```

## Modes

`themeMode` is `'system'` (follow the device), `'light'`, or `'dark'`. The two props combine as:

| `theme` | `themeMode` | Result |
| --- | --- | --- |
| Omitted | Omitted | Hosted UI keeps its own appearance |
| Set | Omitted | Your palette, following the device |
| Omitted | Set | Stock Yodl palette, pinned to that mode |
| Set | Set | Your palette, pinned to that mode |

## Defaults and overrides

`theme` and `themeMode` on `<YodlProvider>` are defaults for every hosted component beneath it. The same props on `<YodlSignup>`, `<YodlPayment>` and `<YodlDashboard>` override the provider **per prop** — set one and the other still inherits.

```tsx
// Provider follows the device; this one screen pins dark but keeps the palette.
<YodlProvider sdk={sdk} provider={provider} address={address} chainId={chainId} theme={brandTheme}>
  <YodlPayment qrData={qrData} themeMode="dark" />
</YodlProvider>;
```

Remove both props from the component *and* its provider to hand appearance control back to the hosted UI.

## Tokens

`name` is an optional label, 64 characters or fewer. Colours go per mode under `light` and `dark`; omitted tokens keep their stock value.

* **Core** — `bg` `surface` `surfaceSecondary` `text` `textSecondary` `textMuted` `border` `success` `error` `warning` `accent`
* **Fine-tune**, derived from core when omitted — `bgDeep` `surfaceHover` `skeleton` `accentStrong` `accentDeep`
* **Brand** — `brandSurface` `brandSheet` `brandText` `brandButton` `brandButtonText`

`font`, `radiusControl` and `radiusSurface` sit at the top level rather than inside a mode, because none is a colour and none varies between light and dark. See [Shape and typography](#shape-and-typography).

:::tip
[The theme builder](/sdk/react-native/theme-builder) generates a link that opens the hosted UI with your palette applied, so you can see all of this without rebuilding an app.
:::

## Brand surfaces

These behave differently from the rest of the palette, in two ways worth knowing before you set them.

| Token | What it styles |
| --- | --- |
| `brandSurface` | The **Pay shell** — the payment screen itself |
| `brandSheet` | Sheets and onboarding |
| `brandText` | Text on both of the above |
| `brandButton` | The pay button on the brand shell |
| `brandButtonText` | Text on that button |

**They are mode-invariant.** A brand value set in only one mode mirrors into the other, so you do not need to repeat them under both `light` and `dark`. Setting them differently per mode is legal, but the default is one shared appearance.

**The last three derive themselves.** As soon as you set a custom `brandSurface` (or `brandSheet`), the runtime fills in whatever you left out, in a chain:

* `brandText` → white or near-black, whichever contrasts better against the brand surface
* `brandButton` → the same contrast colour
* `brandButtonText` → whichever contrasts better against **the button**, not the surface

So a single `brandSurface` gives you a readable shell for free, and a *light* brand surface correctly gets near-black text rather than the stock white.

:::warning
That is a reason to leave them unset. Overriding `brandText` by hand can only lower the contrast the runtime would have chosen — it does not re-derive around your value. Set the surface, check it, and only override if you genuinely need a specific colour.
:::

## Shape and typography

```tsx
const theme: YodlTheme = {
  font: 'geist',
  radiusControl: '12px',
  light: { bg: '#ffffff' },
};
```

`font` is one of `'inter'` (the stock face), `'geist'`, `'mono'` or `'system'`. It is a fixed list rather than a family name you supply, because a font bundled in your app is not visible to the WebView that hosts the Yodl UI — naming it would silently fall back to Inter and look like a bug. Ask us if you need a face that is not listed.

`radiusControl` is the corner radius of every button, input and badge; stock is a full pill. Cards, rows and sheets follow it in both directions, so one value reshapes the UI coherently:

| `radiusControl` | Buttons | Cards and rows | Sheets |
| --- | --- | --- | --- |
| `'0'` | square | 0 | 0 |
| `'12px'` | 12px | 6px to 12px | 18px |
| `'32px'` | 32px | 16px to 32px | 48px |
| omitted | pill | 6px to 16px | 24px |

Containers scale *proportionally* from the value rather than matching it: the surface scale keeps its steps, so a list row and a receipt shell stay visibly different from each other.

A pill-scale value (100px and up, or 50% and up) asks for a shape rather than a size, so buttons go pill while surfaces stay at their stock radius instead of becoming lozenges. Set `radiusSurface` when containers should differ from the buttons.

Circular elements — avatars, token icons, sheet grab handles, status rings — stay circular and follow neither key.

## Images

Image slots go under `images.light` and `images.dark`, each supporting `payBackground`, `loading` and `processing`. `loading` and `processing` are **one visual slot**: the hosted UI has a single splash state, rendered from `loading` with `processing` consulted only when `loading` is unset — set `loading` alone and it covers everything. Media travels by reference — you host the asset, the theme carries the URL.

**Sizing of the splash media.** `loading` renders in the hosted UI's centred column, in normal flow directly above the stepped status caption — where the stock Yodl mark would sit, never over the caption or the whole viewport.

The media keeps its own aspect ratio and is scaled proportionally within maximum dimensions; it is not placed into a container of a set size. The maxima are 16rem wide, no more than 70% of the viewport width, and no more than 40% of its height. Width is applied first and the height follows from the asset's own ratio, so a wide asset occupies a shorter band than a square one and the vertical space taken varies by shape. Source pixel dimensions cannot bypass the maxima — a 4000px-wide asset is scaled down to the same width as a 400px-wide one of the same shape, only sharper. The fit is `contain`, so the media is only ever scaled down to stay within the maxima: it is never cropped to fill the viewport the way a background image would be, and the whole asset stays visible. Videos and images are treated identically.

Authoring: roughly square (around 512×512) scales most predictably, keep important detail away from the edges, and use transparency if you want it to sit on the theme's `bg`. For a full-screen brand backdrop, use `payBackground` instead.

A video URL — `.mp4`, `.m4v`, `.webm` or `.mov` — plays as a looping muted video. **Everything else renders as an `<img>`** — that is the only branch, so any image format the browser decodes works, including the animated ones: animated WebP, APNG, animated AVIF and GIF.

`.ogv` is deliberately not on that list: Chrome and Firefox decode Ogg/Theora but iOS WebViews do not, and a container on the list goes to `<video>` with no `<img>` fallback — so accepting it would blank the splash on iOS.

:::tip
For an animated splash with alpha, prefer **animated WebP** (full colour, 8-bit alpha, small) or **APNG** (same fidelity, usually larger). GIF works but its 256-colour palette and 1-bit transparency fringe around antialiased edges. WebM with alpha is also detected and played now.
:::

:::danger
A URL that is neither an image nor a playable video fails quietly: a `.pdf`, a `.mkv`, or a plain page URL passes validation (the schema checks the URL's *shape*, not what it points at) and then renders as a broken image with no error.
:::

## Validation

* Colours must be `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, `rgb(…)` or `rgba(…)`.
* `font` must be one of the four listed faces.
* `radiusControl` and `radiusSurface` must be a plain CSS length: a bare `0`, or up to four digits with `px`, `rem`, `em` or `%`. A unitless number such as `'4'` is not a CSS length and is rejected, as are `calc(…)` and `var(…)`.
* Image URLs must be `https://`, at most 2048 characters, with no whitespace, quotes or backslashes.
* One invalid key rejects the whole theme, so the UI falls back to stock rather than applying a half-valid palette. `safeParseYodlTheme()` returns the offending key by name.

:::tip
An invalid `theme` or `themeMode` **never crashes**. It falls back to stock Yodl and logs the error when `__DEV__` is true — so check your dev console if a palette silently doesn't apply.
:::

## Live updates

Changing `theme` or `themeMode` on a mounted component applies over the bridge **without reloading**, so the active flow keeps its state.

:::warning
`yodlUiBaseUrl` must point at a current hosted UI build that supports correlated readiness. The theme is delivered on that handshake, so against an older build it is never applied: the WebView is revealed by the SDK's 8-second ceiling instead, showing stock Yodl colours rather than yours. See [the two ceilings](/sdk/react-native/troubleshooting#the-two-ceilings).
:::

## The loading overlay

Before the hosted UI paints, the SDK covers the WebView with an overlay. Its colours come from the same resolved theme:

| Part | Colour |
| --- | --- |
| The overlay's fill, and the surface behind the WebView | `backgroundColor` if set, otherwise the resolved theme's `bg`, otherwise `#18181b` dark / `#f8f8fc` light |
| The `ActivityIndicator` | The resolved theme's `textSecondary`, otherwise `#a1a1aa` dark / `#52525b` light |

Setting `bg` — or `backgroundColor` for a one-off — is enough to make the overlay match your app, and is the cheapest way to remove a light/dark flip at the seam between your screen and the hosted one.

The indicator itself is only the default. `renderLoading` replaces it with your own element, and returning `null` removes the overlay entirely — in which case only the first row of that table still applies, since `backgroundColor` is still painted behind the WebView. See [loading and readiness](/sdk/react-native/api#loading-and-readiness).
