Skip to content
FrameworkJavaScript

Blok configuration options

The configuration object passed to the Blok constructor. It is formally split into two types.

  • BlokMountOptions holds the options that are fixed for the life of the instance (holder, tools, i18n, and so on).
  • BlokState holds the LIVE fields: readOnly (including hideControls), hideToolbar, toolbarPosition, inlineToolbar, and the editor callbacks onChange, onSave, onEnter, onSubmit, onBeforeRender and onAfterRender.

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.

Last updated Oct 7, 2026Edit this page on GitHub
TypeScript
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

OptionTypeDefaultDescription
holderstring | HTMLElement'blok'

Container element ID or reference

toolsRecord<string, ToolConstructable | ToolSettings>{}

Available block and inline tools. Nothing is registered by default. The {} default leaves only Blok's internal tools (stub, delete, copyLink, convertTo), so a bare new Blok({ holder }) cannot render even a paragraph.

Two ready-made bundles are exported from @bloklabs/core/full.

  • defaultTools holds paragraph, header and list, all with inlineToolbar: true.
  • allTools holds defaultTools plus quote, callout, code, toggle and every inline tool.

Setting toolbox: false on a tool keeps it registered, so existing blocks still render and blocks.insert() still works. It removes the tool from every user-insertion path: the + / slash menu, the convert menu, and its keyboard shortcut. That is useful for permission gating.

You can flip it at runtime via tools.update(name, { toolbox }). The React adapter applies changes to the tools prop's toolbox values automatically. So a permission change never requires recreating the editor.

tunesstring[]undefined

Names of block tunes added to every block tool that does not declare its own tunes set. The tune classes themselves must be registered in tools (a class with static isTune = true).

placeholderstring | falsefalse

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 false (also the default) does not remove the placeholder. The paragraph tool then falls back to its own built-in localized text ("Write something or press / to select a tool").

To blank it, give the default tool an empty placeholder of its own: tools: { paragraph: { class: Paragraph, placeholder: '' } }.

minHeightnumber300

Height in px of the editor's bottom clickable zone

captureClicksBelowEditorbooleanfalse

Opt-in: clicks on the host page below the editor append a block, with zero layout footprint. Pair it with minHeight: 0 to remove the bottom zone entirely.

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.

inlineEmojibooleantrue

Inline emoji menu. Typing : followed by a name in a text block opens it, for example :fire. Picking an emoji replaces the typed :query with the character.

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. 10:30, http:// and Note: this do not open it. At least one character must follow the colon.

Typing a closing : can insert the emoji right away. That only happens when the query is an exact shortcode. :fire: gives 🔥 without the menu ever opening. Any other query is left as typed text.

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.

false turns the menu off. An absent key keeps it on.

findboolean | FindConfigtrue

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 find: { placement: 'bottom-center', offset: { y: 24 } }. placement is top-end (the default), top-start, top-center, bottom-end, bottom-start or bottom-center. start and end follow the editor's text direction.

offset is the distance in pixels from those window edges. Use it to clear your own fixed UI, such as a header. The bar is fixed there: people using the editor cannot move it.

Matching is loose by default.

  • Case is ignored. The Aa button (Alt+C) turns on match case.
  • Accents are ignored, so cafe finds café.
  • Curly and straight quotes match each other. So do the different dashes.
  • A run of spaces matches a single space.
  • The ab button (Alt+W) keeps only whole-word matches.

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 3 of 12. A strip under the field marks where each match sits on the page. Click it to jump there.

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 preventDefault() keeps it. With several editors, it goes to the one used last. false leaves Cmd/Ctrl+F to the browser everywhere. An absent key keeps find on.

defaultBlockstring'paragraph'

Default block type

dataOutputData | LooseOutputData | nullundefined

Initial data to render. The loose wire shape is accepted: null values for block data, id, or time (common in backend DTOs) are normalized at the boundary. A whole-document null is also accepted and normalized to an empty document.

So you can pass nullable controlled state straight through, without a value ?? { blocks: [] } guard.

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 items[] structure. 'hierarchical' always uses flat blocks with parent/content references.

sanitizerSanitizerConfig{}

Editor-wide default sanitizer allowlist. Blok composes it with each tool's own sanitize rules. It applies on save, on render, on paste and on copy of selected blocks.

readOnlyboolean | { hideControls?: boolean }false

Enable read-only mode. Pass { hideControls: true } to also hide the hover toolbar, block settings, and inline toolbar. Live field: change it at runtime via readOnly.set(state, { hideControls }). The same instance flips modes in place, preserving caret, undo history and scroll.

onChange(api: API, event: BlockMutationEvent | BlockMutationEvent[]) => voidundefined

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 detail.origin says who made the change. It is 'local' for this tab, including its undo and redo, 'tab' for another tab of this browser, and 'remote' for a collaborator.

Live field: install, replace or unset it at runtime via handlers.set({ onChange }). Its presence, together with onSave, is what arms Blok's change-observation pipeline at all.

onSave(data: OutputData, api: API) => voidundefined

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 handlers.set({ onSave }). Its mere presence makes Blok serialize the document once per change batch.

With tab sync, only the tab you are working in calls it. In background tabs, read the document with editor.save().

onReady(blok?: Blok) => voidundefined

Fires once when the editor becomes ready, receiving the fully-initialized Blok instance

onEnter(event: KeyboardEvent, api: API) => boolean | voidundefined

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 handlers.set({ onEnter }).

onSubmit(data: OutputData, api: API) => voidundefined

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 handlers.set({ onSubmit }). Pass undefined to restore Blok's default Enter (split the block) without recreating the editor.

onError(error: Error, context: { source: 'save' }) => voidundefined

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 | nullundefined

Transforms the raw text/html clipboard payload before any Blok preprocessing or sanitization, so you no longer need a capture-phase paste interceptor.

  • Return the HTML to feed into the rest of the paste pipeline.
  • Return null to skip the HTML path and fall through to plain text.

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.

  • ChatGPT ships no MathML. So a formula's LaTeX is recovered from its source attribute and rebuilt as an equation.
  • Its code blocks are also de-duplicated. Each one renders as a nested editor.
  • Gemini's code language is read off the label it prints above the block.

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 blocks.render() call, and on the repaints Blok performs itself. Those repaints are a runtime i18n.update() and the read-only fallback re-render.

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 handlers.set({ onBeforeRender }).

onAfterRender(api: API) => voidundefined

Fires after each render batch lands in the DOM: the initial render, every blocks.render(), and the repaints Blok performs itself.

Those are a runtime i18n.update() locale or messages change, and a readOnly.set() toggle that falls back to a full re-render because a mounted tool does not support in-place read-only.

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 handlers.set({ onAfterRender }).

autofocusbooleanfalse

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 (#<blockId>) once blocks are rendered. That includes blocks rendered later via blocks.render(). This option only tunes that behavior: topOffset (default 0) reserves space above the block for a sticky header.

inlineToolbarstring[] | booleantrue

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 tools.setInlineToolbar(config).

hideToolbarbooleanfalse

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 toolbar.setHidden(hidden).

toolbarPosition'left' | 'right''left'

Which side of the content column the floating block controls (plus button and drag/settings handle) occupy. 'right' moves both the controls and the gutter reserved for them to the editor's inline-end side. The start gutter collapses and an equal one opens at the end, so the text reclaims the space the controls used to occupy.

The values name the LTR-physical side and are applied through logical properties, so an RTL editor mirrors them. No effect while hideToolbar is on, or in chromeless read-only, because there are no controls to place.

Live field: move them at runtime via toolbar.setPosition(position).

i18nI18nConfigundefined

Internationalization config (locale plus message dictionary). Live field: switch language at runtime via i18n.update({ locale, messages }).

The editor relabels in place, so caret and undo history survive a language switch. defaultLocale is the exception and stays mount-only.

Custom tool titles are localizable by registration name, for example a fileLink tool via messages: { 'toolNames.fileLink': '…' }, or via a titleKey in the tool's toolbox entry.

uploaderBlokUploaderundefined

Editor-level uploader for every media asset, routed by asset KIND rather than by tool. uploadByFile(file, { kind, tool }) and uploadByUrl(url, { kind, tool }) receive kind: 'image' | 'video' | 'audio' | 'file', so one implementation serves the image, video, audio and file blocks.

That includes assets a tool owns outside its own media family, such as the audio block's cover art (kind: 'image', tool: 'audio'), which has no tool-level uploader of its own.

A tool-level uploader (tools.image.config.uploader) stays authoritative for its own kind and takes precedence. This one is the fallback. Without either, assets become blob: URLs that do not survive a reload.

mediaMediaConfigundefined

Convert every uploaded photo into several formats. The editor then shows the best format each browser supports. It is off until you list formats.

  • formats.image takes 'avif', 'webp', 'jpeg' and 'png'.
  • The order you write does not matter. Blok stores and renders the formats best first.
  • The most compatible format becomes url, so readers that ignore variants still work.
  • A format the browser cannot encode is skipped. When no JPEG or PNG was made, the original file is kept as the fallback.
  • A transparent image never gets a JPEG.

Each format is a separate upload. Your uploader receives variant: { mimeType, role } in its context.

convert(file, formats, ctx) replaces the built-in converter. Return null to use Blok's own.

Videos work differently, because converting them takes time.

  • formats.video takes 'av1', 'webm' and 'mp4'.
  • The original plays at once. Each format is made in the background, most compatible first, and added when it is ready.
  • Once an MP4 exists it becomes url. Until then the original stays as url.
  • While a video converts, closing the page shows the browser's "Leave site?" dialog.
  • Videos longer than maxTranscodeDuration seconds (default 600) are only remuxed, never re-encoded.

Blok does not bundle the video converter, because it is MPL-2.0. Install mediabunny and pass mediabunny: () => import('mediabunny'). Without it, video formats come only from convert.

serverstringundefined

Base URL of a service speaking Blok's upload and unfurl contracts, such as https://blok.myapp.com, or a same-origin path like /api/blok. It is shorthand only.

It fills in uploader, the bookmark tool's endpoint and link.unfurl when you have not set them yourself, and anything you set explicitly wins. That is what lets you take the service for link previews while uploading into your own S3, with no bridging code.

It does not configure document storage. Your documents stay yours. See persistence.

ticketstringundefined

Endpoint in YOUR app that mints a short-lived access pass for the signed-in user, answering { "ticket": "<pass>" }. You need it whenever the routes check passes: a standalone service run with --auth ticket, or routes inside your own app run with Auth = "ticket". With Auth set to none or proxy, no pass is read.

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. @bloklabs/server/ticket exports blokTicket() for minting it, and a .NET backend uses BlokTicket.Create from Blok.Server. Any other backend can sign one with its own JWT library. The header must then be exactly {"alg":"HS256","typ":"JWT"}, because the server compares it byte for byte.

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 data, and setting onSave yourself wins.

load may answer with a version alongside the document. Each save is told the version it is overwriting and may report the one it wrote. Blok only carries that version between the two calls, so your endpoint stays the only place a stale write is detected.

collaboration{ doc: string; user?: { name: string; color?: string }; offline?: boolean; offlineScope?: string }undefined

Real-time multiplayer editing against the sync service server points at. Two editors opened on the same doc see each other's edits live.

doc is the shared document id, and it becomes one path segment of the sync URL. It must therefore be a single path segment: no /, no encoded slash, no . or ... Anything else is refused at construction rather than failing at the door.

user is the DISPLAY identity the other people see. It is the name on their avatar, and the color of their cursor and of the small face parked in the margin beside the block they are in. It is also what the user: { id } option, which records edit attribution, gets NAMED by.

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. user.name is not, so a host that sets only user.name names itself and nobody else.

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.

color is HEX only (#rgb, #rrggbb, with or without alpha). Anything else is replaced with a color from the built-in palette.

offline keeps a copy of the document in that browser, so edits made while disconnected survive a reload. It is off by default, because it writes document content into browser storage, and it is dropped whenever the service resets the document.

It REQUIRES offlineScope, an opaque stable id for the signed-in account. Browser storage belongs to the browser rather than to a person, so without a partition the next person on a shared profile is handed the previous one's document.

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 server, and is mutually exclusive with persistence. The sync service owns the whole document round-trip, so a second load/save pair would give the document two owners. Both pairings are refused at construction.

Absent, it costs nothing: Blok opens no socket. Mount-only: changing it means recreating the editor.

  • React and Vue take it as a collaboration prop.
  • Angular has no dedicated input, so it goes through [config].

Connection state and the people present arrive on the collaboration:status event.

documentIdstringundefined

Your app's id for this document. Tabs of one browser that show the same documentId stay in sync live, with no server.

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 isReady rejects.

Without it, Blok uses the id it writes into saved data, plus the page path. That only covers a document that persistence loads or saves.

Mount-only: to show another document, recreate the editor. The Tab sync guide covers where the id comes from.

tabSyncboolean | { settings?: boolean }true

Live sync between the tabs of one browser. It is on by default.

  • false turns it off.
  • { settings: false } keeps document sync but stops syncing locale, theme mode and width.

It never runs with collaboration, whose server already syncs every tab. Mount-only: changing it means recreating the editor.

theme'auto' | 'light' | 'dark''auto'

Color theme; 'auto' follows the OS preference via prefers-color-scheme

onThemeChange(resolvedTheme: ResolvedTheme) => voidundefined

Fires with the RESOLVED theme ('light' or 'dark') whenever it changes. That covers the OS preference flipping while theme is 'auto', and theme.set() changing what the theme resolves to. It does not fire on initialization, and it does not fire when a theme.set() leaves the resolved theme unchanged.

It is a core config option, not an adapter-only prop. The framework adapters expose the same callback as the onThemeChange prop, the theme-change emit and the themeChange output.

linkPaste{ allowGenericEmbed?: boolean; allowedEmbedOrigins?: string[]; hostAliases?: string[] }undefined

Notion-style link-paste behavior. Set allowGenericEmbed: true to also offer "Create embed" (framed in a sandboxed iframe) for URLs that match no registered embed provider. The default keeps Blok's registry-only embed guarantee.

allowedEmbedOrigins is the fine-grained middle ground: hostnames (dashboards.example.com) or wildcard subdomain patterns (*.internal.example.dev) that may be framed as generic embeds. A stored generic embed matching neither renders as a safe clickable link card instead of an iframe, so the URL stays visible without being framed.

Paste a URL while text is selected, and the selected text becomes a link to it. No menu opens.

hostAliases lists other hostnames (or *. wildcards) that count as your own site, next to the page's hostname. A pasted link to your own site never offers "Create bookmark". If no other choice is left, no menu opens.

user{ id: string; name?: string }undefined

Identity of the current editor. Blok stamps user.id onto the lastEditedBy of every block this user edits, and without it lastEditedBy stays null.

Add name and the block settings footer reads "Last edited by <name>" with no callback to wire. That name is local only, so peers name you from collaboration.user.name. Set both to be named everywhere.

For other people's ids Blok asks resolveUser, and in a collaborative room it also learns names from the peers themselves.

The id is not private under collaboration: it goes into the shared document as lastEditedBy, and, only when collaboration.user.name is set too, into the presence state next to that name. That is how peers name each other. Pass an opaque per-document token instead of an internal account key if that matters.

resolveUser(id: string) => UserInfo | Promise<UserInfo | null> | nullundefined

Resolves the lastEditedBy user id Blok shows in the block settings footer. It may return synchronously or asynchronously. Return null for an unknown user, and Blok falls back to the name that user published to the collaborative room, or to the date alone.

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.

notifierPositionNotifierPosition'bottom-center'

Where the built-in toast container is anchored on screen.

notifier(options: NotifierOptions | ConfirmNotifierOptions | PromptNotifierOptions) => voidundefined

Replaces the built-in toast entirely. Blok calls your handler with the same options object instead of rendering its own DOM notification.

logLevelLogLevelsLogLevels.VERBOSE

How much Blok logs to the console. The values are VERBOSE, INFO, WARN and ERROR. There is no "silent" level, so LogLevels.ERROR is the quietest. LogLevels is a named export of the package root.