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-svedit
Install dependencies:
npm install
And run the development server:
npm run dev
Now 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.
Chromeless canvas: We keep the 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.
Convention over configuration: We use conventions and assumptions to reduce configuration code and limit the number of ways something can go wrong. For instance, a node with a property named content of type annotated_text is considered kind text, while all other nodes are considered kind block. Text nodes have special behavior in the system for editing (e.g. they can be split and joined).
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.
Svedit connects five key pieces:
The flow:
<Svedit> componentAll changes go through transactions for atomic updates and undo/redo support. Transforms are the building blocks — pure functions that modify transactions. The session's selection state syncs bidirectionally with the DOM selection.
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:
{ text: '', annotations: [] } formatHere'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 document_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, ... },
// App-specific: Number of layout variants per node type
node_layouts: { text: 4, story: 3, list: 5 },
// Functions that create and insert new nodes
inserters: {
text: (tr, content = {text: '', annotations: []}) => {
const id = nanoid();
tr.create_node(id, 'text', { content });
tr.insert_nodes([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 eventsThe 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 transaction
session.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 selection
Transforms 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 selectionTransforms 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_node(heading_id, '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: 'para_1', type: 'paragraph', content: { text: '', annotations: [] } });
// Delete a node (cascades to unreferenced child nodes)
tr.delete('node_id');
// Insert nodes at current node selection
tr.insert_nodes(['node_1', 'node_2']);
// Build a subgraph from existing nodes (generates new IDs)
const new_root_id = tr.build(source_node_id, source_nodes);
// 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:
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 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 placeis_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 cursorCommands 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 CopyCommand extends Command {
is_enabled() {
return this.context.session.selection !== null;
}
async execute() {
const text = this.context.session.get_selected_plain_text();
await navigator.clipboard.writeText(text);
// Access the editor canvas
this.context.canvas_el.classList.add('copy-feedback');
}
}
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:
When a Svedit instance gains focus:
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);
});
meta+shift+z, ctrl+alt+kmeta+z,ctrl+z (tries Meta+Z first, then Ctrl+Z)meta, ctrl, alt, shifta, 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:
{
type: 'text',
path: ['page_1234', 'body', 0, 'content'],
anchor_offset: 1,
focus_offset: 1
}
{
type: 'node',
path: ['page_1234', 'body'],
anchor_offset: 2,
focus_offset: 4
}
{
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>
<Node> wrapperEvery node component must wrap its content in the <Node> component. This wrapper:
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 document_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:
contenteditable="false", be aware that you can't create a contenteditable="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 in contenteditable="false" to work properly.position: relative to the direct parent of <AnnotatedTextProperty>, it will cause a weird Safari bug to destroy the DOM.<a> tag inside a contenteditable="true" element, as it will cause unexpected behavior. Make it a <div> while editing, and an <a> in read-only mode.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 dev
To create a production version of your app:
npm run build
You 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.