Extending the editor
The embed client is the right fit when you want isolation and a tiny host bundle. When you instead need to add your own blocks, panels, or toolbar buttons, import the editor as a Vue component and configure it with props and plugins. This path pulls Vue into your bundle, so reach for it when you own a Vue app already.
Blocks come in two flavours:
- Vue-native —
renderComponent/settingsComponentare Vue components. Full integration; best for first-party blocks (most of this page). - Framework-neutral — the block ships a custom element and plain data, so it can be authored in React, Svelte, vanilla — anything that compiles to a custom element — and even loaded into the editor at runtime by URL.
<script setup lang="ts">
import { PageEditor } from 'uframe'
import 'uframe/dist/styles.css'
const doc = ref()
</script>
<template>
<PageEditor v-model="doc" autosave-key="my-page" @save="persist" />
</template><PageEditor> props
| Prop | Type | Notes |
|---|---|---|
v-model | PageDocument | Two-way bound document. |
initialDocument | PageDocument | Starting page when there's no model/draft. |
blocks | BlockRegistry | Block registry; defaults to the built-ins. |
plugins | UframePlugin[] | Bundles of blocks / tokens / panels / toolbar slots. |
readonly | boolean | Render without editing affordances. |
autosaveKey | string | Shorthand for a localStorage storage adapter. |
storage | EditorStorageAdapter | Custom (sync or async) load / save. |
prefsKey | string | Namespace for UI prefs (pin / mode / panel width). |
features | EditorFeatureFlags | Toggle autosave / history / hotkeys / preview. |
Emits: save (explicit save), error (validation errors), draftRestored (an autosave draft was loaded).
Feature flags
Each feature is on by default; pass features to turn one off:
<PageEditor :features="{ autosave: false, preview: false }" />Custom blocks
A BlockDefinition describes one block type — how it renders, its settings UI, default props, and (optionally) a schema that validates props on load:
import type { BlockDefinition } from 'uframe'
const calloutBlock: BlockDefinition = {
type: 'callout',
label: 'Callout',
category: 'Structure', // groups it in the Add panel
icon: CalloutIcon,
defaultProps: { tone: 'info', text: '' },
propsSchema: calloutPropsSchema, // optional — any Standard Schema (zod 4 / valibot / arktype)
renderComponent: CalloutBlock, // drawn on the canvas (Vue component)
settingsComponent: CalloutSettings, // Content-tab fields (Vue component)
acceptsChildren: false,
css: '.callout { padding: 1rem }', // injected once into the canvas + export
renderHtml: block => `<div class="callout">${block.props.text}</div>`, // HTML export
}Pass it directly via blocks, or — preferably — through a plugin (below).
Notable fields:
propsSchema— optional. Accepts any Standard Schema (zod 4, valibot, arktype, …); omit it for no prop validation.css— static stylesheet for the block type, injected once into the canvas iframe and the exported<head>, sorenderComponent/renderHtmlcan use classes instead of inline styles. A component's own<style>reaches neither the canvas iframe nor the export, so block styles belong here.renderHtml— framework-free HTML string for the built-in HTML export. The raw-JSON + your-own-components rendering path (e.g. SSR) doesn't need it.
Prop validation
propsSchema accepts any Standard Schema, so you're not tied to zod — use whichever validator you already have:
// ArkType
import { type } from 'arktype'
// Valibot
import * as v from 'valibot'
// zod 4
import { z } from 'zod'
const propsSchema = z.object({ tone: z.enum(['info', 'warn']), text: z.string() })
const propsSchema = v.object({ tone: v.picklist(['info', 'warn']), text: v.string() })
const propsSchema = type({ tone: '\'info\' | \'warn\'', text: 'string' })The editor validates through the schema's ~standard interface, so the library is interchangeable. Two caveats: it's optional (omit for no validation), and only synchronous schemas are supported (sync zod/valibot/arktype cover this).
Plugins
A plugin is plain data (no lifecycle): it bundles blocks, editor-chrome style tokens, toolbar slots, and custom sidebar panels. Because it's just an object, a plugin is an npm package that exports one. Use definePlugin for type inference:
import { definePlugin } from 'uframe'
export const brandPlugin = definePlugin({
name: 'brand',
blocks: [calloutBlock], // merged onto the registry (last-wins on a type clash)
styleTokens: { '--uf-accent': '#7c3aed' }, // recolours panels/toolbar
toolbarSlots: { right: [SaveStatus] }, // mounted into the toolbar clusters
panels: [{ id: 'assets', label: 'Assets', icon: AssetsIcon, component: AssetsPanel }],
})<PageEditor :plugins="[brandPlugin]" v-model="doc" />What each field does:
blocks— block definitions merged onto the registry. Later entries win on atypecollision (plugins override the base registry).styleTokens— CSS custom properties applied to the.uf-editorroot, so they recolour the panels and toolbar. The canvas iframe is a separate document and is intentionally unaffected — see Theming.toolbarSlots— components appended to the toolbar'sleft/rightclusters.panels— custom left-sidebar panels. Each adds a rail item (icon + label) and renderscomponentwhen active;idbecomes the sidebar mode key, so keep it stable and unique.
Framework-neutral blocks (any framework)
To author a block without Vue, provide a registered custom element instead of Vue components. The editor stays Vue internally and mounts your element; your block can be built in React, Svelte, Lit, vanilla — anything that compiles to a custom element.
const calloutBlock = {
type: 'callout',
label: 'Callout',
category: 'Structure',
defaultProps: { tone: 'info', text: 'Heads up!' },
element: 'uf-callout', // a custom element you registered (customElements.define)
settings: 'auto', // editor renders the Content form from the prop shape…
// settings: [{ key: 'tone', type: 'select', options: [...] }, { key: 'text', type: 'textarea' }],
css: calloutCss, // block styles (string)
renderHtml: (block, ctx) => `<div class="${ctx.classes} callout">${ctx.escape(block.props.text)}</div>`,
}element— tag name of a custom element registered in the same realm. Used instead ofrenderComponent. The editor pushes block props onto it (as properties + primitive attribute mirrors) and applies the block's class/id.settings—'auto'infers a Content-tab form fromdefaultProps, or pass an explicitSettingsField[](text/textarea/number/boolean/select/color). No Vue settings component needed.css/renderHtml— same as above; both framework-free.
Bundle styles with the component? They won't reach the canvas iframe or the export, so put block styles in css. For the Vue starter you can lift an SFC <style> into css at build time with the uframe-css Vite plugin (import css from './Block.vue?uframe-css').
React
Mount your React component inside a custom element, then point element at its tag:
import type { Root } from 'react-dom/client'
import { createElement } from 'react'
import { createRoot } from 'react-dom/client'
import Callout from './Callout' // your React component
class CalloutElement extends HTMLElement {
private root?: Root
private props = { tone: 'info', text: 'Heads up!' }
static observedAttributes = ['tone', 'text']
connectedCallback() {
this.root = createRoot(this)
this.render()
}
disconnectedCallback() { this.root?.unmount() }
attributeChangedCallback(name: string, _o: string, v: string) {
(this.props as Record<string, unknown>)[name] = v
this.render()
}
private render() { this.root?.render(createElement(Callout, this.props)) }
}
customElements.define('uf-callout-react', CalloutElement)
export default { name: 'callout-react', blocks: [{
type: 'callout',
label: 'Callout',
defaultProps: { tone: 'info', text: 'Heads up!' },
element: 'uf-callout-react',
settings: 'auto',
renderHtml: (b, ctx) => `<div class="${ctx.classes}">${ctx.escape(String(b.props.text))}</div>`,
}] }Svelte
Svelte 5 compiles a component straight to a custom element — no wrapper needed:
<!-- Callout.svelte -->
<svelte:options customElement={{ tag: 'uf-callout-svelte', shadow: 'none' }} />
<script lang="ts">
let { tone = 'info', text = 'Heads up!' } = $props()
</script>
<div class="uf-callout-block uf-callout-block--{tone}">{text}</div>import './Callout.svelte' // registers <uf-callout-svelte> on import
export default { name: 'callout-svelte', blocks: [{
type: 'callout',
label: 'Callout',
defaultProps: { tone: 'info', text: 'Heads up!' },
element: 'uf-callout-svelte',
settings: 'auto',
css: calloutCss,
renderHtml: (b, ctx) => `<div class="${ctx.classes}">${ctx.escape(String(b.props.text))}</div>`,
}] }Bundle React/Svelte into the plugin (don't externalize them) so the element is self-contained, and use
shadow: 'none'for Svelte if you want the block'scss(light-DOM classes) to apply. Full runnable starters for Vue, React and Svelte live undertemplates/.
Loading plugins at runtime
A neutral plugin built to a self-contained dist (its custom element bundled in) can be loaded into a hosted/embedded editor by URL — no rebuild of the editor:
createUframeEditor({ src, plugins: ['/plugins/callout/dist/index.js'] })
// or later: editor.loadPlugins(['/plugins/callout/dist/index.js'])See Client API & protocol for the plugins option and the loadPlugins handle. Starter templates for Vue, React and Svelte live under templates/.
Storage
Supply an EditorStorageAdapter for full control over persistence, or autosaveKey for the built-in localStorage adapter:
const storage: EditorStorageAdapter = {
load: async () => fetch('/api/page').then(r => r.json()),
save: async doc => fetch('/api/page', { method: 'PUT', body: JSON.stringify(doc) }),
}<PageEditor :storage="storage" />UI preferences (rail pin state, active mode, panel width) are stored separately under prefsKey — never mixed with the page document. Two editors on one page should pass distinct prefsKey values to keep their preferences independent.