Skip to content
FrameworkJavaScript

How to create a custom block tool in Blok

Build a block tool from scratch — a callout box that renders, edits, and saves like any built-in block.

Last updated Jun 30, 2026Edit this page on GitHub

Every built-in block is just a tool that follows the BlockTool interface, and you can add your own. Here you'll build a Callout block: an editable box that saves its text and reloads it like any other block.

1

Write the tool class

A block tool needs three things: a static toolbox getter so it shows up in the / menu, a render() that returns the block's element, and a save() that returns the data to persist.

TypeScript
// callout-tool.ts
export class CalloutTool {
  private data: { text: string };

  // Shows the tool in the "/" menu.
  static get toolbox() {
    return { title: 'Callout', icon: '💡' };
  }

  constructor({ data }: { data: { text?: string } }) {
    this.data = { text: data.text ?? '' };
  }

  // Return the element Blok mounts for this block.
  render() {
    const box = document.createElement('div');
    box.classList.add('callout');
    box.contentEditable = 'true';
    box.textContent = this.data.text;
    return box;
  }

  // Return the data Blok stores when the editor is saved.
  save(block: HTMLElement) {
    return { text: block.textContent ?? '' };
  }
}
2

Register it in the editor

Pass your class under the tools config. The key you give it becomes the block's type in saved data.

TypeScript
import { Blok } from '@bloklabs/core';
import { Paragraph } from '@bloklabs/core/tools';
import { CalloutTool } from './callout-tool';

const editor = new Blok({
  holder: 'editor',
  tools: {
    paragraph: Paragraph, // the default block every empty editor starts with
    callout: CalloutTool, // the key becomes the block's `type`
  },
});
3

Use it and save

Open the / menu, pick Callout, type into the box, and call save(). Your block appears in the output alongside the built-in ones.

TypeScript
const data = await editor.save();

// Your block round-trips exactly like a built-in one:
// {
//   id: 'x9k2f1',
//   type: 'callout',
//   data: { text: 'Heads up — this is a callout.' },
// }

Going further

Add a validate() method to drop empty blocks, a settings menu through renderSettings(), or paste handling with onPaste. The BlockTool interface covers the full lifecycle — rendered(), updated(), moved(), and removed().

TypeScript
// callout-tool.ts (extended)
export class CalloutTool {
  // ...constructor, render() unchanged

  save(block: HTMLElement) {
    return { text: block.textContent ?? '' };
  }

  // Drop empty callouts when the editor saves.
  validate(savedData: { text: string }) {
    return savedData.text.trim().length > 0;
  }
}

// text-color-tune.ts — a block tune adds a control to the settings menu.
export class TextColorTune {
  static isTune = true;

  render() {
    return { title: 'Text color', icon: '🎨', onActivate: () => {/* recolor */} };
  }
}
TypeScript
import { Blok } from '@bloklabs/core';
import { CalloutTool } from './callout-tool';
import { TextColorTune } from './text-color-tune';

const editor = new Blok({
  holder: 'editor',
  tools: {
    callout: { class: CalloutTool, tunes: ['textColor'] },
    // Register the tune as a tool so a block can list it by name.
    textColor: TextColorTune,
  },
});

validate() runs before save() resolves, so an empty callout never makes it into the output. Tunes are opt-in per tool — register the tune class as a tool (a class with static isTune = true), then list its name in tunes: [...] and Blok adds it to the block's settings menu.

For the complete tool contract, see the Tools API and check BlockData for the exact shape your `save()` feeds into.