Svedit (think Svelte Edit) is a tiny library for building editable websites in Svelte. You can model your content in JSON, render it with custom Svelte components, and (this is the kicker) site owners can edit their site directly in the layout — no CMS needed.
Try the demo.
Because Svelte‘s reactivity system is the perfect fit for building super-lightweight content editing experiences. In fact, they're so lightweight, your content is your editor — no context switching between a backend and the live site. Svedit just gives you the gluing pieces around defining a custom document model and mapping DOM selections to the internal model and vice versa.
Clone the bare-bones hello-svedit repository:
git clone https://github.com/michael/hello-svedit
cd hello-sveditInstall dependencies:
npm installAnd run the development server:
npm run devNow make it your own. The next thing you probably want to do is define your own node types, add a Toolbar, and render custom Overlays. For that just get inspired by the Svedit demo code.
Simplicity over completeness: Svedit doesn't guess what your app needs or offer ready-made blocks. Instead, we keep the core lean and provide carefully crafted examples showing how to build anything on top — without compromising flexibility.
White-box library: We expose the internals of the library to allow you to customize and extend it to your needs. That means a little bit more work upfront, but in return lets you control "everything" — the toolbar, the overlays, or how fast the node cursor blinks.
Chromeless canvas: Svedit keeps the editing canvas chromeless, meaning there are no UI elements like toolbars or menus mingled with the content. You can interact with text directly, but everything else happens via tools shown in separate overlays or in the fixed toolbar.
Svedit connects eight key pieces:
- Schema - Define your content structure (node types, properties, annotations)
- Document - An actual document, containing a
document_idand a flat map of nodes that hold the content - Session - Manages the document, selection state, and history
- Transaction - Groups multiple document operations (create, delete, set) into a single atomic unit with undo/redo support
- Transforms - Higher-level composable functions that run inside a transaction (e.g.,
break_text_node,join_text_node) to modify the document. - Config - Maps node types to components, provides inserters and commands
- Components - Render your content using Svelte (one component per node type)
- Commands - User actions (bold text, insert node, undo/redo) that modify the session
The flow:
- Define a schema → create a session → provide config → render with
<Svedit>component - User interactions trigger commands → commands create transactions → which run transforms to modify the document → session applies the transaction → Svelte's reactivity updates the UI
- Selection state syncs bidirectionally with the DOM
You can use a simple JSON-compatible schema definition language to enforce constraints on your documents. E.g. to make sure a page node always has a property body with references to nodes that are allowed within a page.
First off, everything is a node. The page is a node, and so is a paragraph, a list, a list item, a nav and a nav item.
Each node has a kind that determines its behavior:
document: A top-level node accessible via a route (e.g. a page, event)block: A structured node that contains other nodes or propertiestext: A node with editable text content (can be split and joined)annotation: An inline annotation applied to text (bold, link, etc.)
Properties of nodes can hold values:
string: A good old JavaScript stringnumber: Just like a number in JavaScriptinteger: A number for which Number.isInteger(number) returns trueboolean: true or falsestring_array: An array of good old JavaScript stringsinteger_array: An array of integersnumber_array: An array of numbersannotated_text: a plain text string, but with annotations (bold, italic, link etc.)
Or references:
node: References a single node (e.g. an image node can reference a global asset node)node_array: References a sequence of nodes (e.g. page.body references paragraph and list nodes)
const document_schema = {
page: {
kind: 'document',
properties: {
body: {
type: 'node_array',
node_types: ['nav', 'paragraph', 'list'],
default_node_type: 'paragraph',
}
}
},
paragraph: {
kind: 'text',
properties: {
content: { type: 'annotated_text', allow_newlines: true }
}
},
list_item: {
kind: 'text',
properties: {
content: { type: 'annotated_text', allow_newlines: true },
}
},
list: {
kind: 'block',
properties: {
list_items: {
type: 'node_array',
node_types: ['list_item'],
default_node_type: 'list_item',
}
}
},
nav: {
kind: 'block',
properties: {
nav_items: {
type: 'node_array',
node_types: ['nav_item'],
default_node_type: 'nav_item',
}
}
},
nav_item: {
kind: 'block',
properties: {
url: { type: 'string' },
label: { type: 'string' },
}
}
};A document is a plain JavaScript object (POJO) with a document_id (the entry point) and a nodes object containing all content nodes.
Rules:
- All nodes must be reachable from the document node (unreachable nodes are discarded)
- No cyclic references allowed
- Text content uses
{ text: '', annotations: [] }format
Here's an example document:
const doc = {
document_id: 'page_1',
nodes: {
nav_item_1: {
id: 'nav_item_1',
type: 'nav_item',
url: '/homepage',
label: 'Home'
},
nav_1: {
id: 'nav_1',
type: 'nav',
nav_items: ['nav_item_1']
},
paragraph_1: {
id: 'paragraph_1',
type: 'text',
layout: 1,
content: { text: 'Hello world.', annotations: [] }
},
list_item_1: {
id: 'list_item_1',
type: 'list_item',
content: { text: 'First list item', annotations: [] }
},
list_item_2: {
id: 'list_item_2',
type: 'list_item',
content: { text: 'Second list item', annotations: [] }
},
list_1: {
id: 'list_1',
type: 'list',
list_items: ['list_item_1', 'list_item_2']
},
page_1: {
id: 'page_1',
type: 'page',
body: ['nav_1', 'paragraph_1', 'list_1']
}
}
};Documents need a config object that tells Svedit how to render and manipulate your content. See the full example in src/routes/create_demo_session.js.
const session_config = {
// ID generator for creating new nodes
generate_id: () => nanoid(),
// System components (NodeCursorTrap, Overlays)
system_components: { NodeCursorTrap, Overlays },
// Map node types to Svelte components
node_components: { Page, Text, Story, List, Button, ... },
// Functions that create and insert new nodes
inserters: {
text: (tr, content = {text: '', annotations: []}) => {
const text_id = nanoid();
tr.create({ id: text_id, type: 'text', content });
tr.insert_nodes([text_id]);
}
},
// Returns { commands, keymap } for the editor instance
create_commands_and_keymap: (context) => { ... },
// Optional: handle image paste events
handle_image_paste: (session, images) => { ... }
};Key config options:
generate_id- Function that generates unique IDs for new nodesnode_components- Maps each node type from your schema to a Svelte componentsystem_components- Provides custom NodeCursorTrap and Overlays componentsinserters- Functions that create blank nodes of each type and set up the selectioncreate_commands_and_keymap- Factory function that creates commands and keybindings for an editor instancehandle_image_paste- Optional handler for image paste events
The config is accessible throughout your app via session.config.
The Session class manages your content graph, selection state, and history. See src/lib/Session.svelte.js for the full API.
Document content (session.doc) and selection (session.selection) are immutable with copy-on-write semantics. When a change is made, only the modified parts are copied — unchanged nodes keep their original references. This avoids the overhead of reactive proxies (using Svelte's $state.raw) since state is reassigned rather than mutated. Also, console.log(session.get(some_node_id)) gives you a readable raw object, not a proxy.
import { Session } from 'svedit';
const session = new Session(schema, doc, config);session.get(['page_1', 'body']) // => ['nav_1', 'paragraph_1', 'list_1']
session.get(['nav_1']) // => { id: 'nav_1', type: 'nav', ... }
session.get('nav_1') // => shorthand for above (single node ID)
session.inspect(['page_1', 'body']) // => { kind: 'property', type: 'node_array', node_types: [...] }
session.kind(node) // => 'text', 'block', or 'annotation'session.selection // Current selection (text, node, or property)
session.selected_node // The currently selected node (derived)
session.active_annotation('strong') // Check if annotation is active at cursor
session.can_insert('paragraph') // Check if node type can be inserted
session.available_annotation_types // Annotation types allowed at current selection (derived)const tr = session.tr; // Create a transaction
tr.set(['nav_1', 'label'], 'Home');
tr.insert_nodes(['new_node_id']);
session.apply(tr); // Apply the transactionsession.can_undo // Boolean (derived)
session.can_redo // Boolean (derived)
session.undo()
session.redo()Because document state is immutable, you can detect unsaved changes by comparing references. When a change is made, session.doc gets a new reference — unchanged documents keep the same reference.
let last_saved_doc = $state(null);
let has_unsaved_changes = $derived.by(() => {
if (!last_saved_doc) {
// No save yet — use undo history as indicator
return session.can_undo;
} else {
// Compare current doc reference against last saved
return last_saved_doc !== session.doc;
}
});
function save() {
// ... save to server ...
last_saved_doc = session.doc;
}This works because of Svedit's copy-on-write strategy: only modified parts of the document are copied, so reference equality is a reliable and efficient way to detect changes. You can use has_unsaved_changes to show/hide a save button, display a dirty indicator, or warn before navigating away.
session.doc.document_id // The document's root ID
session.generate_id() // Generate a new unique ID
session.config // Access the config object
session.validate_doc() // Validate all nodes against schema
session.traverse(node_id) // Get all nodes reachable from a node
session.select_parent() // Select parent of current selectionTransforms are pure functions that modify a transaction. They encapsulate common editing operations like breaking text nodes, joining nodes, or inserting new content.
Transforms take a transaction (tr) as their parameter and return true if successful or false if the transform cannot be applied (e.g., wrong selection type or invalid state).
// Example: break a text node at the cursor
import { break_text_node } from 'svedit';
const tr = session.tr;
const success = break_text_node(tr);
if (success) {
session.apply(tr);
}Svedit provides several core transforms in src/lib/transforms.svelte.js:
break_text_node(tr)- Split a text node at the cursor positionjoin_text_node(tr)- Join current text node with the previous oneinsert_default_node(tr)- Insert a new node at the current selection
Transforms are composable. You can build higher-level transforms from lower-level ones:
function custom_transform(tr) {
// Compose multiple transforms
if (!break_text_node(tr)) return false;
if (!insert_default_node(tr)) return false;
return true;
}You're encouraged to write custom transforms for your application's specific needs. Keep them pure functions that operate on the transaction object:
function insert_heading(tr) {
const selection = tr.selection;
if (selection?.type !== 'node') return false;
// Create and insert a heading node
const heading_id = tr.generate_id();
tr.create({ id: heading_id, type: 'heading', content: { text: '', annotations: [] } });
tr.insert_nodes(selection.path, selection.anchor_offset, [heading_id]);
return true;
}Transactions group multiple operations into atomic units that can be applied and undone as one. They provide the same read API as sessions (tr.get(), tr.inspect(), tr.kind(), tr.generate_id()), so transforms can query document state directly. See src/lib/Transaction.svelte.js for the full API.
const tr = session.tr; // Create a new transaction
tr.set(['node_1', 'title'], 'New Title'); // Modify properties
session.apply(tr); // Apply atomically// Create a new node (must include all required properties from schema)
tr.create({ id: 'paragraph_1', type: 'paragraph', content: { text: '', annotations: [] } });
// Delete a node (cascades to unreferenced child nodes)
tr.delete('paragraph_26');
// Insert nodes at current node selection
tr.insert_nodes(['paragraph_1', 'list_1']);
// Build a subgraph from existing nodes (generates new IDs)
const new_node_id = tr.build('the_list', {
first_item: {
id: 'first_item',
type: 'list_item',
content: node.content
}
the_list: {
id: 'the_list',
type: 'list',
list_items: ['first_item']
}
});// Insert text at cursor (replaces selection if expanded)
tr.insert_text('Hello');
// Toggle annotation on selected text
tr.annotate_text('strong');
tr.annotate_text('link', { href: 'https://example.com' });
// Delete selected text or nodes
tr.delete_selection();// Set the selection after operations
tr.set_selection({
type: 'text',
path: ['node_1', 'content'],
anchor_offset: 0,
focus_offset: 5
});All transaction methods return this for chaining:
tr.create(node)
.insert_nodes([node.id])
.set_selection(new_selection);Commands provide a structured way to implement user actions. Commands are stateful and UI-aware, unlike transforms which are pure functions.
There are two types of commands in Svedit:
- Document-scoped commands - Bound to a specific Svedit instance/document and only active when that editor has focus
- App-level commands - Operate at the application level, independent of any specific document
Let's start with document-scoped commands, which are the foundation of the editing experience.
Document-scoped commands operate on a specific document and have access to its selection, content, and editing state through a context object.
Extend the Command base class and implement the is_enabled() and execute() methods:
import { Command } from 'svedit';
class ToggleStrongCommand extends Command {
is_enabled() {
return this.context.editable && this.context.session.selection?.type === 'text';
}
execute() {
this.context.session.apply(this.context.session.tr.annotate_text('strong'));
}
}Document-scoped commands receive a context object with access to the Svedit instance's state:
context.session- The current session instancecontext.editable- Whether the editor is in edit modecontext.canvas_el- The DOM element of the Svedit editor canvascontext.is_composing- Whether IME composition is currently taking place
is_enabled(): boolean
Determines if the command can currently be executed. This is automatically evaluated and exposed as the disabled derived property, which can be used to disable UI elements.
is_enabled() {
return this.context.editable && this.context.session.selection?.type === 'text';
}execute(): void | Promise<void>
Executes the command's action. Can be synchronous or asynchronous.
execute() {
const tr = this.context.session.tr;
tr.insert_text('Hello');
this.context.session.apply(tr);
}Svedit provides several core commands out of the box:
UndoCommand- Undo the last changeRedoCommand- Redo the last undone changeSelectParentCommand- Select the parent of the current selectionToggleAnnotationCommand- Toggle text annotations (bold, italic, etc.)AddNewLineCommand- Insert newline character in textBreakTextNodeCommand- Split text node at cursorSelectAllCommand- Progressively expand selectionInsertDefaultNodeCommand- Insert a new node at cursor
Commands are created by passing them a context object from the Svedit component. See a complete example in src/routes/create_demo_session.js in the create_commands_and_keymap configuration function:
create_commands_and_keymap: (context) => {
const commands = {
undo: new UndoCommand(context),
redo: new RedoCommand(context),
toggle_strong: new ToggleAnnotationCommand('strong', context),
toggle_emphasis: new ToggleAnnotationCommand('emphasis', context),
// ... more commands
};
const keymap = define_keymap({
'meta+z,ctrl+z': [commands.undo],
'meta+b,ctrl+b': [commands.toggle_strong],
// ... more keybindings
});
return { commands, keymap };
}Bind commands to UI elements in your components:
<button
disabled={document_commands.toggle_strong.disabled}
class:active={document_commands.toggle_strong.active}
onclick={() => document_commands.toggle_strong.execute()}>
Bold
</button>Commands can have derived state for reactive UI binding. The active property in toggle commands is a common pattern:
class ToggleEmphasisCommand extends Command {
// Automatically recomputes when annotation state changes
active = $derived(this.context.session.active_annotation('emphasis'));
is_enabled() {
return this.context.editable && this.context.session.selection?.type === 'text';
}
execute() {
this.context.session.apply(this.context.session.tr.annotate_text('emphasis'));
}
}The disabled property is automatically derived from is_enabled() on all commands.
Commands can access the DOM through the context or global APIs:
class FocusNextSelectableCommand extends Command {
execute() {
const selectables = this.context.canvas_el.querySelectorAll('.svedit-selectable');
const next = selectables[0]; // Find next based on current selection
const path = next.closest('[data-path]').dataset.path.split('.');
this.context.session.selection = { type: 'text', path, anchor_offset: 0, focus_offset: 0 };
}
}While document-scoped commands operate on a specific Svedit instance, app-level commands operate at the application level and handle concerns like saving, loading, switching between edit/view modes, or managing multiple documents.
Svedit uses a scope hierarchy (scope stack) to manage which commands are active at any given time:
- App-level scope (top level) - Commands that are always available, independent of document focus
- Document-level scope (per Svedit instance) - Commands bound to a specific document/editor
When a Svedit instance gains focus:
- The previous document's scope is popped from the stack (its commands become inactive)
- The newly focused document's scope is pushed onto the stack (its commands become active)
This means commands automatically work with the correct document based on focus.
App-level commands have their own context, separate from any specific document:
import { Command } from 'svedit';
class SaveCommand extends Command {
is_enabled() {
return this.context.editable;
}
async execute() {
await this.context.save_all_documents();
this.context.show_notification('All changes saved');
}
}
class ToggleEditModeCommand extends Command {
is_enabled() {
return !this.context.editable;
}
execute() {
this.context.editable = true;
}
}The app-level context contains application-wide state and methods:
const app_context = {
get editable() {
return editable; // App-level editable state
},
set editable(value) {
editable = value;
},
get session() {
return session;
},
get app_el() {
return app_el;
}
};
const app_commands = {
save: new SaveCommand(app_context),
toggle_edit: new ToggleEditCommand(app_context)
};The KeyMapper manages keyboard shortcuts using a scope-based stack system. Scopes are tried from top to bottom (most recent to least recent), allowing more specific keymaps to override general ones.
import { KeyMapper, define_keymap } from 'svedit';
const key_mapper = new KeyMapper();
// Define a keymap
const keymap = define_keymap({
'meta+z,ctrl+z': [document_commands.undo],
'meta+b,ctrl+b': [document_commands.bold],
'enter': [document_commands.break_text_node]
});
// Push the keymap onto the scope stack
key_mapper.push_scope(keymap);
// Handle keydown events
window.addEventListener('keydown', (event) => {
key_mapper.handle_keydown(event);
});- Multiple modifiers:
meta+shift+z,ctrl+alt+k - Cross-platform:
meta+z,ctrl+z(tries Meta+Z first, then Ctrl+Z) - Modifiers:
meta,ctrl,alt,shift - Keys: Any key name (e.g.,
a,enter,escape,arrowup)
Commands are wrapped in arrays to support fallback behavior:
define_keymap({
'meta+b,ctrl+b': [
document_commands.bold, // Try this first
document_commands.fallback // Use this if first is disabled
]
});Use push_scope() and pop_scope() to manage different keyboard contexts:
// App-level keymap (always active)
const app_keymap = define_keymap({
'meta+s,ctrl+s': [app_commands.save],
'meta+n,ctrl+n': [app_commands.new_document]
});
key_mapper.push_scope(app_keymap);
// Document-level keymap (active when editor has focus)
const doc_keymap = define_keymap({
'meta+z,ctrl+z': [document_commands.undo],
'meta+b,ctrl+b': [document_commands.bold]
});
// When editor gains focus:
key_mapper.push_scope(doc_keymap);
// When editor loses focus:
key_mapper.pop_scope();The KeyMapper tries scopes from top to bottom, so push more specific keymaps last.
Selections are at the heart of Svedit. There are just three types of selections:
- Text Selection: A text selection spans across a range of characters in a string. E.g. the below example has a collapsed cursor at position 1 in a text property 'content'.
{
type: 'text',
path: ['page_1234', 'body', 0, 'content'],
anchor_offset: 1,
focus_offset: 1
}- Node Selection: A node selection spans across a range of nodes inside a node_array. The below example selects the nodes at index 3 and 4.
{
type: 'node',
path: ['page_1234', 'body'],
anchor_offset: 2,
focus_offset: 4
}- Property Selection: A property selection addresses one particular property of a node.
{
type: "property",
path: [
"page_1",
"body",
11,
"image"
]
}You can access the current selection through session.selection anytime. And you can programmatically set the selection using session.selection = new_selection.
Now you can start making your Svelte pages in-place editable by wrapping your design inside the <Svedit> component.
<Svedit {session} path={[session.doc.document_id]} editable={true} />Node components are Svelte components that render specific node types in your document. Each node component receives a path prop and uses the <Node> wrapper component along with property components to render the node's content.
A typical node component follows this pattern:
<script>
import { Node, AnnotatedTextProperty } from 'svedit';
let { path } = $props();
</script>
<Node {path}>
<div class="my-node">
<AnnotatedTextProperty path={[...path, 'content']} />
</div>
</Node>Every node component must wrap its content in the <Node> component. This wrapper:
- Registers the node with the editor
- Handles selection and cursor behavior
- Provides the foundation for editing interactions
Svedit provides specialized components for rendering different property types:
<AnnotatedTextProperty> - For editable text content with inline formatting:
<AnnotatedTextProperty
tag="p"
class="body"
path={[...path, 'content']}
placeholder="Enter text here"
/><NodeArrayProperty> - For container properties that hold multiple nodes:
<NodeArrayProperty
class="list-items"
path={[...path, 'list_items']}
/><CustomProperty> - For custom properties like images or other non-text content:
<CustomProperty class="image-wrapper" path={[...path, 'image']}>
<div contenteditable="false">
<img src={node.image} alt={node.title.text} />
</div>
</CustomProperty>Use the Svedit context to access node data:
<script>
import { getContext } from 'svelte';
const svedit = getContext('svedit');
let { path } = $props();
let node = $derived(svedit.session.get(path));
let layout = $derived(node.layout || 1);
</script>Here's a complete example of a text node component that supports multiple layouts:
<script>
import { getContext } from 'svelte';
import { Node, AnnotatedTextProperty } from 'svedit';
const svedit = getContext('svedit');
let { path } = $props();
let node = $derived(svedit.session.get(path));
let layout = $derived(node.layout || 1);
let tag = $derived(layout === 1 ? 'p' : `h${layout - 1}`);
</script>
<Node {path}>
<div class="text layout-{layout}">
<AnnotatedTextProperty
{tag}
class="body"
path={[...path, 'content']}
placeholder="Enter text"
/>
</div>
</Node>A simple list component that renders child items:
<script>
import { Node, NodeArrayProperty } from 'svedit';
let { path } = $props();
</script>
<Node {path}>
<div class="list">
<NodeArrayProperty path={[...path, 'list_items']} />
</div>
</Node>Node components are registered in the document config's node_components map:
const session_config = {
node_components: {
Text,
Story,
List,
ListItem,
// ... other components
}
}The key in this map corresponds to the node's type property in the schema. Note that the component name should match the node type name. For example, a node with type: "list_item" will look for a component registered as ListItem in the node_components map.
Svedit relies on the contenteditable attribute to make elements editable. The below example shows you
a simplified version of the markup of <NodeCursorTrap> and why it is implemented the way it is.
<div contenteditable="true">
<div class="some-wrapper">
<!--
Putting a <br> tag into a div gives you a single addressable cursor position.
Adding a ​ (or any character) here will lead to 2 cursor
positions (one before, and one after the character)
Using <wbr> will make it only addressable for ArrowLeft and ArrowRight, but not ArrowUp and ArrowDown.
And using <span></span> will not make it addressable at all.
Svedit uses this behavior for node-cursor-traps, and when an
<AnnotatedTextProperty> is empty.
-->
<div class="cursor-trap"><br></div>
<!--
If you create a contenteditable="false" island, there needs to be some content in it,
otherwise it will create two additional cursor positions. One before, and another one
after the island.
The Svedit demo uses this technique in `<NodeCursorTrap>` to create a node-cursor
visualization, that doesn't mess with the contenteditable cursor positions.
-->
<div contenteditable="false" class="node-cursor">​</div>
</div>
</div>Further things to consider:
- If you make a sub-tree
contenteditable="false", be aware that you can't create acontenteditable="true"segment somewhere inside it. Svedit can only work reliably when there's one contenteditable="true" at root (it's set by<Svedit>) <AnnotatedTextProperty>and<CustomProperty>must not be wrapped incontenteditable="false"to work properly.- Never apply
position: relativeto the direct parent of<AnnotatedTextProperty>, it will cause a weird Safari bug to destroy the DOM. - Never use an
<a>tag inside acontenteditable="true"element, as it will cause unexpected behavior. Make it a<div>while editing, and an<a>in read-only mode (whensvedit.editableisfalse).
Not yet. Please just read the code for now. It's only a couple of files with less than 3000 LOC in total. The files in routes are considered example code (copy them and adapt them to your needs), while files in lib are considered library code. Read them to understand the API and what's happening behind the scenes.
Once you've cloned the Svedit repository and installed dependencies with npm install, start a development server:
npm run devTo create a production version of your app:
npm run buildYou can preview the production build with npm run preview.
At the very moment, the best way to help is to donate or to sponsor us, so we can buy time to work on this exclusively for a couple of more months. Please get in touch personally.
Find my contact details here.
It's still early. Expect bugs. Expect missing features. Expect the need for more work on your part to make this fit for your use case.
Svedit is led by Michael Aufreiter with guidance and support from Johannes Mutter.