Theming
Edmi design tokens. Light is the default, dark is the .dark class on html.
Tokens are CSS variables (OKLCH with a hex fallback) and share names with shadcn. Load them with
@edmi-ui/tokens/tokens.css and map them to Tailwind v4 with @edmi-ui/tokens/theme.css.
npm install @edmi-ui/tokenspnpm add @edmi-ui/tokensyarn add @edmi-ui/tokensbun add @edmi-ui/tokens@import "tailwindcss";@import "@edmi-ui/tokens/tokens.css";@import "@edmi-ui/tokens/theme.css";Type: Instrument Sans for UI text, JetBrains Mono for every number, Sora 600 for the wordmark only.
shadcn
| Token | Light | Dark |
|---|---|---|
--background | #f4f3ef | #121214 |
--foreground | #1f1f1d | #ececee |
--card | #ffffff | #1c1c20 |
--card-foreground | #1f1f1d | #ececee |
--popover | #ffffff | #232328 |
--popover-foreground | #1f1f1d | #ececee |
--primary | #2b2b28 | #e3e3e6 |
--primary-foreground | #ffffff | #121214 |
--secondary | #f1f0ec | #2a2a30 |
--secondary-foreground | #1f1f1d | #ececee |
--muted | #f7f6f2 | #17171b |
--muted-foreground | #7a7974 | #9b9ba3 |
--accent | #ecebe5 | #303036 |
--accent-foreground | #1f1f1d | #ececee |
--destructive | #d9674f | #ef5b70 |
--border | #e3e1da | #303036 |
--input | #d2d0c7 | #48484f |
--ring | #5bb26a | #3ecf8e |
--chart-1 | #5bb26a | #3eab5e |
--chart-2 | #3b6fd6 | #5a8ff3 |
--chart-3 | #e39a3c | #c0851f |
--chart-4 | #8a7fd6 | #957ee5 |
--chart-5 | #d9674f | #e75e6a |
--sidebar | #f7f6f2 | #16161a |
--sidebar-foreground | #1f1f1d | #ececee |
--sidebar-primary | #2b2b28 | #e3e3e6 |
--sidebar-primary-foreground | #ffffff | #121214 |
--sidebar-accent | #ecebe5 | #2a2a30 |
--sidebar-accent-foreground | #1f1f1d | #ececee |
--sidebar-border | #e3e1da | #2a2a2e |
--sidebar-ring | #5bb26a | #3ecf8e |
edmi
| Token | Light | Dark |
|---|---|---|
--destructive-foreground | #ffffff | #ffffff |
--brand | #5bb26a | #3ecf8e |
--brand-foreground | #ffffff | #0b1a12 |
--success | #2f8a43 | #3ecf8e |
--warning | #e39a3c | #e8a64a |
--info | #3b6fd6 | #5b8cff |
--primary-hi | #4a4a46 | #ffffff |
--primary-edge | #141412 | transparent |
--primary-shade | rgba(0,0,0,0.22) | rgba(0,0,0,0) |
--primary-lip | #0d0d0c | #7d7d84 |
--primary-inset | rgba(255,255,255,0.24) | rgba(255,255,255,0.7) |
--secondary-hi | #ffffff | #36363d |
--secondary-inset | #ffffff | rgba(255,255,255,0.1) |
--secondary-inset-b | rgba(0,0,0,0.045) | rgba(0,0,0,0.3) |
--secondary-lip | #cfcdc3 | #4a4a52 |
--destructive-hi | #e48670 | #f47a8b |
--destructive-edge | #b24a36 | #c9455a |
--destructive-lip | #8f3a2a | #7a2433 |
--destructive-soft | #fbe6e1 | #3c272e |
--destructive-text | #b24a36 | #f48797 |
--brand-hi | #73c482 | #5fdba3 |
--brand-edge | #3f8f4e | #2aa970 |
--brand-lip | #2f7a3c | #1c6e4a |
--brand-soft | #e5f4e8 | #233832 |
--brand-text | #2f7d3f | #5fdba3 |
--success-soft | #e5f4e8 | #233832 |
--success-text | #2f7d3f | #5fdba3 |
--warning-soft | #fbebd6 | #3b3228 |
--warning-text | #a8651a | #f0bb6b |
--info-soft | #e8eefc | #282f44 |
--info-text | #3b6fd6 | #8eb0ff |
--ring-soft | rgba(91,178,106,0.28) | rgba(62,207,142,0.28) |
--overlay | rgba(31,31,29,0.32) | rgba(0,0,0,0.6) |
--lip | #e4e2da | #36363c |
--lip-strong | #d6d4cb | #3f3f45 |
--outline-hi | #ffffff | #27272c |
--outline-face | #ffffff | #1c1c20 |
--outline-lip | #e4e2da | #44444b |
--card-hi | #ffffff | rgba(255,255,255,0.035) |
--grid-dot | #e3e1da | #222226 |
--stage | #efeee9 | #151518 |
--foreground-2 | #4d4c48 | #b6b6bc |
--tab-active | #ffffff | #303036 |
--muted-foreground-2 | #a09f99 | #6b6b73 |
--border-2 | #ecebe5 | #26262b |
--r1-p | inset 0 0 0 transparent | inset 0 1px 0 #fff,inset 0 -1px 0 rgba(0,0,0,.10) |
--r1-p-face | linear-gradient(180deg,var(--primary-hi),var(--primary)) | linear-gradient(180deg,color-mix(in srgb,var(--primary) 98%,#000),color-mix(in srgb,var(--primary) 93%,#000)) |
--r1-s | inset 0 1px 0 #fff,inset 0 -1px 0 rgba(0,0,0,.06) | inset 0 0 0 transparent |
--r1-s-face | linear-gradient(180deg,color-mix(in srgb,var(--foreground) 3%,var(--card)),color-mix(in srgb,var(--foreground) 6%,var(--card))) | linear-gradient(180deg,var(--bv-face-a),var(--bv-face-b)) |
--fl-p | inset 0 2px 0 rgba(255,255,255,.22),inset 0 -2px 0 rgba(0,0,0,.18) | inset 0 2px 0 #fff,inset 0 -2px 0 rgba(0,0,0,.12) |
--fl-s | inset 0 2px 0 #fff,inset 0 -2px 0 rgba(0,0,0,.07) | inset 0 2px 0 rgba(255,255,255,.08),inset 0 -2px 0 rgba(0,0,0,.25) |
--fl-s-face | linear-gradient(180deg,color-mix(in srgb,var(--foreground) 6%,var(--card)),color-mix(in srgb,var(--foreground) 10%,var(--card))) | linear-gradient(180deg,var(--bv-face-a),var(--bv-face-b)) |
--fl-p-face | linear-gradient(180deg,var(--primary-hi),var(--primary)) | linear-gradient(180deg,color-mix(in srgb,var(--primary) 96%,#000),color-mix(in srgb,var(--primary) 90%,#000)) |
--bv-ring | rgba(255,255,255,0.35) | rgba(255,255,255,0.06) |
--bv-top | #ffffff | rgba(255,255,255,0.08) |
--bv-out | rgba(0,0,0,0.25) | rgba(0,0,0,0.55) |
--bv-face-a | color-mix(in srgb,var(--foreground) 6%,var(--card)) | color-mix(in srgb,#fff 7%,var(--card)) |
--bv-face-b | color-mix(in srgb,var(--foreground) 2%,var(--card)) | color-mix(in srgb,#fff 3.5%,var(--card)) |
--bv-float | 0 10px 15px -3px rgba(0,0,0,0.10),0 4px 6px -4px rgba(0,0,0,0.10) | 0 8px 24px rgba(0,0,0,0.5) |
--sk-bg | color-mix(in srgb,#000 3.5%,var(--card)) | color-mix(in srgb,#000 14%,var(--card)) |
--sk-bd | color-mix(in srgb,#000 6%,var(--card)) | color-mix(in srgb,#fff 4%,var(--card)) |
--sk-sh | inset 0 2px 4px rgba(0,0,0,0.07),inset 0 -1px 0 rgba(255,255,255,0.8),inset 0 0 0 1px rgba(0,0,0,0.03) | inset 0 2px 5px rgba(0,0,0,0.45),inset 0 -1px 0 rgba(255,255,255,0.04),inset 0 0 0 1px rgba(0,0,0,0.22) |
radius
| Token | Value |
|---|---|
--radius-base | 0.625rem |
--radius-sm | calc(var(--radius) * 0.6) |
--radius-md | calc(var(--radius) * 0.8) |
--radius-lg | var(--radius) |
--radius-xl | calc(var(--radius) * 1.4) |
--radius-2xl | calc(var(--radius) * 1.8) |
--radius-3xl | calc(var(--radius) * 2.2) |
--radius-4xl | calc(var(--radius) * 2.6) |
Base, theme, radius and mode
Section titled “Base, theme, radius and mode”Four independent knobs, all set on <html> and all plain CSS variables. Try them on the Themes page.
| Knob | How | Values | Changes |
|---|---|---|---|
| Mode | class="dark" |
light (default), dark | every token |
| Base color | data-base="…" |
stone (default, in tokens.css), slate |
neutrals: surfaces, text, borders, the bevel colours, the neutral “ink” primary |
| Theme | data-theme="…" |
green (default, in tokens.css), ocean |
accent: brand, ring, charts, sidebar-primary; Ocean also recolors primary |
| Radius | style="--radius: …" |
0.625rem default (0.3rem sharp to 1rem round) |
every radius |
<html class="dark" data-base="slate" data-theme="ocean" style="--radius: 0.625rem">Status colors (destructive, warning, success, info) never change with the base or theme. Positive values use
success, so they stay green in Ocean. Stone · Green is the default and needs no attributes. Slate and Ocean
are examples of how to customize: every base and theme is one CSS file, and components never reference a name.
Registry themes
Section titled “Registry themes”@edmi-ui/theme installs the default (Stone · Green) tokens. For every base and theme combination there is also a
registry:theme item with the complete light and dark color set. Installing one replaces the color variables in
:root and .dark (your radius is left alone), like shadcn’s themes:
| Item | Base | Theme |
|---|---|---|
theme-stone-green |
Stone | Green (same as the default) |
theme-stone-ocean |
Stone | Ocean |
theme-slate-green |
Slate | Green |
theme-slate-ocean |
Slate | Ocean |
React
npx shadcn@latest add @edmi-ui/theme-slate-oceanpnpm dlx shadcn@latest add @edmi-ui/theme-slate-oceanyarn dlx shadcn@latest add @edmi-ui/theme-slate-oceanbunx shadcn@latest add @edmi-ui/theme-slate-oceanVue
npx shadcn-vue@latest add @edmi-ui/theme-slate-oceanpnpm dlx shadcn-vue@latest add @edmi-ui/theme-slate-oceanyarn dlx shadcn-vue@latest add @edmi-ui/theme-slate-oceanbunx shadcn-vue@latest add @edmi-ui/theme-slate-oceanSvelte (URL only)
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/theme-slate-ocean.jsonpnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/theme-slate-ocean.jsonyarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/theme-slate-ocean.jsonbunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/theme-slate-ocean.jsonRun it after @edmi-ui/theme (which also carries the Tailwind theme map and fonts). The Themes page generates the
same variables for any selection with Copy CSS if you would rather paste them. New items appear automatically
when you add a base or theme file (see below).
Runtime theming
Section titled “Runtime theming”Use the CSS route instead when your app lets end users switch base, theme, radius or mode at runtime.
1. Load the files in this order. Themes must win over bases for primary.
@import "tailwindcss";@import "@edmi-ui/tokens/tokens.css"; /* Stone · Green + dark */@import "@edmi-ui/tokens/base/slate.css"; /* [data-base="slate"], .dark[data-base="slate"] */@import "@edmi-ui/tokens/themes/ocean.css"; /* [data-theme="ocean"], .dark[data-theme="ocean"] */@import "@edmi-ui/tokens/theme.css";The defaults are also available as explicit rules, only needed to switch a nested scope back (see Scoped theming):
@edmi-ui/tokens/base/stone.css ([data-base="stone"]) and @edmi-ui/tokens/themes/green.css ([data-theme="green"]).
Load them in the same slots: the base before the themes.
Every base and theme ships light and dark blocks. Keys a file does not set fall back to the default.
2. Switch with attributes on <html>. Persist the choice in cookies and render the attributes on the server
so the first paint is already right (no flash).
// anywhere on the clientfunction setTheme(opts: { mode?: "light" | "dark"; base?: string; theme?: string; radius?: string }) { const root = document.documentElement; const set = (k: string, v: string) => { document.cookie = `${k}=${v}; path=/; max-age=31536000; samesite=lax`; }; if (opts.mode) { root.classList.toggle("dark", opts.mode === "dark"); set("edmi-mode", opts.mode); } if (opts.base) (root.dataset.base = opts.base), set("edmi-base", opts.base); if (opts.theme) (root.dataset.theme = opts.theme), set("edmi-accent", opts.theme); if (opts.radius) { root.style.setProperty("--radius", `${opts.radius}rem`); set("edmi-radius", opts.radius); }}React or Vue with Vite (client only). An inline script in index.html applies the cookies before first paint:
<script> (function () { var c = {}; document.cookie.split("; ").forEach(function (p) { var i = p.indexOf("="); c[p.slice(0, i)] = p.slice(i + 1); }); var r = document.documentElement; if (c["edmi-mode"] === "dark") r.classList.add("dark"); if (c["edmi-base"]) r.dataset.base = c["edmi-base"]; if (c["edmi-accent"]) r.dataset.theme = c["edmi-accent"]; if (c["edmi-radius"]) r.style.setProperty("--radius", c["edmi-radius"] + "rem"); })();</script>SvelteKit (server-rendered). Read the cookies in hooks.server.ts and fill placeholders in app.html:
import type { Handle } from "@sveltejs/kit";
export const handle: Handle = ({ event, resolve }) => { const get = (k: string) => event.cookies.get(k) ?? ""; return resolve(event, { transformPageChunk: ({ html }) => html .replace("%edmi.mode%", get("edmi-mode") === "dark" ? "dark" : "") .replace("%edmi.base%", get("edmi-base") || "stone") .replace("%edmi.accent%", get("edmi-accent") || "green") .replace("%edmi.radius%", `${get("edmi-radius") || "0.625"}rem`), });};<html lang="en" class="%edmi.mode%" data-base="%edmi.base%" data-theme="%edmi.accent%" style="--radius: %edmi.radius%">Vue with Nuxt (or any SSR). Same idea: read the cookies on the server (useCookie("edmi-mode"), …) and render them
with useHead({ htmlAttrs: { class, "data-base", "data-theme", style } }) so the HTML arrives themed. In a plain Vite
app use the inline script above.
Scoped theming. Any subtree can carry its own attributes and class="dark", for example an always-dark sidebar
inside a light app. Put class="dark" data-base="slate" on that element; its tokens (and dark: styles) resolve inside it only.
To switch a scope back to the defaults inside a Slate · Ocean app, set data-base="stone" data-theme="green" on it (with
base/stone.css and themes/green.css loaded). The Stone file also resets primary; the Green file leaves primary to the base.
Create your own base or theme
Section titled “Create your own base or theme”Copy a file, change the values, keep the selectors:
Run bun scripts/gen-default-scopes.ts after changing tokens.css or adding a base or theme: it regenerates
base/stone.css and themes/green.css (a test fails when they are stale).
- New base: copy
packages/tokens/src/base/slate.csstobase/<name>.cssand change the neutrals ([data-base="<name>"]and.dark[data-base="<name>"]). - New theme: copy
themes/ocean.csstothemes/<name>.cssand change the accent keys ([data-theme="<name>"]and.dark[data-theme="<name>"]). - Import it after
tokens.cssand set the attribute on<html>.
In this repository, dropping the file in those folders is all it takes: the registry generates the matching
theme-<base>-<theme> items and the Themes page offers the new option.
Elevation tokens
Section titled “Elevation tokens”Depth is the elevation system. Components are flat unless they take an
elevation level (sunken, flat, raised, floating) from the prop or from an ElevationProvider. The visuals
come from tokens that exist for light and dark and for every base color, so a theme never has to touch them.
| Token | Role |
|---|---|
--bv-ring |
inner rim of the bevel |
--bv-top |
1px top highlight of the bevel |
--bv-out |
dark hairline around a raised or floating surface |
--bv-face-a, --bv-face-b |
neutral face gradient (top to bottom) |
--bv-float |
the one soft drop of a floating layer |
--sk-bg, --sk-bd, --sk-sh |
sunken well: fill, border, soft inset |
--r1-p, --r1-p-face, --r1-s, --r1-s-face |
raised (+1) button edge and face, primary and neutral |
--fl-p, --fl-p-face, --fl-s, --fl-s-face |
floating (+2) button edge and face, primary and neutral |
@edmi-ui/tokens/theme.css maps them to Tailwind utilities: shadow-raised, shadow-floating, shadow-sunken,
shadow-btn-raised-primary|neutral|color, shadow-btn-float-primary|neutral|color, shadow-btn-sunken-filled,
shadow-pressed, shadow-pressed-float, shadow-thumb, shadow-group-float, plus bg-sk-bg and border-sk-bd.
Components use these; do not hard-code shadows. Gradient faces on a bordered control use a transparent border and
[background-origin:border-box].
The old lip tokens (--lip, --lip-strong, --*-lip, --*-edge, --*-shade, --card-hi, --outline-*) are kept
only for compatibility. Components no longer use them.
Dark ladder
Section titled “Dark ladder”The stone dark palette uses a clearer ladder so surfaces separate without black blocks: background #121214, card
#1c1c20, popover #232328, secondary #2a2a30, border and accent #303036, input #48484f, muted text #9b9ba3.
Slate keeps its own dark values. In dark mode depth comes from the bevel hairline and a faint top highlight, and the
sunken well is a soft inset darker than the card.
Layered mode in the docs
Section titled “Layered mode in the docs”The landing page and the Themes page preview switch between Flat and Layered: the showcase is wrapped in an
ElevationProvider and the toggle flips its mode. Your app does the same with one wrapper.