noteloom

Getting started

noteloom is a React-first, block-based rich text editor with zero runtime dependencies — the only things it expects from your app are react and react-dom. Undo/redo, clipboard, slash commands, tables, inline widgets, offline persistence, and real-time collaboration are all built from scratch on top of a small, normalized document store — nothing here is a wrapper around another editor library.

This guide comes in two parts, the same split as every reference page after it:

  • SimpleuseEditor() + <NoteloomEditor />, two calls to a fully wired editor. Everything from here down through exporting/theming is built on this.
  • Advanced — the granular pieces (EditorStore, EditorProvider, individual hooks/components) that useEditor()/NoteloomEditor are themselves built from, for when you need a custom toolbar, a hand-rolled surface element, or mobile chrome/voice typing mounted separately. See Advanced: the granular API below — the two are never an either/or choice, since useEditor() still hands you the raw { store, registry, inlineRegistry } to drop into the granular API at any time.

Install

bash
npm install noteloom react react-dom

Quick start

useEditor() creates a fully wired store (undo/redo included) and both registries pre-populated with every built-in block and inline type. <NoteloomEditor> renders it with clipboard, slash/emoji/@-mention menus, the floating format toolbar, keyboard shortcuts, and block-range drag already hooked up:

jsx
import { useEditor, NoteloomEditor } from 'noteloom';

function Editor() {
  const editor = useEditor();
  return <NoteloomEditor editor={editor} />;
}

That's the whole thing. No CSS to import either — see Styling below. Try it live in the Playground.

Using Next.js, Vue, or Angular? Read Framework recipes for the copy-paste integration path. React and Next.js are native; Vue and Angular work best by mounting noteloom as a small React island/custom element.

Pass a starting document, or turn off undo/redo

jsx
const editor = useEditor({
  doc: myDocumentJSON, // defaults to one empty paragraph
  history: true,       // default; false gives a plain store with no undo/redo
});

Returns { store, registry, inlineRegistry } — memoized once, on first render. Pass a different doc and change key on the consuming component to load a different document, rather than expecting doc to re-apply on every render. See Configuration for every useEditor() option and <NoteloomEditor> prop in one reference table.

Anything this doesn't cover — a custom toolbar, mobile chrome (MobileActionBar), voice typing, field-type management — is still just EditorProvider plus the granular hooks/components underneath, unchanged and fully available; see Advanced below.

Styling — zero setup required

You don't need to import any CSS. The moment <NoteloomEditor> mounts, it injects a single <style> tag with a minimal, clean default theme — no import 'noteloom/style.css' line, no build-tool CSS configuration, nothing to wire up. It's idempotent (mounting more than one editor on a page only injects it once) and client-only (a no-op under SSR; hydrate as normal and it injects on mount).

Retheme it by overriding the CSS custom properties it reads from — defined on :root (not scoped to a wrapper element, since portaled pieces like the slash menu and Select's popover aren't DOM descendants of the editor itself):

css
:root {
  --noteloom-accent: #16a34a;      /* swap the indigo accent for green */
  --noteloom-radius-md: 4px;       /* sharper corners */
  --noteloom-font: 'Inter', sans-serif;
}

Dark mode follows prefers-color-scheme automatically; to control it explicitly instead (e.g. a manual light/dark toggle), set data-theme="dark" or data-theme="light" on any ancestor (typically <html>) — see the full variable list in Theming & styling.

Opt out entirely with theme="none" — nothing gets injected at runtime, and you take full responsibility for loading CSS yourself. The same theme is still published standalone at noteloom/style.css for this case:

jsx
import 'noteloom/style.css'; // optional — control load order yourself

<NoteloomEditor editor={editor} theme="none" />

This site's own editor (the Playground and Notes pages) takes exactly this route — a top-level @import 'noteloom/style.css' in its global stylesheet, paired with theme="none" so the runtime injection doesn't redundantly duplicate it.

Picking only the blocks you want

useEditor() registers every built-in block/inline type by default — the fastest way to a fully-featured editor. If you'd rather ship only what you use (the same idea as TipTap's extensions: [...]), every built-in block/inline type is also exported individually, and registerBlocks/registerInlineTypes register just the ones you name, via useEditor()'s own registerBlocks/registerInlineTypes options:

jsx
import { useEditor, NoteloomEditor, registerBlocks, paragraphBlockType, headingBlockType, TABLE_BLOCKS } from 'noteloom';

function Editor() {
  const editor = useEditor({
    registerBlocks: (registry) =>
      registerBlocks(registry, { paragraph: paragraphBlockType, heading: headingBlockType, ...TABLE_BLOCKS }),
  });
  return <NoteloomEditor editor={editor} />;
}

registerBuiltInBlocks(registry) (what useEditor() calls by default) is itself just registerBlocks(registry, { paragraph: paragraphBlockType, ... }) with every type included — so mixing "give me everything" and "just these few" across different parts of your app is never an either/or choice. table/layout each need their own group of related types registered together — see TABLE_BLOCKS/LAYOUT_BLOCKS in the API reference. TABLE_SELECT_INLINE_TYPES (inline side) is only needed if you use a table's "select" column type.

To keep every built-in type and add your own on top, call registerBuiltInBlocks yourself inside the callback:

jsx
import { useEditor, NoteloomEditor, registerBuiltInBlocks } from 'noteloom';

function Editor() {
  const editor = useEditor({
    registerBlocks: (registry) => {
      registerBuiltInBlocks(registry); // keep everything built-in...
      registry.register('myCustomType', myBlockTypeEntry); // ...plus your own
    },
  });
  return <NoteloomEditor editor={editor} />;
}

See Registering custom blocks for writing myBlockTypeEntry from scratch.

Next: Architecture for how the document model fits together, the block reference, or jump straight to Offline persistence and Live collaboration if that's what brought you here.

Advanced: the granular API

Everything above is useEditor()/<NoteloomEditor> — a convenience layer over the pieces below, nothing hidden behind them. Reach for this section when you need more control: a custom toolbar, mobile chrome (MobileActionBar) mounted separately, or a hand-rolled surface element.examples/basic in the package repo builds a complete editor this way, wiring up everything the simple guide covers individually (mobile chrome, voice typing, export, field-type management, …) from these same granular pieces.

jsx
import {
  EditorStore,
  History,
  EditorProvider,
  BlockChildren,
  createBlockRegistry,
  registerBuiltInBlocks,
  createInlineRegistry,
  registerBuiltInInlineTypes,
  useClipboardHandlers,
  useSlashMenuTrigger,
  useEditorKeyboardShortcuts,
  SlashMenu,
} from 'noteloom';
import { useMemo, useRef } from 'react';

function Editor() {
  const containerRef = useRef(null);
  const { store, registry, inlineRegistry } = useMemo(() => {
    const registry = createBlockRegistry();
    registerBuiltInBlocks(registry);
    const inlineRegistry = createInlineRegistry();
    registerBuiltInInlineTypes(inlineRegistry);
    const store = new History(
      new EditorStore({
        rootId: 'root',
        blocks: [
          { id: 'root', type: 'page', parentId: null, contentIds: ['p1'], props: {} },
          { id: 'p1', type: 'paragraph', parentId: 'root', contentIds: ['r1'], props: {} },
        ],
        runs: [{ id: 'r1', type: 'text', value: 'Hello — try typing "/" for commands.', marks: {} }],
      }),
    );
    return { store, registry, inlineRegistry };
  }, []);

  const { onCopy, onCut, onPaste } = useClipboardHandlers();
  const slashMenu = useSlashMenuTrigger(containerRef);
  useEditorKeyboardShortcuts(containerRef);

  return (
    <EditorProvider store={store} registry={registry} inlineRegistry={inlineRegistry} history={store}>
      <div ref={containerRef} onCopy={onCopy} onCut={onCut} onPaste={onPaste}>
        <BlockChildren parentId="root" />
        <SlashMenu
          isOpen={slashMenu.isOpen}
          rect={slashMenu.rect}
          commands={slashMenu.commands}
          runId={slashMenu.runId}
          onSelect={slashMenu.selectCommand}
          onClose={slashMenu.close}
        />
      </div>
    </EditorProvider>
  );
}

registry/inlineRegistry here work identically to useEditor()'s — pass the same registerBlocks-style callback pattern, or call registerBuiltInBlocks/registerBlocks directly as shown. useClipboardHandlers wires copy/cut/paste, useSlashMenuTrigger watches for "/" and returns everything <SlashMenu /> needs to render, and useEditorKeyboardShortcuts wires Ctrl/Cmd+B/I/U, arrow-key navigation, Enter/Backspace/Tab conventions, and Ctrl/Cmd+Z/Y. None of this is optional wiring hidden behind a single component — every piece is a normal hook or component you compose yourself, so you can leave any of it out.

See Mobile & voice typing for why MobileActionBar specifically requires this granular path (it needs direct access to your editor surface's DOM node, which <NoteloomEditor> doesn't expose), and Registering custom blocks for writing an entirely new block/inline type component from scratch.