Uncial

The backend-agnostic CMS block editor and renderer.
Define custom blocks once as components, and reuse them seamlessly across your WYSIWYG editor and frontend.

Install

Add Uncial to a Svelte 5 app

Install the package in your application. Uncial expects svelte@^5 as a peer dependency.

# Terminal
npm install uncial
pnpm add uncial
bun add uncial

Blocks

Define a custom block once

A Svelte block definition gives Uncial a stable id, an editor label, normalized attribute defaults, and the Svelte component used by both the editor and SSR-capable renderer.

<!-- src/lib/blocks/PromoCard.svelte -->
<script lang="ts">
	interface Props {
		title?: string;
		body?: string;
	}

	let { title = 'Spring launch', body = 'Save 20% on featured plans.' }: Props = $props();
</script>

<article class="promo-card">
	<h3>{title}</h3>
	<p>{body}</p>
</article>
// src/lib/blocks/promoCard.ts
import { defineSvelteBlock } from 'uncial';
import PromoCard from './PromoCard.svelte';

export const promoCard = defineSvelteBlock({
	id: 'promoCard',
	label: 'Promo Card',
	description: 'A reusable promotional card block',
	attributes: {
		title: '',
		featured: false,
		priority: { default: 0, input: 'number' },
		metadata: { default: { theme: 'sand' }, input: 'json' }
	},
	component: PromoCard
});

Setup

Create the registry, schema, and controller

The registry is the shared block catalog. The schema controls allowed blocks and marks. The attributes controller powers block and link editing UI.

// src/lib/uncial.ts
import { createBlockAttributesController, createBlockRegistry, createSchema } from 'uncial';
import { promoCard } from './blocks/promoCard';

export const blocks = createBlockRegistry([promoCard]);
export const schema = createSchema(blocks);
export const attributesController = createBlockAttributesController();

Usage

Edit and render the same JSON

Bind your document to Editor with bind:json. Later, pass the saved document to Renderer as content with the same blocks and schema.

<!-- src/routes/editor/+page.svelte -->
<script lang="ts">
	import 'uncial/styles';
	import { Editor, Renderer } from 'uncial';
	import { attributesController, blocks, schema } from './uncial';

	let document = $state({
		type: 'doc',
		content: [{ type: 'paragraph' }]
	});
</script>

<Editor {blocks} {schema} {attributesController} bind:json={document} />
<Renderer content={document} {blocks} {schema} />

Metadata

Edit document-level metadata

Add typed metaFields to your schema for frontmatter-style data such as title, author, publish date, or tags. Metadata is stored as JSON on the top-level document meta field and is not serialized to YAML.

<script lang="ts">
	import {
		DocumentMetaPanel,
		Editor,
		createDocumentMetaController,
		createSchema
	} from 'uncial';

	const schema = createSchema(blocks, {
		metaFields: {
			title: { default: '', required: true },
			author: { default: '' },
			publishedAt: { default: '', placeholder: 'YYYY-MM-DD' },
			tags: { default: [], input: 'json' }
		}
	});

	const metaController = createDocumentMetaController(schema.metaFields);
	let document = $state({ type: 'doc', content: [{ type: 'paragraph' }] });
	let meta = $state({ title: 'Hello world', author: 'Ada' });
</script>

<Editor {blocks} {schema} {metaController} bind:json={document} bind:meta />
<DocumentMetaPanel
	controller={metaController}
	fields={schema.metaFields}
	onCommit={(nextMeta) => (meta = nextMeta)}
/>

When metadata fields are configured, Editor also shows a built-in Metadata button in the toolbar. DocumentMetaPanel remains available for apps that prefer a permanent sidebar or custom placement.

The renderer normalizes the same metadata and exposes it as an optional snippet prop.

<Renderer content={document} {blocks} {schema}>
	{#snippet meta(meta)}
		<h1>{meta?.title}</h1>
	{/snippet}
</Renderer>

Theming

Match Uncial to your site

Uncial’s editor and renderer chrome are plain CSS. Tailwind and DaisyUI are not required by the library. Import the default styles once, then override --uncial-* custom properties anywhere above the editor in the cascade.

// Full default editor, renderer, controls, and rich text prose styles.
import 'uncial/styles';

// Or import only the editor chrome if your app owns prose typography.
import 'uncial/styles/chrome';

Scope theme tokens to a wrapper when one page should look different from the rest of your app:

<div class="your-custom-class">
	<Editor {blocks} {schema} bind:json={document} />
	<Renderer content={document} {blocks} {schema} />
	<BlockAttributesPanel controller={attributesController} {blocks} />
</div>
.your-custom-class .uncial-editor-shell,
.your-custom-class .uncial-renderer,
.your-custom-class .uncial-attrs-panel,
.your-custom-class .uncial-link-panel,
.your-custom-class .uncial-richtext-wrapper,
.your-custom-class .uncial-block-menu {
	--uncial-color-surface: white;
	--uncial-color-surface-elevated: #f6f3ee;
	--uncial-color-border: #d8cab8;
	--uncial-color-text: #241a12;
	--uncial-color-text-muted: #756657;
	--uncial-color-primary: #7c3aed;
	--uncial-color-primary-contrast: white;
	--uncial-color-accent: #d97706;
	--uncial-color-danger: #dc2626;
	--uncial-color-focus-ring: #7c3aed;

	--uncial-radius-sm: 0.25rem;
	--uncial-radius-md: 0.5rem;
	--uncial-radius-lg: 0.875rem;

	--uncial-font-body: Inter, system-ui, sans-serif;
	--uncial-font-display: Georgia, serif;
	--uncial-font-mono: 'IBM Plex Mono', ui-monospace, monospace;
}

The most common tokens are:

TokenControls
--uncial-color-primaryPrimary buttons, links, active controls, block focus outlines.
--uncial-color-primary-contrastText/icons on primary buttons.
--uncial-color-accentSecondary emphasis color for consumers who want one.
--uncial-color-surfaceMain editor and renderer background.
--uncial-color-surface-elevatedToolbar, inputs, nested block surfaces.
--uncial-color-borderControl, panel, renderer, and block chrome borders.
--uncial-color-textEditor UI and rich text foreground.
--uncial-color-code-bgFenced code block background. Defaults dark in both light and dark themes.
--uncial-radius-sm/md/lgButtons, inputs, panels, renderer, and block chrome radius.
--uncial-font-body/display/monoEditor UI, headings, and code typography.

Rich text uses a stable .uncial-rich-content hook. If your site already has article typography, import uncial/styles/chrome and style that hook yourself:

.your-custom-class .uncial-rich-content {
	font: 400 1rem/1.7 var(--site-body-font);
	color: var(--site-ink);
}

.your-custom-class .uncial-rich-content h2 {
	font: 700 1.75rem/1.2 var(--site-display-font);
	margin-block: 2rem 0.75rem;
}

Attributes

Describe editable block data

SpecUse
defaultRequired fallback value. Shorthand values like title: "" become defaults.
inputEditor control hint: text, textarea, number, checkbox, json, richtext, or select.
requiredMarks an attribute as required during validation.
validateCustom predicate for accepting or rejecting an attribute value.
parse / serializeCoerce values at editor or persistence boundaries.
optionsAllowed values for select-style attributes.

Runtime plugins

Svelte ships first, core stays neutral

Svelte is the only bundled block runtime today. Non-Svelte block authoring is enabled through public runtime plugins that normalize native components and can provide editor node-view mounting. A registry may contain blocks from only one runtime in this release; mixed-runtime documents fail fast.

Svelte blocks keep the direct Svelte renderer path, so custom block rendering remains SSR-capable. Future React or Vue support should be implemented with full-document runtime renderers that can use each framework’s SSR APIs, not by mixing client-only component islands into the Svelte renderer.

import { defineRuntimeBlock } from 'uncial/core';
import { reactRuntime } from 'uncial-react-runtime';

export function defineReactBlock(config) {
	return defineRuntimeBlock(reactRuntime, config);
}

Containers

Allow nested document flow

Atomic blocks have no child content. Add content: { kind: 'flow' } when a block should own one default child region. The block component receives attribute props plus a children snippet for that region.

// src/lib/blocks/collapsible.ts
import { defineSvelteBlock } from 'uncial';
import Collapsible from './Collapsible.svelte';

export const collapsible = defineSvelteBlock({
	id: 'collapsible',
	label: 'Collapsible',
	attributes: {
		title: ''
	},
	component: Collapsible,
	content: { kind: 'flow' }
});
<!-- src/lib/blocks/Collapsible.svelte -->
<script lang="ts">
	import type { Snippet } from 'svelte';

	interface Props {
		title?: string;
		children?: Snippet;
	}

	let { title = '', children }: Props = $props();
</script>

<details class="collapse collapse-arrow border border-base-300 bg-base-100">
	<summary class="collapse-title font-semibold">{title}</summary>
	<div class="collapse-content">
		{#if children}
			{@render children()}
		{/if}
	</div>
</details>

Validation

Normalize and validate before publish

Use normalizeDocument and validateDocument around persistence boundaries, or pass onIssue to Editor and Renderer to observe document issues as they happen.

// src/lib/publish.ts
import { normalizeDocument, validateDocument } from 'uncial';
import { blocks, schema } from './uncial';

const normalized = normalizeDocument(document, blocks, schema);
const result = validateDocument(normalized, blocks, schema, {
	onIssue: (issue) => console.warn(issue.code, issue.path)
});

if (!result.ok) {
	throw new Error('Document is not publishable');
}

Security

Render known blocks, validate user content

  • Renderer uses the block registry you provide, so unknown custom blocks are not rendered as trusted components.
  • Built-in rich text links are sanitized to http, https, mailto, tel, relative paths, and hash links.
  • Run validation before publishing or saving user-authored documents.
  • Custom block components and html.render hooks are trusted application code; sanitize any raw HTML or navigation attributes they emit.