Skip to content

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-nativerenderComponent / settingsComponent are 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.
vue
<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

PropTypeNotes
v-modelPageDocumentTwo-way bound document.
initialDocumentPageDocumentStarting page when there's no model/draft.
blocksBlockRegistryBlock registry; defaults to the built-ins.
pluginsUframePlugin[]Bundles of blocks / tokens / panels / toolbar slots.
readonlybooleanRender without editing affordances.
autosaveKeystringShorthand for a localStorage storage adapter.
storageEditorStorageAdapterCustom (sync or async) load / save.
prefsKeystringNamespace for UI prefs (pin / mode / panel width).
featuresEditorFeatureFlagsToggle 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:

vue
<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:

ts
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>, so renderComponent / renderHtml can 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:

ts
// 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:

ts
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 }],
})
vue
<PageEditor :plugins="[brandPlugin]" v-model="doc" />

What each field does:

  • blocks — block definitions merged onto the registry. Later entries win on a type collision (plugins override the base registry).
  • styleTokens — CSS custom properties applied to the .uf-editor root, 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's left / right clusters.
  • panels — custom left-sidebar panels. Each adds a rail item (icon + label) and renders component when active; id becomes 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.

ts
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 of renderComponent. 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 from defaultProps, or pass an explicit SettingsField[] (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:

tsx
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:

svelte
<!-- 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>
ts
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's css (light-DOM classes) to apply. Full runnable starters for Vue, React and Svelte live under templates/.

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:

ts
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:

ts
const storage: EditorStorageAdapter = {
  load: async () => fetch('/api/page').then(r => r.json()),
  save: async doc => fetch('/api/page', { method: 'PUT', body: JSON.stringify(doc) }),
}
vue
<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.