Skip to content
edmi-ui

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.

Terminal window
npm install @edmi-ui/tokens
Terminal window
pnpm add @edmi-ui/tokens
Terminal window
yarn add @edmi-ui/tokens
Terminal window
bun 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

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

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

TokenValue
--radius-base0.625rem
--radius-smcalc(var(--radius) * 0.6)
--radius-mdcalc(var(--radius) * 0.8)
--radius-lgvar(--radius)
--radius-xlcalc(var(--radius) * 1.4)
--radius-2xlcalc(var(--radius) * 1.8)
--radius-3xlcalc(var(--radius) * 2.2)
--radius-4xlcalc(var(--radius) * 2.6)

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.

@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

Terminal window
npx shadcn@latest add @edmi-ui/theme-slate-ocean
Terminal window
pnpm dlx shadcn@latest add @edmi-ui/theme-slate-ocean
Terminal window
yarn dlx shadcn@latest add @edmi-ui/theme-slate-ocean
Terminal window
bunx shadcn@latest add @edmi-ui/theme-slate-ocean

Vue

Terminal window
npx shadcn-vue@latest add @edmi-ui/theme-slate-ocean
Terminal window
pnpm dlx shadcn-vue@latest add @edmi-ui/theme-slate-ocean
Terminal window
yarn dlx shadcn-vue@latest add @edmi-ui/theme-slate-ocean
Terminal window
bunx shadcn-vue@latest add @edmi-ui/theme-slate-ocean

Svelte (URL only)

Terminal window
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/theme-slate-ocean.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/theme-slate-ocean.json
Terminal window
yarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/theme-slate-ocean.json
Terminal window
bunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/theme-slate-ocean.json

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

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 client
function 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:

src/hooks.server.ts
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`),
});
};
src/app.html
<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.

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

  1. New base: copy packages/tokens/src/base/slate.css to base/<name>.css and change the neutrals ([data-base="<name>"] and .dark[data-base="<name>"]).
  2. New theme: copy themes/ocean.css to themes/<name>.css and change the accent keys ([data-theme="<name>"] and .dark[data-theme="<name>"]).
  3. Import it after tokens.css and 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.

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.

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.

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.