Preview a token change in your running React app
When you finish this page you have a React app that re-skins live when the Sorb™ plugin (Canopy) proposes a token change, with no rebuild. Budget about fifteen minutes.
Before you start
- A React app that runs with Vite or a similar dev server. The commands below assume Vite on port 5173 and an
src/App.jsxyou can edit. - React 18 or 19 (
@sorb/leaf0.5.1 or newer). If you are pinned to@sorb/leaf0.5.0 on a React 19 app,npm installstops withERESOLVE unable to resolve dependency treebecause that version declaredreact@^18— upgrade to 0.5.1 rather than passing--legacy-peer-deps. - Node 20 or newer and npm.
- Figma desktop with the Sorb plugin (Canopy) imported — see Install (beta); it is not on the Figma Community yet.
- For the hosted connect path, a Sorb beta invite — access is by request, request access and Nathan emails you an invite. The local bridge on this page needs no account.
- Your
package.jsonsets"type": "module". Vite projects do. If yours does not, name the Style Dictionary configsd.config.mjsand use that name wherever this page sayssd.config.js.
Every code block on this page is copied from a working app and is executed by the
docs build (scripts/snippets-check.mjs). If a step fails for you, the error text you see is listed under that step.
1. Install the packages
@sorb/leaf ships in your app bundle. The bridge (@sorb/juice), the Style Dictionary formats (@sorb/seed) and Style Dictionary itself are dev dependencies.
npm install @sorb/leaf
npm install -D @sorb/juice @sorb/seed style-dictionary
2. Create the bridge config
npx sorb init
This writes sorb.config.json in the current directory:
{
"namespace": "my-app",
"tokenSources": [
"tokens/primitive.json",
"tokens/semantic.json",
"tokens/component.json"
],
"styleDictionaryConfig": "sd.config.js",
"port": 7777,
"appUrl": "",
"gh": ""
}
Set appUrl to the page you want previews to open, which for Vite is http://localhost:5173. Leave gh empty for now; it is the GitHub edit URL of your tokens file and only matters when you open a PR from the plugin.
If the file already exists the command prints sorb.config.json already exists and changes nothing.
If you run npx sorb init before step 1 has finished, npm answers could not determine executable to run. Install first, or use the full name: npx @sorb/juice init.
3. Write three token files
Sorb reads DTCG JSON in three tiers. The file name decides the tier: primitive.json, semantic.json, component.json. A {ref} value points at another token. Start with this minimal set and grow it later.
{
"$version": "1.0.0",
"color": {
"white": { "$value": "#FFFFFF", "$type": "color" },
"blue": {
"500": { "$value": "#0F65EF", "$type": "color" },
"700": { "$value": "#083884", "$type": "color" }
}
},
"radius": {
"4": { "$value": "4px", "$type": "dimension" }
}
}
{
"$version": "1.0.0",
"color": {
"action": {
"primary": { "$value": "{color.blue.500}", "$type": "color" },
"primary-hover": { "$value": "{color.blue.700}", "$type": "color" }
},
"text": {
"on-action": { "$value": "{color.white}", "$type": "color" }
}
},
"radius": {
"control": { "$value": "{radius.4}", "$type": "dimension" }
}
}
{
"$version": "1.0.0",
"button": {
"radius": { "$value": "{radius.control}", "$type": "dimension" },
"primary": {
"bg": { "default": { "$value": "{color.action.primary}", "$type": "color" },
"hover": { "$value": "{color.action.primary-hover}", "$type": "color" } },
"text": { "default": { "$value": "{color.text.on-action}", "$type": "color" } }
}
}
}
4. Configure Style Dictionary
One build produces the three files Sorb needs: the CSS custom properties your app reads, the committed token set the provider bundles, and the resolved map the bridge serves.
import StyleDictionary from "style-dictionary";
import {
SORB_RESOLVED,
SORB_SET_META,
SORB_TOKENSET,
sorbResolved,
sorbSetMeta,
sorbTokenSet,
} from "@sorb/seed";
// Register Sorb's parser (lifts per-file $version) and its two output formats.
StyleDictionary.registerParser(sorbSetMeta);
StyleDictionary.registerFormat({ name: SORB_RESOLVED, format: sorbResolved });
StyleDictionary.registerFormat({ name: SORB_TOKENSET, format: sorbTokenSet });
export default {
source: ["tokens/primitive.json", "tokens/semantic.json", "tokens/component.json"],
parsers: [SORB_SET_META],
platforms: {
// The CSS custom properties your components read: --button-primary-bg-default, …
css: {
transformGroup: "css",
buildPath: "src/tokens/generated/",
files: [
{
destination: "variables.css",
format: "css/variables",
options: { outputReferences: true },
},
],
},
// The committed token set SorbProvider bundles (a flat name → value object).
js: {
transformGroup: "css",
buildPath: "src/tokens/generated/",
files: [{ destination: "tokens.js", format: SORB_TOKENSET }],
},
// The resolved map `sorb dev` serves to the plugin.
sorb: {
transformGroup: "css",
buildPath: ".sorb/",
files: [{ destination: "resolved.json", format: SORB_RESOLVED }],
},
},
};
Add two scripts to package.json:
{
"scripts": {
"tokens": "style-dictionary build --config ./sd.config.js",
"sorb": "sorb dev"
}
}
Build the tokens once:
npm run tokens
You now have src/tokens/generated/variables.css, src/tokens/generated/tokens.js, and .sorb/resolved.json. Add .sorb/ to .gitignore; the generated src/tokens/generated/ files are meant to be committed because your app imports them.
If the build fails with Cannot find package '@sorb/seed', the install in step 1 did not complete. If it fails with Cannot use import statement outside a module, see the "type": "module" note at the top of this page.
5. Wrap your app
Import the generated CSS so your components see the variables, and pass a config to SorbProvider. The config prop is required; without it the provider has no committed tokens and no bridge to poll.
import React from "react";
import { createRoot } from "react-dom/client";
import { SorbProvider, PreviewBanner } from "@sorb/leaf";
import { tokens } from "./tokens/generated/tokens";
import App from "./App";
// The CSS custom properties your components read. A preview swaps them at runtime.
import "./tokens/generated/variables.css";
const sorbConfig = {
namespace: "my-app",
tokens,
preview: {
// Off in production builds unless you opt in.
enabled: import.meta.env.MODE !== "production",
// Where `sorb dev` is listening.
origin: "http://localhost:7777",
// Token-name prefixes your app actually reads. A preview that matches none
// of them shows a warning in the banner instead of silently doing nothing.
expectPrefixes: ["button-", "color-", "radius-"],
},
};
createRoot(document.getElementById("root")).render(
<React.StrictMode>
<SorbProvider config={sorbConfig}>
<App />
{/* Bottom banner while a preview is active. Renders nothing otherwise. */}
<PreviewBanner />
</SorbProvider>
</React.StrictMode>,
);
Reference the variables in your CSS with a fallback, so the app renders the same with or without Sorb. Give yourself one element that reads them, so you can see a preview land — replace the scaffolded src/App.jsx with:
import "./App.css";
export default function App() {
return (
<main>
<h1>my-app</h1>
<button className="button-primary">Primary action</button>
</main>
);
}
.button-primary {
background: var(--button-primary-bg-default, #0f65ef);
color: var(--button-primary-text-default, #fff);
border-radius: var(--button-radius, 4px);
border: 0;
padding: 0.5rem 1rem;
}
The fallbacks are the committed values, so the button looks identical before Sorb is wired and while no preview is active.
6. Start the bridge and your app
In one terminal:
npm run sorb
The bridge prints its namespace, token sources and port, runs Style Dictionary, then listens on http://127.0.0.1:7777. It rebuilds the tokens every time a file under tokens/ changes. Check it from another terminal:
curl -s http://127.0.0.1:7777/health
In a second terminal start your app as usual:
npx vite
If npm run sorb prints style-dictionary config not found at sd.config.js, skipping, the bridge is running from a different directory than sd.config.js. Run it from the project root.
7. Connect the plugin to your local bridge
Open the Sorb plugin in Figma. The front door promotes Sign in to connect, which pairs the plugin with a hosted Sorb Cloud project. For the local bridge you started in step 6, use the advanced path:
- Expand Advanced setup at the bottom of the plugin's first screen.
- The plugin probes
http://127.0.0.1:7777and shows Found a Sorb bridge running on this computer. Click Connect. - If the banner does not appear, choose Manual bridge setup and enter Bridge origin
http://localhost:7777and App URLhttp://localhost:5173. Both fields have a Test button.
When the plugin is connected, its Tokens tab lists the tokens from .sorb/resolved.json.
To use the hosted path instead, sign in and follow Hosted vs local for the two config changes your app needs (preview.origin and a sorb_pk_ key). For every other way to connect — org key, handshake invite, or a second look at the sign-in front door — see The Sorb plugin (Canopy) and Connect flows.
8. Propose, preview, verify
- In the plugin's Tokens tab, change the value of
button.primary.bg.default. - Click Preview in app. The plugin creates a preview on the bridge and opens
http://localhost:5173/?preview=<id>. - Your button re-skins and the preview banner appears at the bottom of the page. Edit the value again; the page updates without a reload.
- When the change is right, use Save to project or Open PR to carry it into your token files.
Without the plugin
The plugin only does what any HTTP client can do. To see a preview land before you open Figma, post a proposed value to the bridge yourself. Keys are the custom-property names without the leading -- — the same keys as src/tokens/generated/tokens.js; the SDK adds the -- when it writes them to <html>.
curl -s -X POST http://127.0.0.1:7777/preview \
-H "content-type: application/json" \
-d '{"button-primary-bg-default": "#e91e63"}'
The response is {"id":"<id>","url":"?preview=<id>"}. Open http://localhost:5173/?preview=<id>: the button turns pink and the banner appears. A key that does start with -- is stored verbatim and applied as ----…, so nothing on the page changes — the amber "vocabulary mismatch" banner is the tell.
If the banner appears but nothing changes on screen, the preview's token names do not match the prefixes in expectPrefixes. The banner shows a vocabulary-mismatch warning. See Troubleshooting.
Next steps
- How previews work: the lifecycle from
POST /previewto the banner states. - Hosted vs local: what runs where, and which key does what.
@sorb/leafreference: every export, including hooks and dark mode.@sorb/juicereference: everysorbcommand and bridge route.- Try it without installing: the hosted demos.
Works with Figma. Not affiliated with, or endorsed by, Figma. Figma is a trademark of Figma, Inc.