Skip to content

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:

  1. 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.
  2. 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).

TokenRole
--uncorded-bgBase background — the deepest surface.
--uncorded-pagePanel/page surface, slightly raised from bg.
--uncorded-textPrimary text (ink).
--uncorded-mutedSecondary / lower-emphasis text.
--uncorded-dimDimmest text, or a scrim/disabled fill.
--uncorded-borderHairline borders and dividers.
--uncorded-primary-colorAccent / brand color (the user's chosen accent).
--uncorded-ctaCall-to-action button background.
--uncorded-cta-fgForeground/text on a cta surface.
--uncorded-dangerDestructive / error fill (delete buttons, error states).
--uncorded-danger-fgLegible foreground on a danger fill.
--uncorded-successSuccess / positive fill.
--uncorded-success-fgLegible foreground on a success fill.
--uncorded-warningWarning / caution fill.
--uncorded-warning-fgLegible foreground on a warning fill.
--uncorded-fontThe shell's UI font stack.
--uncorded-radiusStandard corner radius.
--uncorded-springStandard 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.

css
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-spring is the curve only, not a duration. Pair it with your own time: transition: transform 250ms var(--uncorded-spring, ease);
  • --uncorded-radius is 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 hardcoded px.

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.

css
: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:

css
/* 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:

css
/* 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:

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