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.

StepWhat happensWhere
1. Read the idnew URLSearchParams(location.search).get('preview')core.js:382
2. Origin guardpreview.enabled must be exactly true, and the origin must be loopback or allowlistedpreviewGuard.js:63
3. FetchGET <origin>/preview/<id> with Authorization: Bearer <preview.key> when a key is setcore.js:251, bridgeAuth.js
4. Apply + watchwrites the values, then polls every preview.pollInterval ms (default 1500) or opens one SSE streamcore.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:

OutcomeTriggerWhat it usually means
not_foundHTTP 404The id belongs to a different project, or the preview expired or was deleted.
unauthorizedHTTP 401 or 403The key in preview.key cannot read this project — or it is a write-scoped key used where a read key was expected.
networkAny other status, or fetch itself rejectedThe 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.

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

StateShows whenHeadlineSecondary lineButton
error (red)previewError is setSorb preview unavailableNot available to this app — it may belong to another projectDismiss
mismatch (amber)preview active and previewMismatchSorb preview active — may not re-skinNo matching tokens for this app — colours may be unchangedExit preview
active (blue)preview active, no mismatchSorb preview activeToken changes from Figma are liveExit 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 strategysetMode('dark')setMode('light')setMode('auto')
attribute (default)sets data-bs-theme="dark"sets data-bs-theme="light"removes the attribute
classadds the dark class (e.g. dark)removes itremoves it
medianothing — the OS governsnothingnothing

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

Works with Figma. Not affiliated with, or endorsed by, Figma. Figma is a trademark of Figma, Inc.