tiptap-rs
✍️ Type-safe Wasm bindings for the Tiptap headless rich text editor.
tiptap-rs mirrors Tiptap's original JavaScript API as faithfully as possible, so a transition from JS feels like a transliteration rather than a rewrite — whilst handing you an idiomatic, type-safe Rust experience on top.
Disclaimer: this project has no affiliation with the official Tiptap project or trademark.
🖤 Features
editor.chain().focus().toggle_bold().run()— the chained-command API is mirrored 1:1 from Tiptap's JS, so muscle memory carries over and there's nothing new to learnType-safe
EditorOptions— configure the editor with a plain Rust struct instead of poking at an untyped JS object and hoping for the bestStarterKitin, batteries included — the same common-extensions bundle you'd reach for in JS, one enum variant awayCustomExtension(...)— bring your own — anything Tiptap can load, you can hand straight through; you're not boxed into what we happened to wrapHeadings as one method per level —
toggle_h1()throughtoggle_h6(), because.toggleHeading({ level })doesn't translate cleanly and we'd rather it read like Rust
Table of Contents
⸻
📦 Installation
cargo
Add the crate to your Cargo.toml:
[dependencies]
tiptap-rs = "0.1"Or let cargo do it for you:
cargo add tiptap-rsSetup
tiptap-rs binds to Tiptap running in the browser, so Tiptap itself has to be on the page. Add the following to your HTML <head> to make it available to your Wasm module:
<script type="module">
import * as Tiptap from "https://esm.sh/@tiptap/core";
import StarterKit from "https://esm.sh/@tiptap/starter-kit";
window.Tiptap = Tiptap;
window.StarterKit = StarterKit;
</script>The bindings resolve Tiptap and any extension (like StarterKit) off the global object by name — so whatever you want to use from Rust, expose it on window here first.
⸻
🧪 Usage
Create an editor
use tiptap_rs::prelude::*;
use gloo::utils::document;
let element = document
.query_selector(".editor")
.unwrap()
.unwrap();
let options = EditorOptions {
element,
content: "<p>Hello from Rust!</p>".to_string(),
extensions: vec![StarterKit],
};
let editor = Editor::new(options);Chained commands
Tiptap's original API is faithfully mirrored, so the chained commands read just like they do in JS:
// Toggle bold formatting
editor.chain().focus().toggle_bold().run();
// Toggle italic formatting
editor.chain().focus().toggle_italic().run();
// Toggle a paragraph block
editor.chain().focus().toggle_paragraph().run();Toggling headings
Headings are the one place we deviate from the JS API on purpose: instead of .toggleHeading({ level: 1 }) you get one method per level, so it stays type-safe and reads like Rust:
editor.chain().focus().toggle_h1().run();
editor.chain().focus().toggle_h2().run();
// ... through toggle_h6()Custom extensions
StarterKit covers the common cases, but you're not limited to it. Expose any Tiptap extension on the window in your setup script:
<script type="module">
import Highlight from "https://esm.sh/@tiptap/extension-highlight";
window.Highlight = Highlight;
</script>then hand it through with CustomExtension, which resolves it off the global object by name:
use tiptap_rs::prelude::*;
use wasm_bindgen::JsValue;
use web_sys::js_sys::{global, Reflect};
let highlight = Reflect::get(&global(), &JsValue::from_str("Highlight")).unwrap();
let options = EditorOptions {
element,
content: "<p>Hello from Rust!</p>".to_string(),
extensions: vec![StarterKit, CustomExtension(highlight)],
};
let editor = Editor::new(options);Handling button events
Connect editor commands to UI buttons:
use wasm_bindgen::prelude::*;
let editor_clone = editor.clone();
let callback = Closure::wrap(Box::new(move |_| {
editor_clone.chain().focus().toggle_bold().run();
}) as Box<dyn FnMut(_)>);
let bold_button = document.query_selector(".bold-button").unwrap().unwrap();
bold_button
.add_event_listener_with_callback("click", callback.as_ref().unchecked_ref())
.unwrap();
callback.forget();⸻
📐 API Reference
Editor
The main editor instance, wrapping Tiptap's Editor class.
Editor::new(options: EditorOptions) -> EditorCreates a new editor instance with the specified options. Editor is Clone, so you can hand copies into event callbacks.
EditorOptions
Configuration struct for instantiating an editor:
pub struct EditorOptions {
pub element: Element,
pub content: String,
pub extensions: Vec<Extension>,
}Extension
The extension set passed to an editor:
pub enum Extension {
StarterKit,
CustomExtension(JsValue),
}StarterKit— Tiptap's bundle of common extensions, resolved off the global object by name.CustomExtension(JsValue)— any other Tiptap extension you've exposed onwindow(see Custom extensions).
Both variants are re-exported from the prelude, so StarterKit and CustomExtension(...) are in scope directly.
Chained commands
Currently supported on ChainedCommands:
toggle_bold()toggle_italic()toggle_strike()toggle_paragraph()toggle_bullet_list()toggle_ordered_list()toggle_h1()throughtoggle_h6()focus()run()— execute the command chain (returnsbool)
Start a chain with editor.chain().
⸻
🚀 Examples
Run the bundled example:
cargo make serveThis requires cargo-make; it pulls in trunk on first run to build and serve the Wasm.
The example demonstrates:
Basic editor setup
Formatting commands wired to UI buttons
Extension usage with
StarterKit
⸻
🛠️ Requirements
A
wasm32-unknown-unknowntarget —tiptap-rsonly makes sense compiled to Wasm and run in a browser. Add it withrustup target add wasm32-unknown-unknown.Tiptap on the page — the matching
@tiptap/*modules exposed onwindow(see Setup); the bindings call into them at runtime.A bundler that loads it all — anything that ships your Wasm alongside the HTML works; the example uses trunk.
⸻
📁 Repo & Contributions
📦 Crate: https://crates.io/crates/tiptap-rs 🛠️ Repo: https://github.com/dsplce-co/tiptap-rs
PRs welcome 🖤
⸻
📄 License
MIT or Apache-2.0, at your option.