zudo-codemirror-wisdom

Type to search...

to open search from anywhere

Palette-Based Theme System

CreatedApr 3, 2026Takeshi Takatsudo

Create a CodeMirror theme driven entirely by CSS custom properties using an ANSI-style palette, color-mix() for opacity, and HighlightStyle tag mappings.

Palette-Based Theme System

This page documents a theme architecture where CodeMirror gets all its colors from CSS custom properties following an ANSI terminal palette convention. The editor’s appearance updates automatically when the CSS variables change, with no need to rebuild or reconfigure the theme.

The Palette

The color scheme is defined as CSS custom properties on a parent element:

:root {
  --palette-fg: #c0c0c0;
  --palette-bg: #1e1e2e;
  --palette-cursor: #f5e0dc;
  --palette-selection: rgba(88, 91, 112, 0.5);

  /* ANSI-style palette: 0-15 */
  --palette-0: #11111b; /* black   -- surfaces, gutters */
  --palette-1: #f38ba8; /* red     -- invalid, errors */
  --palette-2: #a6e3a1; /* green   -- strings */
  --palette-3: #f9e2af; /* yellow  -- numbers, types */
  --palette-4: #89b4fa; /* blue    -- keywords, headings */
  --palette-5: #cba6f7; /* magenta -- functions, tags */
  --palette-6: #94e2d5; /* cyan    -- operators, links */
  --palette-7: #bac2de; /* white   -- punctuation */
  --palette-8: #585b70; /* bright black -- comments */
  --palette-15: #6c7086; /* bright white -- line numbers */
}

This palette maps to the traditional 16-color ANSI terminal convention, which provides a natural mapping for syntax highlighting categories.

Editor UI Theme

The editor theme uses EditorView.theme() with CSS custom properties for every color:

import { EditorView } from "@codemirror/view";

function createEditorColorTheme(isDark: boolean) {
  const baseTheme = EditorView.theme(
    {
      "&": {
        color: "var(--palette-fg)",
        backgroundColor: "var(--palette-bg)",
      },
      ".cm-content": {
        caretColor: "var(--palette-cursor)",
      },
      ".cm-cursor, .cm-dropCursor": {
        borderLeftColor: "var(--palette-cursor)",
      },
      "&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection":
        {
          backgroundColor: "var(--palette-selection)",
        },
      ".cm-panels": {
        backgroundColor: "var(--palette-0)",
        color: "var(--palette-fg)",
      },
      ".cm-gutters": {
        backgroundColor: "var(--palette-bg)",
        color: "var(--palette-15)",
        border: "none",
      },
    },
    { dark: isDark },
  );

  // ... (highlight style below)
  return [baseTheme];
}

The isDark parameter tells CodeMirror whether this is a dark theme, which affects default styles for UI elements not explicitly styled.

Opacity with color-mix()

For semi-transparent overlays (active line, search matches, selection matches), the theme uses color-mix() in the oklch color space:

".cm-activeLine": {
  backgroundColor: "color-mix(in oklch, var(--palette-fg) 4%, transparent)",
},
".cm-searchMatch": {
  backgroundColor: "color-mix(in oklch, var(--palette-3) 30%, transparent)",
  outline: "1px solid color-mix(in oklch, var(--palette-3) 50%, transparent)",
},
".cm-searchMatch.cm-searchMatch-selected": {
  backgroundColor: "color-mix(in oklch, var(--palette-3) 50%, transparent)",
},
".cm-selectionMatch": {
  backgroundColor: "color-mix(in oklch, var(--palette-selection) 50%, transparent)",
},
"&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": {
  backgroundColor: "color-mix(in oklch, var(--palette-4) 25%, transparent)",
},

💡 Tip

color-mix(in oklch, var(--some-color) 30%, transparent) is a modern CSS alternative to rgba() with hardcoded values. It works with any color format and produces perceptually uniform blending in the oklch color space. It allows you to derive semi-transparent variants from opaque custom properties without defining additional variables.

Syntax Highlighting with HighlightStyle

The highlight style maps Lezer tags to palette colors:

import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { tags } from "@lezer/highlight";

const highlightStyle = HighlightStyle.define([
  // Comments
  { tag: tags.comment, color: "var(--palette-8)" },

  // Keywords & control flow
  { tag: tags.keyword, color: "var(--palette-4)" },

  // Functions & tags
  {
    tag: [
      tags.function(tags.variableName),
      tags.function(tags.definition(tags.variableName)),
      tags.tagName,
    ],
    color: "var(--palette-5)",
  },

  // Strings
  { tag: [tags.string, tags.special(tags.string)], color: "var(--palette-2)" },
  { tag: tags.regexp, color: "var(--palette-6)" },
  { tag: tags.escape, color: "var(--palette-6)" },

  // Numbers, booleans, types
  {
    tag: [tags.number, tags.bool, tags.null, tags.typeName, tags.className, tags.namespace],
    color: "var(--palette-3)",
  },

  // Variables & properties
  { tag: [tags.variableName, tags.definition(tags.variableName)], color: "var(--palette-fg)" },
  { tag: [tags.propertyName, tags.definition(tags.propertyName)], color: "var(--palette-4)" },

  // Operators & punctuation
  { tag: tags.operator, color: "var(--palette-6)" },
  { tag: tags.punctuation, color: "var(--palette-7)" },
  { tag: [tags.derefOperator, tags.separator], color: "var(--palette-fg)" },

  // HTML/JSX attributes
  { tag: tags.attributeName, color: "var(--palette-3)" },
  { tag: tags.attributeValue, color: "var(--palette-2)" },

  // Markdown markup
  { tag: tags.heading, color: "var(--palette-4)", fontWeight: "bold" },
  { tag: tags.emphasis, fontStyle: "italic", color: "var(--palette-5)" },
  { tag: tags.strong, fontWeight: "bold", color: "var(--palette-3)" },
  { tag: [tags.link, tags.url], color: "var(--palette-6)", textDecoration: "underline" },
  { tag: tags.quote, color: "var(--palette-8)", fontStyle: "italic" },
  { tag: tags.monospace, color: "var(--palette-2)" },
  { tag: tags.strikethrough, textDecoration: "line-through" },

  // Meta & invalid
  { tag: tags.meta, color: "var(--palette-8)" },
  { tag: tags.invalid, color: "var(--palette-1)" },
]);

Palette-to-Syntax Mapping

Palette SlotANSI ColorSyntax Category
--palette-1RedInvalid tokens
--palette-2GreenStrings, attribute values, monospace
--palette-3YellowNumbers, types, booleans, attributes, strong text
--palette-4BlueKeywords, properties, headings
--palette-5MagentaFunctions, tags, emphasis
--palette-6CyanOperators, regex, escape sequences, links
--palette-7WhitePunctuation
--palette-8Bright blackComments, quotes, meta
--palette-15Bright whiteLine numbers
--palette-fgForegroundVariables, separators, deref operators

Returning the Complete Theme

The function returns both the editor theme and the syntax highlighting as an array of extensions:

function createEditorColorTheme(isDark: boolean): Extension[] {
  const baseTheme = EditorView.theme({ /* ... */ }, { dark: isDark });
  const highlightStyle = HighlightStyle.define([ /* ... */ ]);
  return [baseTheme, syntaxHighlighting(highlightStyle)];
}

Usage

const extensions = [
  ...createEditorColorTheme(isDark),
  // ... other extensions
];

Switching themes at runtime only requires changing the CSS custom property values on the parent element. Because HighlightStyle.define() uses var() references (not resolved values), the colors update via CSS without reconfiguring CodeMirror.

📝 Note

The isDark parameter passed to EditorView.theme() is a static boolean set at editor creation time. If the user switches between light and dark themes, the editor should be recreated with the correct isDark value. This only affects CodeMirror’s default fallback styles for elements you have not explicitly themed.

Tooltip and Panel Styling

The theme also covers tooltips, panels, and fold placeholders to maintain visual consistency:

".cm-tooltip": {
  border: "1px solid var(--palette-0)",
  backgroundColor: "var(--palette-bg)",
},
".cm-tooltip-autocomplete": {
  "& > ul > li[aria-selected]": {
    backgroundColor: "var(--palette-selection)",
    color: "var(--palette-fg)",
  },
},
".cm-foldPlaceholder": {
  backgroundColor: "transparent",
  border: "none",
  color: "var(--palette-8)",
},

Every visible surface in the editor uses a palette color, ensuring the editor looks correct regardless of which color scheme is active.

Revision History