Skip to content

Theming

notectl ships with a complete theming system built on CSS custom properties. Two built-in themes (light & dark), automatic system-preference detection, and full custom-theme support are included out of the box.

For CSP runtime styling and nonce setup, see the Content Security Policy guide.

import { createEditor, ThemePreset } from '@notectl/core';
const editor = await createEditor({
theme: ThemePreset.Dark,
});

Available presets:

PresetValueDescription
ThemePreset.Light'light'Default light theme
ThemePreset.Dark'dark'Dark theme (Catppuccin-inspired)
ThemePreset.System'system'Follows OS prefers-color-scheme
<notectl-editor theme="dark"></notectl-editor>
const editor = await createEditor({
theme: ThemePreset.System,
});

The editor listens to prefers-color-scheme changes and switches automatically.

// Switch to dark
editor.setTheme(ThemePreset.Dark);
// Read current theme
const current = editor.getTheme(); // 'light' | 'dark' | 'system' | Theme object

Toggle example:

const toggle = document.getElementById('theme-toggle');
toggle.addEventListener('click', () => {
const next = editor.getTheme() === ThemePreset.Dark
? ThemePreset.Light
: ThemePreset.Dark;
editor.setTheme(next);
});

Create a custom theme by extending a built-in base theme with partial overrides:

import { createTheme, LIGHT_THEME, createEditor } from '@notectl/core';
import type { Theme } from '@notectl/core';
const corporate: Theme = createTheme(LIGHT_THEME, {
name: 'corporate',
primitives: {
primary: '#6B21A8',
primaryForeground: '#6B21A8',
primaryMuted: 'rgba(107, 33, 168, 0.15)',
borderFocus: '#6B21A8',
focusRing: 'rgba(107, 33, 168, 0.2)',
},
});
const editor = await createEditor({ theme: corporate });

Only specify the values you want to change — everything else falls back to the base theme.

Component-level tokens (toolbar, code block, tooltip) can be overridden independently:

const myTheme: Theme = createTheme(DARK_THEME, {
name: 'custom-dark',
codeBlock: {
background: '#0d1117',
foreground: '#c9d1d9',
},
tooltip: {
background: '#21262d',
foreground: '#f0f6fc',
},
});

Themes are plain objects — export them from a package:

my-theme-package/index.ts
import { createTheme, DARK_THEME } from '@notectl/core';
import type { Theme } from '@notectl/core';
export const OCEAN_THEME: Theme = createTheme(DARK_THEME, {
name: 'ocean',
primitives: {
primary: '#06b6d4',
background: '#0c1222',
surfaceRaised: '#1a2332',
surfaceOverlay: '#1a2332',
},
});

Every component-level rule in notectl reads its color through a layered cascade:

border-color: var(--notectl-table-border, var(--notectl-border, #d0d7de));
/* ^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^ ^^^^^^^^
component-scoped token global semantic hard-coded
(target a single component) (theme-wide) (last resort) */
TierExampleAudience
Component-scoped token--notectl-table-borderYou only want to recolor a single component
Global semantic token--notectl-borderYou want a unified theme across the editor
Hard-coded fallback#d0d7deUsed only when neither token is set

Backwards compatibility. Setting --notectl-border continues to work exactly as before — it cascades to every component that doesn’t have a more specific override. The component-scoped tokens are purely additive; they exist for cases where the global token is too broad.

Example — theme just the table:

notectl-editor {
--notectl-table-border: #6366f1;
--notectl-table-header-bg: #eef2ff;
}

This is the exact ask from discussion #120: style the table without affecting any other component.

For customization beyond what tokens cover, notectl exposes CSS Shadow Parts on every structural element. Parts let you target deep DOM internals from outside the shadow root without forking the editor or piercing the boundary.

notectl-editor::part(toolbar-button) {
border-radius: 9999px;
}
notectl-editor::part(toolbar-button-active) {
background: linear-gradient(135deg, #6366f1, #8b5cf6);
}
notectl-editor::part(blockquote) {
font-style: italic;
}
PartElementNotes
editor<notectl-editor> wrapperRoot of the editor surface
contentEditable content areaWhere the user types
plugin-container + plugin-container-topToolbar hostTwo parts on the same element so consumers can target either
plugin-container + plugin-container-bottomBelow-content slot
toolbarToolbar root
toolbar-buttonAny toolbar button
toolbar-button + toolbar-button-activeActive toolbar buttonModifier part synced with aria-pressed
toolbar-button + toolbar-overflow-buttonThe “more” button in burger-menu overflow mode
toolbar-dividerGroup separator
tableTable wrapper
table-row<tr>
table-cell<td>
code-block<pre> root
code-block-headerHeader row (language label + actions)
code-block-content<code> inside
blockquote<blockquote> root

Stateful elements expose state via modifier parts (space-separated values). The modifier mirrors ARIA state for styling only — semantic state remains on attributes like aria-pressed.

<button part="toolbar-button toolbar-button-active" aria-pressed="true">B</button>
notectl-editor::part(toolbar-button-active) {
color: var(--my-accent);
}

This works in every browser that supports ::part() and does not depend on the newer CustomStateSet API.

The theme engine sets all properties on :host inside the Shadow DOM. Every color in the editor references these variables.

Documented public tokens are declared with @property so DevTools surfaces them and invalid values fall back to a typed initial value instead of breaking downstream rules.

These are the core tokens that all components derive their colors from.

CSS PropertyTokenDescription
--notectl-bgbackgroundEditor canvas background
--notectl-fgforegroundMain text color
--notectl-fg-mutedmutedForegroundSecondary text (placeholders, labels, arrows)
--notectl-borderborderDefault borders (editor, toolbar, inputs, separators)
--notectl-border-focusborderFocusFocus state border
--notectl-primaryprimaryAccent color (selection outlines, insert lines, active states)
--notectl-primary-fgprimaryForegroundText on primary-tinted backgrounds
--notectl-primary-mutedprimaryMutedSubtle primary background (active toolbar button, selected cells)
--notectl-surface-raisedsurfaceRaisedElevated surfaces (toolbar background)
--notectl-surface-overlaysurfaceOverlayOverlay surfaces (popups, context menus, dropdowns)
--notectl-hover-bghoverBackgroundHover state background
--notectl-active-bgactiveBackgroundActive/pressed state background
--notectl-dangerdangerDelete and error color
--notectl-danger-muteddangerMutedSubtle danger background
--notectl-successsuccessChecked/success color (checklist checkmarks)
--notectl-shadowshadowBox-shadow color
--notectl-focus-ringfocusRingFocus ring shadow (typically semi-transparent)
CSS PropertyTokenFallback
--notectl-toolbar-bgtoolbar.backgroundvar(--notectl-surface-raised)
--notectl-toolbar-bordertoolbar.borderColorvar(--notectl-border)
--notectl-toolbar-button-bg— (CSS-only)transparent
--notectl-toolbar-button-fg— (CSS-only)var(--notectl-fg)
--notectl-toolbar-button-hover-bg— (CSS-only)var(--notectl-hover-bg)
--notectl-toolbar-button-active-bg— (CSS-only)var(--notectl-active-bg)
--notectl-toolbar-button-active-fg— (CSS-only)var(--notectl-primary-fg)
CSS PropertyDescriptionFallback
--notectl-table-borderAll table cell bordersvar(--notectl-border)
--notectl-table-cell-bgPer-cell backgroundtransparent
--notectl-table-header-bgHeader row background (<th>)var(--notectl-surface-raised)

The per-table inline override set by the toolbar’s “Border color” action (--ntbl-border-color) still wins over --notectl-table-border so user customizations remain visible after a theme switch.

CSS PropertyDescriptionFallback
--notectl-blockquote-borderLeft bar colorvar(--notectl-border)
--notectl-blockquote-bgBackgroundtransparent
--notectl-blockquote-fgForegroundinherit
CSS PropertyTokenFallback
--notectl-code-block-bgcodeBlock.backgroundvar(--notectl-surface-raised)
--notectl-code-block-colorcodeBlock.foregroundvar(--notectl-fg)
--notectl-code-block-header-bgcodeBlock.headerBackgroundvar(--notectl-surface-raised)
--notectl-code-block-header-colorcodeBlock.headerForegroundvar(--notectl-fg-muted)
--notectl-code-block-header-bordercodeBlock.headerBordervar(--notectl-border)

When a ThemeSyntax object is provided in codeBlock.syntax, token colors are emitted as CSS custom properties. Each falls back to var(--notectl-code-block-color) when not set. There are 16 canonical token types; the theme engine derives all variables automatically from the SYNTAX_TOKEN_TYPES list.

CSS PropertyTokenFallback
--notectl-code-token-keywordcodeBlock.syntax.keywordvar(--notectl-code-block-color)
--notectl-code-token-stringcodeBlock.syntax.stringvar(--notectl-code-block-color)
--notectl-code-token-commentcodeBlock.syntax.commentvar(--notectl-code-block-color)
--notectl-code-token-numbercodeBlock.syntax.numbervar(--notectl-code-block-color)
--notectl-code-token-functioncodeBlock.syntax.functionvar(--notectl-code-block-color)
--notectl-code-token-operatorcodeBlock.syntax.operatorvar(--notectl-code-block-color)
--notectl-code-token-punctuationcodeBlock.syntax.punctuationvar(--notectl-code-block-color)
--notectl-code-token-booleancodeBlock.syntax.booleanvar(--notectl-code-block-color)
--notectl-code-token-nullcodeBlock.syntax.nullvar(--notectl-code-block-color)
--notectl-code-token-propertycodeBlock.syntax.propertyvar(--notectl-code-block-color)
--notectl-code-token-typecodeBlock.syntax.typevar(--notectl-code-block-color)
--notectl-code-token-annotationcodeBlock.syntax.annotationvar(--notectl-code-block-color)
--notectl-code-token-tagcodeBlock.syntax.tagvar(--notectl-code-block-color)
--notectl-code-token-attributecodeBlock.syntax.attributevar(--notectl-code-block-color)
--notectl-code-token-constantcodeBlock.syntax.constantvar(--notectl-code-block-color)
--notectl-code-token-regexcodeBlock.syntax.regexvar(--notectl-code-block-color)

Tokens whose value is a TokenStyle object (rather than a plain color string) additionally emit font-style and font-weight variables when those fields are set:

CSS Property patternEmitted when
--notectl-code-token-<type>-font-styleTokenStyle.fontStyle is set
--notectl-code-token-<type>-font-weightTokenStyle.fontWeight is set

The built-in light and dark themes both include full syntax token definitions for all 16 types, so code blocks are styled automatically.

CSS PropertyTokenFallback
--notectl-tooltip-bgtooltip.backgroundvar(--notectl-fg)
--notectl-tooltip-fgtooltip.foregroundvar(--notectl-bg)
CSS PropertyDescription
--notectl-content-min-heightMinimum height of the content area (default: 400px)
--notectl-content-max-heightMaximum height of the content area before it scrolls internally (default: none)

By default the editor grows with its content. To embed it in a fixed-size frame where the toolbar stays pinned at the top, the bottom plugin container stays pinned at the bottom, and the content area scrolls internally, you have two options.

Editor with a fixed external height — toolbar pinned at the top while the content scrolls internally

Give the <notectl-editor> element a height; the toolbar/content/footer distribute themselves automatically.

<notectl-editor style="display: block; height: 500px;"></notectl-editor>

Use --notectl-content-max-height if the editor itself should grow with toolbar/footer but the editable area should scroll once it exceeds a given size.

<notectl-editor style="--notectl-content-max-height: 320px;"></notectl-editor>

Both approaches preserve full keyboard accessibility — caret navigation automatically scrolls the focused position into view.

When a syntax highlighter is configured on the CodeBlockPlugin, token classes are applied to code content. The built-in light and dark themes include full syntax color definitions via CSS custom properties (see the Code Block Syntax Tokens reference above), so code blocks are styled automatically.

To customize syntax colors, override the codeBlock.syntax section in your custom theme. Each token accepts either a plain color string or a TokenStyle object for full font-weight and font-style control:

import { createTheme, LIGHT_THEME } from '@notectl/core';
import type { Theme } from '@notectl/core';
const myTheme: Theme = createTheme(LIGHT_THEME, {
name: 'custom-syntax',
codeBlock: {
syntax: {
keyword: '#d73a49',
string: '#032f62',
number: '#005cc5',
comment: { color: '#6a737d', fontStyle: 'italic' },
function: '#6f42c1',
operator: '#d73a49',
punctuation: '#24292e',
boolean: '#005cc5',
null: '#005cc5',
property: '#005cc5',
// New in 16-token system:
type: '#e36209',
annotation: '#6f42c1',
tag: '#22863a',
attribute: '#6f42c1',
constant: '#005cc5',
regex: '#032f62',
},
},
});

You only need to specify tokens you want to override — unspecified tokens inherit from the base theme.

interface ThemePrimitives {
readonly background: string;
readonly foreground: string;
readonly mutedForeground: string;
readonly border: string;
readonly borderFocus: string;
readonly primary: string;
readonly primaryForeground: string;
readonly primaryMuted: string;
readonly surfaceRaised: string;
readonly surfaceOverlay: string;
readonly hoverBackground: string;
readonly activeBackground: string;
readonly danger: string;
readonly dangerMuted: string;
readonly success: string;
readonly shadow: string;
readonly focusRing: string;
}
/** Per-token style: color plus optional font weight and style. */
interface TokenStyle {
readonly color: string;
readonly fontWeight?: 'normal' | 'bold';
readonly fontStyle?: 'normal' | 'italic';
}
/** A token style value is either a plain color string or a full TokenStyle object. */
type TokenStyleValue = string | TokenStyle;
/**
* Syntax highlighting styles for all 16 canonical token types.
* Derived automatically from SYNTAX_TOKEN_TYPES — adding a new token type
* here propagates to CSS variables and theme validation.
*/
type ThemeSyntax = { readonly [K in SyntaxTokenType]: TokenStyleValue };
// The 16 canonical token types:
// 'keyword' | 'string' | 'comment' | 'number' | 'function' | 'operator'
// | 'punctuation' | 'boolean' | 'null' | 'property'
// | 'type' | 'annotation' | 'tag' | 'attribute' | 'constant' | 'regex'
interface ThemeCodeBlock {
readonly background: string;
readonly foreground: string;
readonly headerBackground: string;
readonly headerForeground: string;
readonly headerBorder: string;
readonly syntax?: ThemeSyntax;
}
interface Theme {
readonly name: string;
readonly primitives: ThemePrimitives;
readonly toolbar?: Partial<ThemeToolbar>;
readonly codeBlock?: Partial<ThemeCodeBlock>;
readonly tooltip?: Partial<ThemeTooltip>;
}

All theme-related exports from @notectl/core:

ExportKindDescription
ThemePresetEnum objectLight, Dark, System
LIGHT_THEMEConstantBuilt-in light theme
DARK_THEMEConstantBuilt-in dark theme
SYNTAX_TOKEN_TYPESConstantTuple of all 16 canonical token type names
createTheme(base, overrides)FunctionCreate custom theme from a base
resolveTheme(preset | theme)FunctionResolve a preset to a full Theme
generateThemeCSS(theme)FunctionGenerate CSS string from a Theme
createThemeStyleSheet(theme)FunctionCreate a CSSStyleSheet from a Theme
ThemeTypeFull theme definition
PartialThemeTypePartial overrides for createTheme()
ThemePrimitivesTypePrimitive color palette
ThemeToolbarTypeToolbar color overrides
ThemeCodeBlockTypeCode block color overrides (includes syntax)
ThemeSyntaxTypeSyntax token styles — mapped type over all 16 token types
ThemeTooltipTypeTooltip color overrides
SyntaxTokenTypeTypeUnion of all 16 token type name strings
TokenStyleTypePer-token style with color, optional fontWeight and fontStyle
TokenStyleValueTypestring | TokenStyle — accepted by every syntax token slot

notectl honors @media (forced-colors: active) (Windows High Contrast Mode, equivalent system-level accessibility settings). The editor falls back to system colors (CanvasText, Highlight, …) so structural elements stay legible even when a user’s OS overrides all color choices.

When writing custom styles for plugins, avoid setting background-color and color independently in a way that breaks the system palette — prefer system colors or currentColor inside forced-colors blocks.

Plugins that create UI elements (popups, dialogs, overlays) should reference theme variables instead of hardcoding colors. Use context.registerStyleSheet() to inject CSS that references the theme custom properties:

// Register a stylesheet during plugin init()
context.registerStyleSheet(`
.my-popup {
background: var(--notectl-surface-overlay);
border: 1px solid var(--notectl-border);
color: var(--notectl-fg);
box-shadow: 0 4px 12px var(--notectl-shadow);
}
`);

This ensures your plugin adapts automatically when the user switches themes, and remains CSP-compliant since all styles are injected via adopted stylesheets rather than inline style attributes.