Blok configuration options
The configuration object passed to the Blok constructor. It is formally split into two types.
BlokMountOptionsholds the options that are fixed for the life of the instance (holder, tools, i18n, and so on).BlokStateholds the LIVE fields:readOnly(includinghideControls),hideToolbar,toolbarPosition,inlineToolbar, and the editor callbacksonChange,onSave,onEnter,onSubmit,onBeforeRenderandonAfterRender.
Every BlokState field maps to a documented runtime setter (readOnly.set, toolbar.setHidden, toolbar.setPosition, tools.setInlineToolbar, handlers.set). Changing one never requires recreating the editor. The React, Vue and Angular adapters react to these props and inputs in place.
Callback PRESENCE is itself load-bearing: an onSubmit turns Enter into serialize-and-submit, and an onSave arms the change pipeline. That is why handlers.set also accepts undefined, to unset one.
BlokConfig = BlokMountOptions & BlokState, so existing code compiles unchanged.
import { Blok, type BlokConfig } from '@bloklabs/core';
const config: BlokConfig = {
holder: 'editor',
placeholder: 'Start writing...',
autofocus: true,
readOnly: false,
minHeight: 300,
};
const editor = new Blok(config);Configuration
| Option | Type | Default | Description |
|---|---|---|---|
holder | string | HTMLElement | 'blok' | Container element ID or reference |
tools | Record<string, ToolConstructable | ToolSettings> | {} | Available block and inline tools. Nothing is registered by default. The Two ready-made bundles are exported from
Setting You can flip it at runtime via |
tunes | string[] | undefined | Names of block tunes added to every block tool that does not declare its own |
placeholder | string | false | false | Placeholder text handed to every block of the default tool. It reaches every block, not only the first, and not only while the document is empty. With the built-in paragraph it is visible whenever a block is empty and focused. Note that To blank it, give the default tool an empty placeholder of its own: |
minHeight | number | 300 | Height in px of the editor's bottom clickable zone |
captureClicksBelowEditor | boolean | false | Opt-in: clicks on the host page below the editor append a block, with zero layout footprint. Pair it with Only clicks landing on the empty background of an element that contains the editor count. Clicks on your own content rendered below are ignored. Propagation is never stopped, so host click handlers keep working alongside. |
inlineEmoji | boolean | true | Inline emoji menu. Typing The colon opens the menu only at the start of a word. That means at the start of the block, or right after a space. Typing a closing Escape closes the menu. It leaves the typed text unchanged. The page does not scroll while the menu is open. The menu's own grid still scrolls. The menu has no search field, no random button, and no remove button. It also has no curated section. Those stay on the Callout tool's own emoji picker, which this option does not affect.
|
find | boolean | FindConfig | true | Find in page. Cmd/Ctrl+F inside the editor opens Blok's own find bar instead of the browser's. The bar opens at the top right of the window, where browsers put their own find bar, and stays above everything on the page. If you select short, single-line text in the editor or a host field, such as a page title, the bar starts searching for it. Pass an object to choose where the bar sits, for example
Matching is loose by default.
A match can run across bold, italic and links. It never spans two blocks or two table cells. Search covers text across the host page and all Blok editors, including read-only content, code blocks, table cells and the children of collapsed toggles. Enter and Shift+Enter move to the next and previous match, and so do the Down and Up arrows in the search field. Cmd/Ctrl+G and F3 do the same, and Shift makes them go back. A counter shows your place, such as Typing never opens a collapsed toggle, but the matches inside it still count. Moving to one of them opens its toggle. That goes for the keys, the arrow buttons and the match map. In an editable editor this is a normal change, the same as clicking the toggle's arrow. It is saved and can be undone. Escape closes the bar and selects the current match only when it is editable text in this editor. Typing then replaces it. Pressing Cmd/Ctrl+F again while the bar has focus selects the search text. It does not open the browser's find. Open replace with the chevron button, with Cmd+Option+F on macOS, or with Ctrl+H elsewhere. In the replace field, Enter replaces the current match if it is in this editor, then moves to the next one. Cmd/Ctrl+Enter replaces every editable match in this editor as a single undo step. Text elsewhere on the page is never replaced. Replace is not available in read-only mode. While you type a replacement, editable text in this editor shows the result before you replace. Each match is struck out, with the replacement right after it, and lines wrap the way they will. The preview is not saved, is not part of undo, and cannot be clicked. Matches are painted with the CSS Custom Highlight API. No markup is added to your content, so finding never changes the document. In a browser without that API the matches get no fill. The ring around the current match still shows, and stepping still works. The colors are theme tokens, listed in the Styles API section. Cmd/Ctrl+F in your own page's fields, such as a page title or a search box, opens Blok's find too, and so does Cmd/Ctrl+F with nothing focused. A field that handles Cmd/Ctrl+F itself and calls |
defaultBlock | string | 'paragraph' | Default block type |
data | OutputData | LooseOutputData | null | undefined | Initial data to render. The loose wire shape is accepted: So you can pass nullable controlled state straight through, without a |
dataModel | 'legacy' | 'hierarchical' | 'auto' | 'auto' | Input/output data model. 'auto' detects the format of the data you render and preserves it on save. 'legacy' always uses the nested |
sanitizer | SanitizerConfig | {} | Editor-wide default sanitizer allowlist. Blok composes it with each tool's own |
readOnly | boolean | { hideControls?: boolean } | false | Enable read-only mode. Pass |
onChange | (api: API, event: BlockMutationEvent | BlockMutationEvent[]) => void | undefined | Change callback function. The event argument carries the mutation that occurred, or an array of them when several fire at once. Latency is bounded, so it is safe to drive UI from. The first change of an idle document arrives on the next microtask, in the same frame the user typed in. The changes after it are coalesced into one further call at the end of a short batch window, and later changes never extend that window. Each event's Live field: install, replace or unset it at runtime via |
onSave | (data: OutputData, api: API) => void | undefined | Reactive save callback. It fires automatically with the full serialized content on every batched content change, so you do not have to call save() by hand. It rides the trailing edge of the batch window only. Unlike onChange it never leads the window, because serializing the whole document is too expensive to front-run the batch with. Live field: install, replace or unset it at runtime via With tab sync, only the tab you are working in calls it. In background tabs, read the document with |
onReady | (blok?: Blok) => void | undefined | Fires once when the editor becomes ready, receiving the fully-initialized Blok instance |
onEnter | (event: KeyboardEvent, api: API) => boolean | void | undefined | Fires when Enter is pressed in a block, before Blok splits it or creates a new one. Return true to mark it handled, and Blok suppresses its default block split or create (the native newline is still prevented). It never fires for tools with enableLineBreaks, or while a popover or toolbar owns Enter, or for a soft-line-break Shift+Enter. The one exception is iOS: Safari reports Shift+Enter for a sentence-ending '. ', and Blok creates a block, so the hook fires there too. It suits chat inputs ("Enter sends"). Pair it with the paragraph tool's preserveBlank config instead of subclassing Paragraph. Live field: install, replace or unset it at runtime via |
onSubmit | (data: OutputData, api: API) => void | undefined | Fires with the full serialized OutputData on the Enter that would otherwise create or split a block, the "Enter sends" gesture. Blok serializes the document and suppresses the default split, so you do not wire save() into onEnter by hand. It inherits every onEnter escape. When both are set, an onEnter that returns true takes precedence and suppresses onSubmit. Live field: install, replace or unset it at runtime via |
onError | (error: Error, context: { source: 'save' }) => void | undefined | Fires when an editor operation fails that Blok would otherwise only log. Today the sole source is serialization. Both the debounced auto-save and an explicit save() route through it. A failed save() rejects with the underlying error and never resolves with undefined, so wrap explicit saves in try/catch. onError additionally surfaces failures of the debounced auto-save, which has no promise of its own. |
onBeforePaste | (html: string) => string | null | undefined | Transforms the raw
Everything below runs after your hook. Blok normalizes what other apps put on the clipboard. Copy an answer out of ChatGPT, Claude or Gemini, or a page out of Notion or Google Docs. It arrives as real blocks, not one flat paragraph. That means headings, lists, tables, quotes and code. ChatGPT and Gemini get a dedicated pre-pass on top, because each hides meaning in markup the sanitizer would otherwise drop.
Claude has no pre-pass of its own: its answers are already semantic HTML, so they come through the standard HTML and markdown paths. |
onBeforeRender | (blocks: OutputBlockData[]) => OutputBlockData[] | undefined | Transforms the blocks array just before it is rendered. It runs on the initial render, on every It receives the raw saved blocks, before format analysis or hierarchical expansion, and returns the blocks to render. That way app-specific data migrations run inside Blok instead of ahead of it. It must therefore be idempotent: those repaints feed it blocks it has already transformed. Live field: install, replace or unset it at runtime via |
onAfterRender | (api: API) => void | undefined | Fires after each render batch lands in the DOM: the initial render, every Those are a runtime Use it for post-render side effects such as scroll restoration or attaching observers. Keep those extra triggers in mind if you count renders. Distinct from onReady, which fires once when the editor first becomes ready. Live field: install, replace or unset it at runtime via |
autofocus | boolean | false | If true, sets the caret in the first block once the editor is ready |
scrollToBlock | { topOffset?: number } | undefined | Blok always smooth-scrolls to the block whose id matches the page URL hash ( |
inlineToolbar | string[] | boolean | true | Default inline toolbar for all tools. An array restricts it to the listed inline tools, and false disables it. The array picks which tools appear, never where. The toolbar always renders the built-ins in one fixed order (convert, color, bold, italic, underline, clear formatting, link, strikethrough, inline code, equation, sup/sub). Any custom inline tool is appended after them, in registration order. Live field: reconfigure it at runtime via |
hideToolbar | boolean | false | Hide the hover block toolbar (plus button / drag handle) and collapse the editor gutter reserved for it. The keyboard "/" menu keeps working. Live field: flip it at runtime via |
toolbarPosition | 'left' | 'right' | 'left' | Which side of the content column the floating block controls (plus button and drag/settings handle) occupy. The values name the LTR-physical side and are applied through logical properties, so an RTL editor mirrors them. No effect while Live field: move them at runtime via |
i18n | I18nConfig | undefined | Internationalization config (locale plus message dictionary). Live field: switch language at runtime via The editor relabels in place, so caret and undo history survive a language switch. Custom tool titles are localizable by registration name, for example a |
uploader | BlokUploader | undefined | Editor-level uploader for every media asset, routed by asset KIND rather than by tool. That includes assets a tool owns outside its own media family, such as the audio block's cover art ( A tool-level uploader ( |
media | MediaConfig | undefined | Convert every uploaded photo into several formats. The editor then shows the best format each browser supports. It is off until you list formats.
Each format is a separate upload. Your uploader receives
Videos work differently, because converting them takes time.
Blok does not bundle the video converter, because it is MPL-2.0. Install |
server | string | undefined | Base URL of a service speaking Blok's upload and unfurl contracts, such as It fills in It does not configure document storage. Your documents stay yours. See |
ticket | string | undefined | Endpoint in YOUR app that mints a short-lived access pass for the signed-in user, answering The editor caches the pass and replaces it ahead of expiry rather than at it, so no request arrives already invalid. Uploads and link previews share the same pass. |
persistence | { load(): Promise<OutputData | PersistedDocument | null>; save(data: OutputData, ctx: SaveContext): Promise<SaveResult | void>; onError?(error: unknown): void } | undefined | Load the document on mount and save it as it changes, against your own endpoint. The Blok service stores no documents. It takes two callbacks rather than a URL, because the endpoint shape, its auth and the document id are yours. Saves never run in parallel, and only the newest pending document follows the one in flight. So a slow save finishing after a fast one cannot bring stale content back. Loading only happens when you passed no
|
collaboration | { doc: string; user?: { name: string; color?: string }; offline?: boolean; offlineScope?: string } | undefined | Real-time multiplayer editing against the sync service
Set both and peers can read "Last edited by <name>" on the blocks you edited, because the id rides into the presence state alongside that name. The name is published to the room. Everyone in the room also sees when you were last active, alongside where your cursor is. Set neither and you are present as an anonymous avatar with no id published at all.
It REQUIRES It is never an authorization claim, because the server never sees it, and it is never the display identity. Do not derive it from anything that rotates: a new partition on every refresh strands every copy before it. It requires Absent, it costs nothing: Blok opens no socket. Mount-only: changing it means recreating the editor.
Connection state and the people present arrive on the |
documentId | string | undefined | Your app's id for this document. Tabs of one browser that show the same Use the id your app already loads and saves the document by, such as a route param or a record key. It must be unique across the whole site. An empty string is refused, and Without it, Blok uses the Mount-only: to show another document, recreate the editor. The Tab sync guide covers where the id comes from. |
tabSync | boolean | { settings?: boolean } | true | Live sync between the tabs of one browser. It is on by default.
It never runs with |
theme | 'auto' | 'light' | 'dark' | 'auto' | Color theme; 'auto' follows the OS preference via prefers-color-scheme |
onThemeChange | (resolvedTheme: ResolvedTheme) => void | undefined | Fires with the RESOLVED theme ('light' or 'dark') whenever it changes. That covers the OS preference flipping while It is a core config option, not an adapter-only prop. The framework adapters expose the same callback as the |
link | { target?: string; rel?: string; transformHref?: (href: string) => string; transform?: (context: LinkTransformContext) => LinkTransformResult | void; unfurl?: { endpoint: string; headers?: Record<string, string> | (() => Promise<Record<string, string>>) } } | undefined | Controls the anchors Blok creates, instead of post-processing the rendered DOM. It applies on every path that produces an
Both must be idempotent: on the render and paste paths they re-run against already-transformed anchors on every render.
|
linkPaste | { allowGenericEmbed?: boolean; allowedEmbedOrigins?: string[]; hostAliases?: string[] } | undefined | Notion-style link-paste behavior. Set
Paste a URL while text is selected, and the selected text becomes a link to it. No menu opens.
|
user | { id: string; name?: string } | undefined | Identity of the current editor. Blok stamps Add For other people's ids Blok asks The id is not private under collaboration: it goes into the shared document as |
resolveUser | (id: string) => UserInfo | Promise<UserInfo | null> | null | undefined | Resolves the Blok asks at most once per id: it keeps both an answer and an "I don't know this one" for the editor's lifetime. A callback that throws or rejects is absorbed rather than breaking the menu, and because that is not an answer, the id is asked about again later. Wiring this callback also means an unauthenticated name from the room is never shown while the lookup is still running. The id arrives trimmed and stripped of NUL. One longer than 128 characters is refused rather than passed on. |
notifierPosition | NotifierPosition | 'bottom-center' | Where the built-in toast container is anchored on screen. |
notifier | (options: NotifierOptions | ConfirmNotifierOptions | PromptNotifierOptions) => void | undefined | Replaces the built-in toast entirely. Blok calls your handler with the same options object instead of rendering its own DOM notification. |
logLevel | LogLevels | LogLevels.VERBOSE | How much Blok logs to the console. The values are |