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:
- Simple —
useEditor()+<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) thatuseEditor()/NoteloomEditorare 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, sinceuseEditor()still hands you the raw{ store, registry, inlineRegistry }to drop into the granular API at any time.
Install
npm install noteloom react react-domQuick 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:
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.
Pass a starting document, or turn off undo/redo
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):
: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:
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:
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:
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.
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.
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.
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.