Skip to content

Theme Guide

The MIDDAG design system is built on CSS custom properties (design tokens) using the OKLCH color space, which provides perceptually uniform color manipulation — equal numeric steps produce equal visual contrast, regardless of hue.

Token Reference

All tokens are defined in :root and can be overridden at any scope. Dark mode values are applied via :root.dark or [data-theme="dark"].

Canvas

TokenLight DefaultDark DefaultPurpose
--backgroundoklch(0.99 0.001 286)oklch(0.13 0.01 285.82)Page background
--foregroundoklch(0.17 0.01 286)oklch(0.93 0.005 285.82)Default text color

Primary (Brand)

TokenLight DefaultDark DefaultPurpose
--primaryoklch(0.21 0.034 264.06)oklch(0.6 0.06 264.06)Brand color for buttons, links, active states
--primary-foregroundoklch(0.98 0 0)oklch(0.1 0.01 264.06)Text on primary surfaces
--primary-subtleoklch(0.95 0.012 264.06)oklch(0.22 0.03 264.06)Soft primary background (badges, tags)
--primary-mutedoklch(0.88 0.02 264.06)oklch(0.28 0.04 264.06)Muted primary (hover states, borders)

Secondary

TokenLight DefaultDark DefaultPurpose
--secondaryoklch(0.97 0.001 286)oklch(0.2 0.005 286.38)Secondary button backgrounds
--secondary-foregroundoklch(0.45 0.01 286)oklch(0.9 0.005 285.82)Text on secondary surfaces

Destructive

TokenLight DefaultDark DefaultPurpose
--destructiveoklch(0.577 0.245 27.33)oklch(0.63 0.24 27.33)Error states, delete actions
--destructive-foregroundoklch(0.98 0 0)oklch(0.1 0.01 27.33)Text on destructive surfaces
--destructive-subtleoklch(0.95 0.05 27.33)oklch(0.22 0.06 27.33)Soft error background
--destructive-mutedoklch(0.88 0.08 27.33)oklch(0.28 0.08 27.33)Muted error (hover, borders)

Success

TokenLight DefaultDark DefaultPurpose
--successoklch(0.55 0.17 155)oklch(0.62 0.17 155)Positive states, confirmations
--success-foregroundoklch(0.98 0 0)oklch(0.1 0.01 155)Text on success surfaces
--success-subtleoklch(0.95 0.04 155)oklch(0.2 0.04 155)Soft success background
--success-mutedoklch(0.88 0.06 155)oklch(0.26 0.06 155)Muted success (hover, borders)

Warning

TokenLight DefaultDark DefaultPurpose
--warningoklch(0.75 0.18 75)oklch(0.78 0.18 75)Caution states, non-blocking alerts
--warning-foregroundoklch(0.18 0.02 75)oklch(0.15 0.02 75)Text on warning surfaces
--warning-subtleoklch(0.96 0.04 75)oklch(0.22 0.04 75)Soft warning background
--warning-mutedoklch(0.9 0.06 75)oklch(0.28 0.06 75)Muted warning (hover, borders)

Info

TokenLight DefaultDark DefaultPurpose
--infooklch(0.58 0.16 250)oklch(0.65 0.16 250)Informational callouts
--info-foregroundoklch(0.98 0 0)oklch(0.1 0.01 250)Text on info surfaces
--info-subtleoklch(0.95 0.04 250)oklch(0.2 0.04 250)Soft info background
--info-mutedoklch(0.88 0.06 250)oklch(0.26 0.06 250)Muted info (hover, borders)

Neutral (Surface & Structure)

TokenLight DefaultDark DefaultPurpose
--mutedoklch(0.97 0.001 286)oklch(0.2 0.005 286.38)Muted backgrounds (disabled, secondary panels)
--muted-foregroundoklch(0.58 0.008 286)oklch(0.6 0.01 285.82)Text on muted surfaces (help text, placeholders)
--accentoklch(0.97 0.001 286)oklch(0.22 0.005 286.38)Accent backgrounds (hover rows, active nav)
--accent-foregroundoklch(0.21 0.014 285.82)oklch(0.9 0.005 285.82)Text on accent surfaces

Structure

TokenLight DefaultDark DefaultPurpose
--borderoklch(0.92 0.004 286)oklch(0.28 0.005 286.32)Default border color
--inputoklch(0.92 0.004 286)oklch(0.28 0.005 286.32)Input field borders
--ringoklch(0.708 0.028 256.85)oklch(0.55 0.04 256.85)Focus ring color

Overlays

TokenLight DefaultDark DefaultPurpose
--popoveroklch(1 0 0)oklch(0.18 0.008 285.82)Popover/dropdown background
--popover-foregroundoklch(0.145 0.014 285.82)oklch(0.93 0.005 285.82)Text inside popovers
--cardoklch(1 0 0)oklch(0.17 0.008 285.82)Card background
--card-foregroundoklch(0.145 0.014 285.82)oklch(0.93 0.005 285.82)Text inside cards

Invert

TokenLight DefaultDark DefaultPurpose
--invertoklch(0.21 0.014 285.82)oklch(0.7 0.01 285.82)Inverted surface (tooltips, toasts)
--invert-foregroundoklch(0.985 0 0)oklch(0.13 0.01 285.82)Text on inverted surfaces
TokenLight DefaultDark DefaultPurpose
--sidebaroklch(0.975 0.002 286)oklch(0.12 0.008 285.82)Sidebar background
--sidebar-foregroundoklch(0.45 0.01 286)oklch(0.7 0.01 264.06)Sidebar text color
--sidebar-primaryoklch(0.21 0.034 264.06)oklch(0.6 0.06 264.06)Sidebar active item color
--sidebar-primary-foregroundoklch(0.98 0 0)oklch(0.1 0.01 264.06)Text on sidebar active items
--sidebar-accentoklch(0.93 0.012 264)oklch(0.22 0.02 264.06)Sidebar hover/focus background
--sidebar-accent-foregroundoklch(0.21 0.014 285.82)oklch(0.9 0.005 285.82)Text on sidebar accent surfaces
--sidebar-hoveroklch(0.955 0.003 286)oklch(0.185 0.006 286)Sidebar item hover background
--sidebar-borderoklch(0.91 0.005 286)oklch(0.25 0.005 286.32)Sidebar divider/border color

Chart

TokenLight DefaultDark DefaultPurpose
--chart-1oklch(0.646 0.222 41.116)oklch(0.7 0.2 41.116)Chart series 1 (orange)
--chart-2oklch(0.6 0.118 184.714)oklch(0.65 0.12 184.714)Chart series 2 (teal)
--chart-3oklch(0.398 0.07 227.392)oklch(0.5 0.07 227.392)Chart series 3 (steel blue)
--chart-4oklch(0.828 0.189 84.429)oklch(0.85 0.17 84.429)Chart series 4 (yellow-green)
--chart-5oklch(0.769 0.188 70.08)oklch(0.8 0.17 70.08)Chart series 5 (amber)

Typography

TokenDefaultPurpose
--font-sans"Figtree Variable", "Figtree", ui-sans-serif, system-ui, ...Primary UI font stack
--font-mono"JetBrains Mono", "Fira Code", ui-monospace, ...Monospace font stack

Typography Scale

TokenDefaultPurpose
--text-displayclamp(1.75rem, 4vw, 2rem)Hero headings, page title emphasis (28-32px)
--text-h11.5rem (24px)Page title (one per page)
--text-h21.25rem (20px)Section title
--text-h31.125rem (18px)Card title, panel header
--text-h41rem (16px)Sub-section, field group
--text-body-l1rem (16px)Long reading, descriptions
--text-body-m0.875rem (14px)Body default, table cells, labels
--text-body-s0.75rem (12px)Captions, help text, timestamps
--text-mono0.8125rem (13px)Code, IDs, technical values
--text-overline0.6875rem (11px)Section labels (uppercase)

Border Radius

TokenDefaultPurpose
--radius0.625rem (10px)Base radius used by Tailwind theme calculations
--radius-none0pxNo rounding
--radius-sm4pxSmall elements (checkboxes, chips)
--radius-md6pxMedium elements (inputs, small cards)
--radius-lg8pxLarge elements (cards, panels)
--radius-xl12pxExtra large (modals, dialogs)
--radius-2xl16pxFull panels, hero sections
--radius-full9999pxPill shape (avatars, badges)

Tailwind radius mapping

The Tailwind @theme block derives --radius-sm, --radius-md, --radius-lg, --radius-xl, and --radius-2xl from the base --radius value using calc(). If you override --radius, all Tailwind radius utilities adjust proportionally.

Shadows

TokenLight DefaultDark DefaultPurpose
--shadow-xs0 1px 2px oklch(0 0 0 / 0.04)0 1px 2px oklch(0 0 0 / 0.2)Subtle elevation (buttons)
--shadow-sm0 1px 3px ... / 0.06, 0 1px 2px ... / 0.04... / 0.25, ... / 0.2Low elevation (cards)
--shadow-md0 4px 6px ... / 0.06, 0 2px 4px ... / 0.04... / 0.3, ... / 0.2Medium elevation (dropdowns)
--shadow-lg0 10px 15px ... / 0.08, 0 4px 6px ... / 0.04... / 0.35, ... / 0.2High elevation (popovers)
--shadow-xl0 20px 25px ... / 0.08, 0 8px 10px ... / 0.04... / 0.35, ... / 0.2Very high elevation (modals)
--shadow-2xl0 25px 50px oklch(0 0 0 / 0.12)... / 0.45Maximum elevation (command palette)

Dark mode increases shadow opacity so shadows remain visible on dark surfaces.

Spacing (8px base grid)

TokenValuePurpose
--space-00pxNo spacing
--space-0.52pxHairline gap
--space-14pxTight internal padding
--space-1.56pxSmall internal padding
--space-28pxBase unit
--space-312pxComfortable internal padding
--space-416pxStandard padding
--space-520pxMedium gap
--space-624pxSection padding
--space-832pxLarge gap
--space-1040pxSection margin
--space-1248pxLarge section margin
--space-1664pxPage-level spacing
--space-2080pxMaximum spacing

Motion

TokenValuePurpose
--duration-instant0msNo animation
--duration-fast100msMicro-interactions (hover, focus)
--duration-normal150msStandard transitions
--duration-moderate200msPanel slides, accordion
--duration-slow300msPage transitions, modals
--duration-slower500msComplex animations
--ease-defaultcubic-bezier(0.2, 0, 0, 1)General-purpose easing
--ease-incubic-bezier(0.4, 0, 1, 1)Accelerating (exit)
--ease-outcubic-bezier(0, 0, 0.2, 1)Decelerating (enter)
--ease-bouncecubic-bezier(0.34, 1.56, 0.64, 1)Playful overshoot

Reduced motion

When prefers-reduced-motion: reduce is active, all animation and transition durations are forced to 0.01ms.

Layout Dimensions

TokenValuePurpose
--content-max-width1280pxDefault content container max-width
--content-narrow720pxNarrow content (forms, articles)
--content-wide1440pxWide content (dashboards)
--sidebar-width260pxSidebar expanded width
--sidebar-width-collapsed60pxSidebar collapsed width
--sidebar-width-mobile300pxSidebar width on mobile (overlay)

Breakpoints

TokenValuePurpose
--breakpoint-sm640pxSmall screens
--breakpoint-md768pxTablets
--breakpoint-lg1024pxLaptops
--breakpoint-xl1280pxDesktops
--breakpoint-2xl1536pxLarge desktops

Component Sizing

TokenValuePurpose
--size-button-sm32pxSmall button height
--size-button-md36pxDefault button height
--size-button-lg44pxLarge button height (touch target)
--size-input36pxInput field height
--size-table-row48pxDefault table row height
--size-sidebar-item40pxSidebar navigation item height
--size-avatar-sm24pxSmall avatar
--size-avatar-md32pxDefault avatar
--size-avatar-lg40pxLarge avatar

Z-Index Scale

TokenValuePurpose
--z-base0Base layer
--z-sticky10Sticky headers, toolbars
--z-dropdown20Dropdown menus
--z-overlay30Overlays, backdrops
--z-popover40Popovers, tooltips
--z-modal50Modal dialogs
--z-toast60Toast notifications
--z-command100Command palette (always on top)

Built-in Themes (PRO)

PRO Feature

Built-in themes are available with the PRO package from GitHub Packages. Community users can create unlimited custom themes using the same CSS token system — see Custom Themes below.

MIDDAG ships 4 built-in themes that produce visually distinct appearances — not just color swaps, but different radius, density, shadows, and component-level behavior.

ThemePrimary HueRadiusTable RowPersonality
Classic (Maia)264 dark blue0.625rem48px comfortableDefault MIDDAG, shadcn/ui feel
Enterprise255 corporate blue0.375rem40px compactJira/Linear, dense issue-tracker
Soft330 rose/pink0.75rem52px spaciousNotion/Craft, friendly consumer
Midnight220 deep indigo0.375rem36px compactGitHub Dark/Vercel, dev-tools

Using built-in themes

Import the theme CSS file after style.css:

ts
// In your main entry point
import "@middag-io/react/style.css";
import "@middag-io/react/themes/enterprise.css";  // or classic, soft, midnight

Apply the theme class on <body> (so portals like toasts and modals also pick it up):

ts
document.body.classList.add("theme-enterprise");

Or use the exported constants:

ts
import { THEME_CLASSES } from "@middag-io/react";

document.body.classList.add(THEME_CLASSES.enterprise); // "theme-enterprise"

Multiple themes with runtime switching

Import all themes you want to support, then toggle the class:

ts
import "@middag-io/react/style.css";
import "@middag-io/react/themes/classic.css";
import "@middag-io/react/themes/enterprise.css";
import "@middag-io/react/themes/soft.css";
import "@middag-io/react/themes/midnight.css";
ts
import { THEME_CLASSES, THEME_IDS, type ThemeId } from "@middag-io/react";

function switchTheme(newTheme: ThemeId) {
  const body = document.body;
  for (const cls of Object.values(THEME_CLASSES)) body.classList.remove(cls);
  body.classList.add(THEME_CLASSES[newTheme]);
}

Component-level overrides (ThemeContext)

CSS tokens control colors, radius, shadows, and sizing. But some differences require component-level variant selection (e.g., enterprise uses outline badges, soft uses pill tabs). The MIDDAGThemeProvider exposes these overrides:

tsx
import { MIDDAGThemeProvider, useThemeOverrides } from "@middag-io/react";

// Wrap your app
<MIDDAGThemeProvider theme="enterprise">
  <App />
</MIDDAGThemeProvider>

// Inside any component
function MyBadge({ label }: { label: string }) {
  const { badge } = useThemeOverrides();
  // badge.variant = "outline" for enterprise, "soft" for soft, "default" for classic
  // badge.rounded = true for soft, false for others
  return <Badge variant={badge.variant}>{label}</Badge>;
}

Override definitions per theme:

ComponentClassicEnterpriseSoftMidnight
Badge variantdefaultoutlinesoftoutline
Badge roundednonoyesno
Button sizedefaultsmdefaultsm
Tabs variantdefaultlinepillline
Table densitycomfortablecompactspaciouscompact
Table stripednoyesnono
Card borderednoyesnoyes

useThemeOverrides() is safe to call without a provider — returns classic defaults.

Theme architecture (3 layers)

  1. CSS tokens (@middag-io/react/themes/*.css) — colors, radius, shadows, sizing. Applied via .theme-* class on <body>.
  2. Theme bridge (built into style.css) — forces re-resolution of Tailwind --color-* tokens at theme scope. Transparent to consumers.
  3. ThemeContext (MIDDAGThemeProvider) — component-level overrides (badge variant, button size, etc.). Optional React provider.

Custom Themes (Community + PRO)

Any consumer — Community or PRO — can create custom themes using CSS custom properties.

Step 1: Import the base theme

In your application's main CSS file, import the MIDDAG theme before your overrides:

css
@import "tailwindcss";
@import "@middag-io/react/theme.css";

This loads all design tokens into :root and registers the Tailwind @theme mappings.

Step 2: Override specific tokens

After the import, redeclare any token you want to change:

css
@import "tailwindcss";
@import "@middag-io/react/theme.css";

:root {
  /* Replace the default font stack */
  --font-sans: "Inter Variable", "Inter", ui-sans-serif, system-ui, sans-serif;

  /* Adjust the base radius for sharper corners */
  --radius: 0.375rem;

  /* Custom brand color */
  --primary: oklch(0.45 0.2 260);
  --primary-foreground: oklch(0.98 0 0);
}

Step 3: Dark mode overrides

Override tokens specifically for dark mode using .dark on the root element:

css
:root.dark,
[data-theme="dark"] {
  --primary: oklch(0.65 0.15 260);
  --primary-foreground: oklch(0.1 0.01 260);
  --background: oklch(0.1 0.015 280);
}

Step 4: Scoped custom themes

Create named theme variants by nesting token overrides under a class:

css
.theme-ocean {
  /* Colors */
  --primary: oklch(0.5 0.18 230);
  --primary-foreground: oklch(0.98 0 0);
  --primary-subtle: oklch(0.94 0.04 230);
  --accent: oklch(0.95 0.02 230);

  /* Shape — customize radius, density, shadows */
  --radius: 0.5rem;
  --radius-sm: 4px;
  --radius-md: 6px;
  --radius-lg: 8px;
  --size-table-row: 44px;
  --size-button-md: 34px;

  /* Sidebar */
  --sidebar: oklch(0.15 0.03 230);
  --sidebar-foreground: oklch(0.75 0.02 230);
  --sidebar-width: 256px;
}

:root.dark .theme-ocean,
[data-theme="dark"] .theme-ocean {
  --primary: oklch(0.65 0.14 230);
  --primary-foreground: oklch(0.1 0.01 230);
  --background: oklch(0.12 0.01 230);
  --sidebar: oklch(0.10 0.02 230);
}

Apply it to <body> so portals (toasts, modals) also pick up the tokens:

html
<body class="theme-ocean">
  <div id="root" class="middag-root">...</div>
</body>

Theme bridge

If you use Tailwind v4 @theme with var() references, custom theme classes need a theme bridge — re-declarations of --color-*: var(--*) at the .theme-* scope. See the built-in theme bridge for the pattern. Without this, Tailwind utilities like bg-primary won't react to your custom theme's --primary value.


Example: WordPress Plugin Theme

The middag-account WordPress plugin overrides the default Figtree font and injects a brand color. The plugin's PHP enqueues a stylesheet that sets:

css
:root {
  /* Override the font stack to match WordPress admin */
  --font-sans:
    -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
    Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;

  /* Brand color injected via PHP from plugin settings */
  --middag-brand: oklch(0.45 0.12 264);
}

The --middag-brand custom property is special: the Tailwind @theme block maps --color-primary to var(--middag-brand, var(--primary)), so setting --middag-brand overrides the primary color without touching the base token. This allows the host application to brand the UI without conflicting with the default theme.

PHP injection example:

php
// In your plugin's enqueue callback:
$brand = get_option('middag_brand_color', 'oklch(0.45 0.12 264)');
wp_add_inline_style('middag-react', ":root { --middag-brand: {$brand}; }");

Host Integration

When MIDDAG UI runs inside a host application (WordPress admin, Moodle), the host's fixed header and sidebar occupy screen space. Two CSS custom properties let the MIDDAG layout account for this:

PropertyPurposeExample
--host-header-heightHeight of the host's fixed top bar32px (WP admin bar), 50px (Moodle navbar)
--host-sidebar-widthWidth of the host's fixed sidebar160px (WP expanded), 36px (WP collapsed), 0px (Moodle)

The MIDDAG sidebar positions itself using these values:

css
.middag-sidebar {
  top: var(--host-header-height, 0px);
  height: calc(100svh - var(--host-header-height, 0px));
  left: var(--host-sidebar-width, 0px);
}

Set these properties in your host adapter or inline style:

css
:root {
  --host-header-height: 32px;   /* WordPress admin bar */
  --host-sidebar-width: 160px;  /* WordPress sidebar (expanded) */
}

/* When WP sidebar is collapsed */
.folded :root {
  --host-sidebar-width: 36px;
}

For Moodle, --host-header-height is typically 50px and --host-sidebar-width is 0px (Moodle uses a top navigation, not a sidebar).


Density

The DataTable component supports three density levels that control row height:

LevelRow HeightUse Case
compact32pxDense data views, power users, maximum rows visible
comfortable40pxDefault — balanced readability and density
spacious48pxTouch-friendly, accessibility, relaxed scanning

The density toggle is built into the DataTable toolbar and cycles through these levels. The default is comfortable.

These heights align with the component sizing tokens:

  • compact (32px) matches --size-button-sm
  • comfortable (40px) matches --size-sidebar-item
  • spacious (48px) matches --size-table-row

Appearance Modes

The theme system supports three appearance modes:

  • system — auto-detects from the host application (Moodle/WP) or falls back to OS prefers-color-scheme.
  • light — forces light mode regardless of host or OS preference.
  • dark — forces dark mode regardless of host or OS preference.

Users can override via the appearance toggle in the sidebar footer, which cycles through System > Light > Dark > System.

Appearance API

Import these functions from @middag-io/react to control the theme programmatically:

FunctionSignatureDescription
getStoredAppearance()() => AppearanceRead the stored preference from localStorage. Returns "system", "light", or "dark".
setAppearance(pref)(Appearance) => voidPersist preference and apply the resolved theme to DOM immediately.
cycleAppearance()() => AppearanceCycle to the next mode (system > light > dark > system) and return the new value.
getEffectiveTheme(pref)(Appearance) => "light" | "dark"Resolve a preference to the effective theme. For "system", consults host then OS.
applyTheme(theme)("light" | "dark") => voidApply a theme to DOM: sets data-theme on .middag-root elements and toggles .dark on <html>.
toggleDir()() => "ltr" | "rtl"Toggle document direction between LTR and RTL. Persists choice in localStorage.
initDir()() => voidRestore persisted direction on page load.
onSystemThemeChange(cb?)(cb?: () => void) => () => voidListen for OS theme changes. Only reacts when preference is 'system'. Returns cleanup function.

Example

tsx
import { cycleAppearance, getStoredAppearance } from '@middag-io/react';

function ThemeToggle() {
    const [mode, setMode] = useState(getStoredAppearance());

    return (
        <button onClick={() => setMode(cycleAppearance())}>
            {mode === 'system' ? 'System' : mode === 'light' ? 'Light' : 'Dark'}
        </button>
    );
}

useIsDark Hook

The useIsDark hook provides a reactive boolean that updates whenever the theme changes. It uses useSyncExternalStore with a MutationObserver on <html> to detect class changes:

tsx
import { useIsDark } from '@middag-io/react';

function MyComponent() {
    const isDark = useIsDark();
    return (
        <div style={{ background: isDark ? '#1a1a1a' : '#ffffff' }}>
            Current theme: {isDark ? 'dark' : 'light'}
        </div>
    );
}

Prefer Tailwind classes

In most cases you can use Tailwind dark: prefix classes instead of useIsDark. The hook is mainly useful when you need to pass theme information to third-party libraries or canvas-based components.

Host Theme Detection

When appearance is set to system, the theme engine detects the host application's theme before falling back to OS preference:

  • Moodle — checks <html data-theme>, .theme-dark class, and Boost CSS variables.
  • WordPress — checks body.admin-color-* classes and <html class="dark">.
  • Fallback — OS prefers-color-scheme: dark media query.

Read-only detection

Host theme detection is read-only. MIDDAG never modifies the host page's own classes or attributes — it only reads them to determine the current theme.

RTL Support

Full right-to-left support is built in:

  • toggleDir() switches between LTR and RTL, persisting in localStorage.
  • initDir() restores persisted direction on page load.
  • Tailwind RTL utilities (rtl: prefix) work automatically.
ts
import { toggleDir, initDir } from '@middag-io/react';

// On app load, restore persisted direction
initDir();

// Toggle on user action
const newDir = toggleDir();
console.log('Direction:', newDir); // 'rtl' or 'ltr'

Types

ts
type Appearance = 'system' | 'light' | 'dark';
type EffectiveTheme = 'light' | 'dark';
type AsyncStringResolver = (key: string, component?: string) => Promise<string>;

MIDDAG © 2015-2026