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 uncialBlocks
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:
| Token | Controls |
|---|---|
--uncial-color-primary | Primary buttons, links, active controls, block focus outlines. |
--uncial-color-primary-contrast | Text/icons on primary buttons. |
--uncial-color-accent | Secondary emphasis color for consumers who want one. |
--uncial-color-surface | Main editor and renderer background. |
--uncial-color-surface-elevated | Toolbar, inputs, nested block surfaces. |
--uncial-color-border | Control, panel, renderer, and block chrome borders. |
--uncial-color-text | Editor UI and rich text foreground. |
--uncial-color-code-bg | Fenced code block background. Defaults dark in both light and dark themes. |
--uncial-radius-sm/md/lg | Buttons, inputs, panels, renderer, and block chrome radius. |
--uncial-font-body/display/mono | Editor 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
| Spec | Use |
|---|---|
default | Required fallback value. Shorthand values like title: "" become defaults. |
input | Editor control hint: text, textarea, number, checkbox, json, richtext, or select. |
required | Marks an attribute as required during validation. |
validate | Custom predicate for accepting or rejecting an attribute value. |
parse / serialize | Coerce values at editor or persistence boundaries. |
options | Allowed 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.