Theming
Plugin panels render in a sandboxed iframe, but they don't have to look foreign. The shell hands every panel its resolved theme tokens, so your UI can track the user's chosen theme — light or dark, accent color and all — with no JavaScript. You opt in simply by writing your CSS against var(--uncorded-*).
This is the single most impactful thing you can do to make a plugin feel built-in, and it's almost free.
How it works
The shell can't reach into a sandboxed (opaque-origin) iframe with a stylesheet, and its own --uncorded-* variables are nested color-mix() chains that wouldn't resolve in your document anyway. So instead the shell computes the final values and pushes them in over the SDK channel:
- On handshake,
createPluginFrontend()receives the current theme and writes each token onto your<html>element. This happens before the call resolves, so your first paint is already themed. - When the user toggles light/dark or changes the accent, the shell pushes an updated set and the SDK re-applies it live. Your panel re-skins instantly — you write no listener.
Alongside the variables, the SDK sets two attributes on <html>:
data-uncorded-theme="light"or"dark"— branch on it in CSS.color-scheme: light | dark— so native form controls and scrollbars match.
The mechanism is in
@uncorded/plugin-sdk-frontend(applyPluginTheme). It's wired automatically; you never call it. It's exported only for advanced manual use (e.g. re-applying after swapping out your<html>).
The token contract
These are the only variables the shell guarantees to push. Author against this set; don't depend on any other --uncorded-* name (the shell has hundreds internally, but only these cross the boundary).
| Token | Role |
|---|---|
--uncorded-bg | Base background — the deepest surface. |
--uncorded-page | Panel/page surface, slightly raised from bg. |
--uncorded-text | Primary text (ink). |
--uncorded-muted | Secondary / lower-emphasis text. |
--uncorded-dim | Dimmest text, or a scrim/disabled fill. |
--uncorded-border | Hairline borders and dividers. |
--uncorded-primary-color | Accent / brand color (the user's chosen accent). |
--uncorded-cta | Call-to-action button background. |
--uncorded-cta-fg | Foreground/text on a cta surface. |
--uncorded-danger | Destructive / error fill (delete buttons, error states). |
--uncorded-danger-fg | Legible foreground on a danger fill. |
--uncorded-success | Success / positive fill. |
--uncorded-success-fg | Legible foreground on a success fill. |
--uncorded-warning | Warning / caution fill. |
--uncorded-warning-fg | Legible foreground on a warning fill. |
--uncorded-font | The shell's UI font stack. |
--uncorded-radius | Standard corner radius. |
--uncorded-spring | Standard easing curve for transitions (a linear() spring). |
The semantic tokens come in fill + -fg pairs (like cta/cta-fg): set the background to the fill and the text to the -fg so it stays legible in both modes. Their meaning is stable across light/dark — danger is always "this is destructive" — while the actual color shifts to suit the background (a brighter red on dark, a deeper red on light). Use them instead of hand-picking status colors that would clash with the shell.
button.delete { background: var(--uncorded-danger); color: var(--uncorded-danger-fg); }
.toast.ok { background: var(--uncorded-success); color: var(--uncorded-success-fg); }
body { font-family: var(--uncorded-font, system-ui, sans-serif); }A few rules that save surprises:
- Color tokens are resolved color strings (e.g.
oklch(...)/#rrggbb), not functions — safe to drop straight into any property. --uncorded-springis the curve only, not a duration. Pair it with your own time:transition: transform 250ms var(--uncorded-spring, ease);--uncorded-radiusis geometry, not color. It doesn't change with light/dark — but it does follow the user's density/roundness preference, so prefer it over a hardcodedpx.
The pattern: map once, fall back, derive
Map the shell tokens to your own local names once, with a fallback value as the second var() argument. The fallback is what renders when there's no shell — e.g. when you open index.html directly while developing — so your plugin still looks right standalone.
:root {
--bg: var(--uncorded-bg, #15181d);
--page: var(--uncorded-page, #1c2026);
--text: var(--uncorded-text, #e8eaed);
--muted: var(--uncorded-muted, #9aa3af);
--border: var(--uncorded-border, #2a2f37);
--accent: var(--uncorded-primary-color, #6aa3ff);
--cta: var(--uncorded-cta, #6aa3ff);
--cta-fg: var(--uncorded-cta-fg, #0b1020);
--radius: var(--uncorded-radius, 12px);
}
body { background: var(--bg); color: var(--text); }
.panel { background: var(--page); border: 1px solid var(--border); border-radius: var(--radius); }
.hint { color: var(--muted); }
button.primary { background: var(--cta); color: var(--cta-fg); border: 0; border-radius: var(--radius); }Derive, don't hardcode, in-between shades. A raised card or a tinted row should be computed from the mapped tokens so it lifts correctly in both light and dark. color-mix() toward the ink color is the reliable move:
/* A surface one step above --page, correct in light AND dark. */
--raised: color-mix(in oklab, var(--text) 6%, var(--page));
/* A subtle accent-tinted row. */
.row.active { background: color-mix(in oklab, var(--accent) 14%, var(--page)); }If you instead pick a fixed dark pastel, it'll look fine in dark mode and wrong in light. Mixing toward --text lets the same rule track both.
Branching on light vs dark
Most UIs need no branching — derived shades handle both. When you genuinely need a mode-specific tweak, target the attribute:
/* Slightly stronger borders in light mode. */
html[data-uncorded-theme="light"] .panel { border-color: color-mix(in oklab, var(--text) 18%, transparent); }Avoid @media (prefers-color-scheme) for this — it reflects the OS setting, not the user's in-app UnCorded theme. data-uncorded-theme is the source of truth.
What to keep fixed
Not everything should follow the accent, but status and brand still have rules:
- Status (danger / success / warning): use the semantic tokens above. They already hold their meaning stable across light/dark — don't hand-roll your own reds and greens, which will clash with the shell.
- Brand artwork / logos: keep fixed; these are yours, not the theme's.
A good rule of thumb: surfaces, accent, status, and font follow the shell; geometry and your own brand stay yours.
User avatars
For per-user color (message authors, member lists), don't invent your own hashing — use the SDK's avatar helpers so your colors match the rest of the platform for the same user:
const { avatarHtml, avatarColor } = window.UncodedPlugin;
el.innerHTML = avatarHtml({ userId, displayName, avatarUrl, size: 24 });See Frontend SDK → avatars for the full helper set.
See it in context
The Noteboard example is fully theme-aware end-to-end, and the shipped text-channels plugin uses exactly this map-fallback-derive pattern in production.