How previews work
When you finish this page you can explain, and debug, every step between a designer pressing Preview in the Sorb™ plugin and a colour changing in your running app — including the three banner states and the four ways a preview fails. You need an app already wired per Getting started; nothing here is copy-paste setup.
The lifecycle in one pass
A preview is a token set the bridge holds under a short id. Your app fetches it by that id and writes it over the committed tokens. Nothing is written to disk in your repo, and the committed tokens are always the fallback.
Figma plugin bridge (sorb dev / hosted) your running app
| | |
| POST /preview {tokens} | |
|------------------------------->| stores the body, mints an |
| <-- { id: "V1StGXR8" } | 8-character id |
| | |
| opens <appUrl>?preview=V1StGXR8 -------------------------------->|
| | |
| |<-- GET /preview/V1StGXR8 ------|
| |--- 200 {"bs-primary":"#f26722"} ---->|
| | | applies to <html>
| PUT /preview/V1StGXR8 (edit) | |
|------------------------------->|<-- GET /preview/V1StGXR8 ------| every 1500 ms
| | (or one SSE stream) |
| DELETE /preview/V1StGXR8 | |
|------------------------------->| gone -> app falls back to committed
The id is eight characters, minted by the bridge on POST /preview. On the
hosted bridge a preview expires after 24 hours unless your plan carries
persistent previews.
What the SDK does with ?preview=
SorbProvider (and sorbInit, the framework-free entry) reads the preview
query parameter once at startup and then runs four checks in order. Each one
can end the preview path early, and every early exit loads your committed
tokens — a preview can never leave the app blank.
| Step | What happens | Where |
|---|---|---|
| 1. Read the id | new URLSearchParams(location.search).get('preview') | core.js:382 |
| 2. Origin guard | preview.enabled must be exactly true, and the origin must be loopback or allowlisted | previewGuard.js:63 |
| 3. Fetch | GET <origin>/preview/<id> with Authorization: Bearer <preview.key> when a key is set | core.js:251, bridgeAuth.js |
| 4. Apply + watch | writes the values, then polls every preview.pollInterval ms (default 1500) or opens one SSE stream | core.js:415-424 |
The origin guard
Preview is off unless you turn it on, and even then the SDK only talks to a
bridge origin it trusts. This is deliberate: a ?preview= appended to a
production link must not be able to point your app at a bridge someone else
runs.
The origin is allowed when it is localhost, 127.0.0.1, or [::1] on any
port, or when it appears verbatim in preview.allowedOrigins. Comparison is
by normalised origin, so a trailing slash or path does not matter, but the
scheme, host, and port must match exactly.
preview: {
enabled: import.meta.env.MODE !== "production",
origin: "http://localhost:7777",
// A non-loopback bridge must be listed here or it is refused:
allowedOrigins: ["https://bridge.sorbcloud.com"],
}
When the guard refuses, the SDK loads committed tokens and — outside a production build — logs one line naming the reason:
[sorb] ignoring ?preview= — preview not permitted (origin-not-allowlisted); loading committed tokens
The three reasons are preview-disabled (preview.enabled is not literally
true), malformed-origin (preview.origin is not a parseable URL), and
origin-not-allowlisted.
Polling or SSE
Polling is the default. The SDK re-fetches GET /preview/<id> every
preview.pollInterval milliseconds — 1500 by default — so an edit in Figma
lands in the app within about a second and a half.
A single Server-Sent Events stream replaces polling when all of the following
hold: the connection was resolved from an org key, that resolution reports
transport: 'sse', it carries an org id, and the browser has EventSource.
The SDK then opens
GET <bridgeUrl>/orgs/<orgId>/preview/<previewId>/subscribe?key=<key> — the key
travels as a query parameter because EventSource cannot set request headers.
Frames carry JSON: snapshot and update both re-apply tokens, delete
reverts to committed tokens, and ping is a keepalive the SDK ignores. Any
frame shape it does not recognise is ignored rather than thrown.
Token values are validated before they are applied
Preview values are authored elsewhere and cross into your document, so the SDK
treats every one as untrusted input. Each value is checked before it reaches
setProperty; a value that fails is skipped and the rest of the set still
applies.
A value is rejected when it contains an ASCII control character, one of ; { }, the sequences @import, javascript:, or </ (matched
after whitespace is stripped, so java script: is caught too), or any CSS
function outside the allowlist. The allowed functions are rgb, rgba, hsl,
hsla, hwb, lab, lch, oklab, oklch, color, calc, min, max,
clamp, var, and env — which means url(...), image-set(...),
element(...), attr(...), and expression(...) never reach your page.
The check is exported as sanitizeCssValue if you want to run it yourself.
The vocabulary guard
The worst preview failure is the silent one: the tokens apply, the banner turns
blue, and nothing on screen moves — because the preview writes --color-* while
your app renders from --bs-*.
Declaring preview.expectPrefixes makes that loud. Set it to the key prefixes
your app actually consumes:
preview: {
enabled: true,
origin: "http://localhost:7777",
expectPrefixes: ["bs-"],
}
The guard is off unless expectPrefixes is a non-empty array. When it is on and
a preview applies at least one token but matches none of the prefixes, the SDK
sets previewMismatch, the banner turns amber, and one warning is logged:
[Sorb] preview "V1StGXR8" applied 42 tokens but none match expected prefixes ["bs-"] — the app may not visibly re-skin (token-vocabulary mismatch).
An empty preview is not a mismatch — zero applied tokens is a different failure, handled by the fallback path below.
When a preview fails
A failed fetch never breaks the page: the SDK loads the committed tokens, then
records why on previewError as { id, outcome }. The outcome is
classified from the HTTP status:
| Outcome | Trigger | What it usually means |
|---|---|---|
not_found | HTTP 404 | The id belongs to a different project, or the preview expired or was deleted. |
unauthorized | HTTP 401 or 403 | The key in preview.key cannot read this project — or it is a write-scoped key used where a read key was expected. |
network | Any other status, or fetch itself rejected | The bridge origin did not answer, or the response was not parseable JSON. |
A cross-project id and a genuinely expired id both read not_found, because the
bridge answers 404 rather than 403 for a preview owned by another tenant — it
never confirms that someone else's preview exists.
One warning is logged on this path in every build, not only development, because it fires only when someone deliberately asked for a preview:
[@sorb/leaf] preview "V1StGXR8" could not be loaded (not_found) — it may not be visible to this app's key; it may belong to a different project. Falling back to committed tokens.
Banner states
<PreviewBanner /> renders nothing until there is either a live preview or a
preview error, so it is safe to mount unconditionally. It has exactly three
states, evaluated in this priority order.
| State | Shows when | Headline | Secondary line | Button |
|---|---|---|---|---|
error (red) | previewError is set | Sorb preview unavailable | Not available to this app — it may belong to another project | Dismiss |
mismatch (amber) | preview active and previewMismatch | Sorb preview active — may not re-skin | No matching tokens for this app — colours may be unchanged | Exit preview |
active (blue) | preview active, no mismatch | Sorb preview active | Token changes from Figma are live | Exit preview |
The red state renders even though isPreview is false — a failed preview
falls back to committed tokens, so without this rule the failure would be
invisible. That is the point of it.
Both the amber and red backgrounds are token-bindable, so you can theme the
banner to your own palette:
--sorb-preview-warning-bg, --sorb-preview-warning-accent,
--sorb-preview-error-bg, --sorb-preview-error-accent. The blue active state
is a fixed #3B5BDB.
Reading the same state yourself
If you want your own indicator rather than the shipped banner, usePreviewState
returns everything the banner uses:
import { usePreviewState } from "@sorb/leaf";
function PreviewChip() {
const { isPreview, previewId, previewMismatch, previewError, clearPreview } =
usePreviewState();
if (!isPreview && !previewError) return null;
return (
<button onClick={clearPreview}>
{previewError ? `preview failed: ${previewError.outcome}` : `preview ${previewId}`}
</button>
);
}
clearPreview() stops the poll loop, strips the preview parameter from the
URL with history.replaceState, and reloads the committed tokens. It does not
delete the preview on the bridge — the plugin owns that.
Dark mode during a preview
Previews carry dark values as well as light ones. The bridge stores and returns
the body it was given, verbatim, in one of two shapes: a flat map of
"token-name": "value" pairs, or a wrapper object with a tokens key alongside
optional darkTokens and darkMode. The SDK detects the wrapper by the
presence of a top-level tokens key. In both shapes the keys are the
custom-property names without the leading -- (the keys of your generated
tokens.js); the SDK prepends -- when it writes them, so a key that already
carries -- lands on <html> as ----name and changes nothing — see the amber
banner entry on Troubleshooting.
A wrapper whose darkTokens is absent or empty is treated as flat. A wrapper
with real darkTokens switches the SDK from writing inline custom properties to
injecting a single mode-aware <style id="sorb-tokens"> sheet, which is also
what a committed darkTokens set in your SorbConfig does.
useTheme exposes the mode controls:
import { useTheme } from "@sorb/leaf";
const { mode, setMode, resolvedScheme } = useTheme();
// mode: 'auto' | 'light' | 'dark' — your manual selection
// resolvedScheme: 'light' | 'dark' — what is actually rendering right now
mode starts at auto, which defers to the OS prefers-color-scheme and
tracks changes to it live. setMode always works, but it only changes anything
on screen once a dark token set exists for the attribute or class to select.
What setMode writes to <html> depends on the dark-mode convention, which
defaults to the react-bootstrap target's data-bs-theme:
| Convention strategy | setMode('dark') | setMode('light') | setMode('auto') |
|---|---|---|---|
attribute (default) | sets data-bs-theme="dark" | sets data-bs-theme="light" | removes the attribute |
class | adds the dark class (e.g. dark) | removes it | removes it |
media | nothing — the OS governs | nothing | nothing |
Without React
sorbInit(config) runs the identical lifecycle with no React in the bundle. It
returns a small store rather than rendering anything:
import { sorbInit } from "@sorb/leaf";
const sorb = sorbInit(config);
const unsubscribe = sorb.subscribe((state) => {
console.log(state.isPreview, state.previewId, state.previewError);
});
// sorb.setMode('dark'); sorb.clearPreview(); sorb.destroy();
getState() returns tokens, isPreview, previewId, previewMismatch,
previewError, mode, and resolvedScheme — the same fields the hooks expose.
destroy() stops the poll loop, closes any SSE stream, and removes the
listeners.
Next
- Troubleshooting — each banner state and warning, with the fix.
- Hosted vs local — which bridge serves the preview, and which key reads it.
- The bridge — the routes behind
POST /preview.
Works with Figma. Not affiliated with, or endorsed by, Figma. Figma is a trademark of Figma, Inc.