Skip to content

Notion Color Picker

NotionColorPicker adds a bubble-menu trigger (“A” glyph with a slash) that drives named-token inline colors on the textStyle mark - the same 9-color Notion palette mapped to CSS custom properties. Hex/token mutual exclusion means setting a token clears any existing hex, and setting a hex clears any existing token (last action wins).

Not included in StarterKit. Add it separately for Notion-style color UX.

Use NotionColorPicker when you need:

  • A Notion-style bubble menu trigger for text and background colors
  • 9 named colors plus a default reset per type, with keyboard navigation and non-modal role="dialog" semantics
  • Cooperative dismissal with other overlays (dm:dismiss-overlays)

Skip it if:

  • Your color picking happens in the main toolbar with simple dropdowns
  • Your design system only allows a single accent color and per-mark coloring would be off-brand
import {
Editor, StarterKit,
TextStyle, TextColor, Highlight,
NotionColorPicker, BubbleMenu,
} from '@domternal/core';
import '@domternal/theme';
const editor = new Editor({
element: document.getElementById('editor')!,
extensions: [
StarterKit,
TextStyle, TextColor, Highlight, // textStyle mark + color attribute providers
NotionColorPicker, // adds bubble-menu trigger + colorToken/backgroundColorToken attrs
BubbleMenu.configure({ element: bubbleEl }),
],
});

The “A” trigger appears in the bubble menu when text is selected and a picker panel is listening. Framework wrappers render that panel; see the per-framework guide for examples.

  • TextStyle mark - required (extension declares dependencies: ['textStyle'])
  • TextColor + Highlight - recommended (they own the colorToken / backgroundColorToken attribute schemas on textStyle)
  • BubbleMenu - required to surface the “A” trigger (the toolbar item has toolbar: false and is bubble-menu-only)
  • A notionColorOpen listener - required for the trigger to render at all: a DomternalNotionColorPicker component, or your own listener. Loading the extension alone shows nothing, because the trigger’s only action is emitting that event.
NotionColorPicker.configure({
palette: DEFAULT_NOTION_COLOR_PALETTE,
})
OptionTypeDefaultDescription
palettereadonly string[]DEFAULT_NOTION_COLOR_PALETTENamed tokens shown in the picker. Each must have matching --dm-block-text-<token> and --dm-block-bg-<token> CSS variables in the active theme. Tokens with no theme support render as transparent swatches.
export const DEFAULT_NOTION_COLOR_PALETTE: readonly string[] = Object.freeze([
'gray',
'brown',
'orange',
'yellow',
'green',
'blue',
'purple',
'pink',
'red',
]);

These tokens align with BlockColor’s palette, so inline + block tints share the same color set.

interface NotionColorPickerStorage {
isOpen: boolean;
}

The UI flips editor.storage.notionColorPicker.isOpen as the picker opens and closes. Read this to gate other overlays or hide the bubble menu while the picker is showing.

The extension registers one toolbar item:

{
name: 'notionColor',
type: 'button',
command: 'focus', // required by the ToolbarButton schema; emitEvent wins at click time
icon: 'textAUnderline',
label: 'Text and background color',
group: 'textStyle',
priority: 250,
toolbar: false, // bubble-menu only, hidden from main toolbar
emitEvent: 'notionColorOpen',
}

Because toolbar: false, the item appears only in the bubble menu, not the main toolbar. Clicking it emits the notionColorOpen custom event on the editor, which framework wrappers listen for.

command is non-optional on the ToolbarButton type, so the item points it at the harmless focus built-in; it only runs if a host disables the popover UI. label is what a custom toolbar renders as the tooltip.

EventDirectionPayloadPurpose
notionColorOpeneditor.emit{ anchorElement?: HTMLElement | null }Trigger emits when clicked; picker components listen

Listen via editor.on('notionColorOpen', ...) or by wiring the relevant component (DomternalNotionColorPicker in each framework).

Registering a listener is also what makes the trigger appear: with no listener the bubble menu hides it, since pressing it could not do anything. Removing the last listener (editor.off('notionColorOpen')) hides it again on the next sync.

The textStyle mark can carry EITHER a hex color (color: '#ff0000') OR a named token (colorToken: 'red'), not both. Setting one clears the other inside the affected range:

ActionEffect
Set colorToken: 'blue'Clears color hex on the range
Set color: '#0000ff'Clears colorToken on the range
Set backgroundColorToken: 'yellow'Clears backgroundColor hex on the range
Set backgroundColor: '#ffff00'Clears backgroundColorToken on the range

This is implemented inside TextColor and Highlight when they handle the new attrs.

When BlockColor sets a block-level color and stripInlineColorConflicts() runs, it removes inline textStyle marks of the same kind (text or bg) inside the affected range so the block tint isn’t visually masked by older inline overrides.

The reverse also applies: applying an inline color token via NotionColorPicker on text that has a block-level color does NOT remove the block color; both layer (inline on top of block background, semantic priority).

All four wrappers ship a DomternalNotionColorPicker component that listens for notionColorOpen and renders the panel:

import { DomternalNotionColorPicker } from '@domternal/vanilla';
const picker = new DomternalNotionColorPicker({ editor: dm.editor });
// .destroy() when finished

The components handle positioning, outside-click, keyboard navigation, and applying the token via editor.commands.setTextColorToken('blue') for the text half and editor.commands.setBackgroundColorToken('yellow') for the background half. Passing null to either command resets that half to the default.

All four panels render as role="dialog" with aria-label="Text and background color" and aria-modal="false": deliberately non-modal, because the picker does not trap focus (outside click closes it and the editor stays interactive). The “A” trigger button carries aria-haspopup="dialog".

The theme stylesheet defines color values for each token:

VariablePurpose
--dm-block-text-grayText color for the gray token
--dm-block-text-brownText color for the brown token
--dm-block-text-orangeetc.
--dm-block-bg-grayBackground color for the gray token
--dm-block-bg-brownBackground color for the brown token
(9 text + 9 bg)All 9 palette names

These are defined for both light and dark themes in @domternal/theme. Override them in your app’s CSS to customize colors.

  • .dm-ncp-trigger - the “A” trigger button rendered by BubbleMenu (identify it by class rather than caching the element: in the vanilla build the trigger survives a state-only render but is recreated when the bubble menu’s item list changes, while the React, Vue and Angular components render it in its own conditional slot that an item-list change leaves in place)
  • .dm-ncp-trigger-glyph - the “A” span inside the trigger, repainted to the selection’s current text color
  • .dm-ncp-trigger-underline - the underline span inside the trigger, repainted to the selection’s current background color
  • .dm-notion-color-picker - root panel container
  • .dm-ncp-section - one section per color type (text / background)
  • .dm-ncp-label - the section heading (“Text color”, “Background color”)
  • .dm-ncp-grid - the swatch grid inside a section
  • .dm-ncp-swatch - individual swatch button
  • .dm-ncp-swatch--text / .dm-ncp-swatch--bg - variant modifiers on the swatch, styled per data-color
  • .dm-ncp-active - toggled on the swatch matching the selection’s current token
import {
NotionColorPicker,
DEFAULT_NOTION_COLOR_PALETTE,
} from '@domternal/core';
import type {
NotionColorPickerOptions,
NotionColorPickerStorage,
} from '@domternal/core';

@domternal/core - NotionColorPicker.ts

  • Text Color - inline text color via TextStyle
  • Highlight - inline background color (text highlight)
  • Block Color - block-level background and text colors