Rich-text display
Renders a structured content tree (paragraphs, headings, lists, quotes, images, code and rules) through a fixed set of Svelte parts, with a bounded reading column, per-node snippet overrides and no HTML strings.
cmp_rich_text_display_01 Preview
Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.
Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
Using the PageSugar MCP server, fetch component cmp_rich_text_display_01 version 1.0.0 with variant "blue", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.Not connected yet? Set up the MCP server
Code
- Palette
- Blue links
- Version
- 1.0.0
- Digest
Full digest
sha256-1ef7dbb8858763098d65e0325adb5229a5157558491d3bad9ee6e07a91717e87
This component needs all 13 files. Download the ZIP
import type { Snippet } from 'svelte';
/** Inline formatting a text node can carry. Applied in this order, outermost first. */
export type RichMark = 'strong' | 'em' | 'code';
/** A run of plain text. Text is always rendered as text, never as HTML. */
export interface RichTextNode {
type: 'text';
text: string;
marks?: RichMark[];
}
/**
* A link around inline content. Only http, https, mailto and tel URLs, and relative paths and
* fragments, become links; anything else (javascript:, data:) renders its text without one.
*/
export interface RichLinkNode {
type: 'link';
href: string;
/** Link text. A link nested inside another keeps its text and loses its own anchor. */
children: RichInline[];
/** Open in a new tab. Adds target="_blank" and a visually hidden "(opens in new tab)". */
newTab?: boolean;
}
export type RichInline = RichTextNode | RichLinkNode;
export interface RichParagraphNode {
type: 'paragraph';
children: RichInline[];
}
export interface RichHeadingNode {
type: 'heading';
/** Rank inside the content. 1 is treated as 2; the rendered element adds headingOffset. */
level?: 1 | 2 | 3 | 4 | 5 | 6;
children: RichInline[];
/** Optional id for anchor links. Keep it unique on the page. */
id?: string;
}
/** One list item: inline content, optionally followed by a nested list. */
export interface RichListItemNode {
type: 'item';
children: (RichInline | RichListNode)[];
}
export interface RichListNode {
type: 'list';
/** Numbered list (ol) instead of bullets (ul). */
ordered?: boolean;
/** First number of an ordered list. */
start?: number;
children: RichListItemNode[];
}
export interface RichQuoteNode {
type: 'quote';
/** Paragraphs, or inline content that is read as one paragraph. */
children: (RichParagraphNode | RichInline)[];
/** Who said it, shown under the quote. */
attribution?: string;
/** Source URL, set as the blockquote's cite attribute. */
cite?: string;
}
export interface RichImageNode {
type: 'image';
src: string;
/** Text alternative. Leave it out only with decorative: true. */
alt?: string;
/** Marks an image that adds nothing the text does not say; it renders with alt="". */
decorative?: boolean;
caption?: string;
/** Intrinsic size in pixels. Supply both so the space is reserved before the image loads. */
width?: number;
height?: number;
}
export interface RichCodeNode {
type: 'code';
/** The code, rendered as text with whitespace kept. */
text: string;
/** Language name, for a label and for a renderer override that highlights. */
language?: string;
caption?: string;
}
export interface RichRuleNode {
type: 'rule';
}
/** A top-level block. */
export type RichBlock =
| RichParagraphNode
| RichHeadingNode
| RichListNode
| RichQuoteNode
| RichImageNode
| RichCodeNode
| RichRuleNode;
/** Any node the component renders. */
export type RichNode = RichBlock | RichInline | RichListItemNode;
export type RichNodeType = RichNode['type'];
/** What an override receives besides the node. */
export interface RichRenderContext {
/**
* The default rendering of the node's children: the inline content of a paragraph, heading
* or link, the items of a list, the paragraphs of a quote. Empty for images, code and rules.
*/
children: Snippet;
/** For headings: the element level after headingOffset, 2 to 6. */
level?: 2 | 3 | 4 | 5 | 6;
/** For links: the href after the safety check, or undefined when it was refused. */
href?: string;
}
/** Override snippets, one per node type. Each replaces the default output for that type. */
export type RichRenderers = {
[K in RichNodeType]?: Snippet<[Extract<RichNode, { type: K }>, RichRenderContext]>;
};
export type RichDensity = 'spacious' | 'compact';
Usage#
On this pagePass content as an array of plain nodes and it renders them as an article body. It does not parse Markdown or HTML strings, render embeds or iframes, highlight code or fetch and resize images: convert your CMS format to the node shape, and use a renderer override for highlighting or an image component.
- Suggested location
src/lib/components/rich-text-display-01- Required props
content
Limitations
- No Markdown or HTML parser is bundled. Convert Portable Text, mdast, Lexical or your CMS's format to the node shape first;
usage.mdshows a small mdast converter. - Embeds, tables and any other node type render nothing. Report them with
onUnknownNode, or add the type yourself as a part inBlocks.svelte. - Code is rendered as plain text. Highlighting belongs in a
renderers.codeoverride. - Images are rendered as given, lazily, with width and height when the content supplies them. Without both, the space is not reserved before the image loads.
- The missing-alt warning uses
import.meta.env.DEV, so it appears only in development under Vite or another bundler that defines it. - Tones and hairlines default to mixes of the surrounding text colour. If that colour is light grey, set
--rich-text-display-inkand--rich-text-display-muted.
Example
<script lang="ts">
import RichText from '$lib/components/rich-text-display-01/RichText.svelte';
import type { RichNode } from '$lib/components/rich-text-display-01/types';
const content: RichNode[] = [
{ type: 'heading', level: 2, children: [{ type: 'text', text: 'Start from milestones' }] },
{
type: 'paragraph',
children: [
{ type: 'text', text: 'Every task hangs off a ' },
{ type: 'text', text: 'milestone', marks: ['strong'] },
{ type: 'text', text: '. Read the ' },
{ type: 'link', href: '/docs/milestones', children: [{ type: 'text', text: 'guide' }] },
{ type: 'text', text: '.' }
]
}
];
</script>
<h1 class="text-4xl font-semibold">How we plan a quarter</h1>
<RichText {content} class="mt-10" onUnknownNode={(node) => console.warn('Unsupported node', node)} />The source imports its own files by relative path only. Examples import from $lib/...; SvelteKit 3 projects have no $lib alias by default, so import from your #lib subpath (with the .svelte extension) or a relative path instead.
Rich-text display#
An article body from structured content. You pass an array of plain nodes; it renders them through a fixed set of Svelte parts, one per node type, and never treats text as HTML. Use it for blog posts, help articles, CMS pages and product descriptions.
<script lang="ts">
import RichText from '$lib/components/rich-text-display-01/RichText.svelte';
import type { RichNode } from '$lib/components/rich-text-display-01/types';
let { data } = $props();
const content: RichNode[] = data.article.body;
</script>
<article class="mx-auto max-w-3xl px-4">
<h1 class="text-4xl font-semibold tracking-tight">{data.article.title}</h1>
<RichText {content} class="mt-10" />
</article>The node shape#
type RichInline =
| { type: 'text'; text: string; marks?: ('strong' | 'em' | 'code')[] }
| { type: 'link'; href: string; children: RichInline[]; newTab?: boolean };
type RichBlock =
| { type: 'paragraph'; children: RichInline[] }
| { type: 'heading'; level?: 1 | 2 | 3 | 4 | 5 | 6; children: RichInline[]; id?: string }
| { type: 'list'; ordered?: boolean; start?: number; children: RichListItem[] }
| { type: 'quote'; children: (RichParagraph | RichInline)[]; attribution?: string; cite?: string }
| {
type: 'image';
src: string;
alt?: string;
decorative?: boolean;
caption?: string;
width?: number;
height?: number;
}
| { type: 'code'; text: string; language?: string; caption?: string }
| { type: 'rule' };
// A list item holds inline content, optionally followed by a nested list.
type RichListItem = { type: 'item'; children: (RichInline | RichList)[] };types.ts has the exact types with comments. Anything else in the tree, or a node missing a
field it needs, is passed to onUnknownNode and renders nothing. Empty paragraphs and lists
are dropped quietly, so they never leave a gap.
The layout#
The root is a two-column grid. Text sits in the first column, minmax(0, 30em), which holds
about 65 characters to the line at either density. Images and code blocks take
col-span-full and step out to the root's full width, so the article reads as set type with
framed media rather than a stack of blocks of equal width. Give the root a max width to decide
how far they step out; on a phone both columns are the screen.
Spacing is four steps, set as variables on the root: items in a list, a heading to its text,
one paragraph to the next, and the larger space before a heading and around figures and rules.
The rules target the root's direct children inside @layer components, so your override
markup gets the same rhythm, and a margin class on it still wins.
Headings#
A heading's level picks its size; headingOffset is added to pick its element. With the page
title as the h1, leave the offset at 0 and content level 2 renders as h2. Under a page that
already uses h2 for the section, pass headingOffset={1}: the same node becomes an h3 and
looks the same. A level 1 node is read as level 2, and nothing goes past h6.
Overrides#
Every node type can be replaced with a snippet. It receives the node and a context: children
renders the node's children the default way, level is the heading element's level, and
href is the link's href after the safety check.
{#snippet code(node, { children })}
<Highlighted language={node.language} code={node.text} class="col-span-full" />
{/snippet}
{#snippet heading(node, { children, level })}
<svelte:element this={`h${level}`} id={node.id} class="text-2xl font-semibold">
<a href={`#${node.id}`}>{@render children()}</a>
</svelte:element>
{/snippet}
<RichText {content} renderers={{ code, heading }} />Links#
Only http, https, mailto and tel URLs, relative paths and fragments become links. A
javascript: or data: href renders the link's text without a link. Absolute http and
https links, and links with newTab, get rel="noopener noreferrer" (change it with
linkRel). newTab also adds visually hidden "(opens in new tab)" text.
Converting from Markdown or a CMS#
No parser is bundled. A converter from mdast (the tree remark produces) is a few lines:
import type { RichBlock, RichInline } from '$lib/components/rich-text-display-01/types';
function inline(node: any, marks: ('strong' | 'em' | 'code')[] = []): RichInline[] {
switch (node.type) {
case 'text':
return [{ type: 'text', text: node.value, marks }];
case 'inlineCode':
return [{ type: 'text', text: node.value, marks: [...marks, 'code'] }];
case 'strong':
case 'emphasis': {
const mark = node.type === 'strong' ? 'strong' : 'em';
return node.children.flatMap((child: any) => inline(child, [...marks, mark]));
}
case 'link':
return [
{
type: 'link',
href: node.url,
children: node.children.flatMap((c: any) => inline(c, marks))
}
];
default:
return [];
}
}Blocks follow the same pattern: paragraph, heading (depth becomes level), list and
listItem, blockquote, image, code (value becomes text, lang becomes language)
and thematicBreak as rule. Portable Text maps the same way: block with a style becomes a
paragraph or heading, listItem blocks are grouped into lists, and markDefs become links.
Colour#
Every tone is a mix of the surrounding text colour: muted 68%, hairlines 12%, the code surface 4%. On a dark band, set the band's text colour and leave the tokens alone:
<section class="bg-zinc-950 text-zinc-50">
<RichText {content} />
</section>On a tinted page, set the text colour to your ink (a warm brown on cream) and the rest follows.
To tie links to your buttons, set --rich-text-display-accent.
Props and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
content | RichNode[] | Yes | None | The content tree as plain data: paragraph, heading, list (of item), quote, image, code and rule blocks, holding text and link inline nodes. Text is never read as HTML. |
headingOffset | 0 | 1 | 2 | No | 0 | Added to each heading's level to pick its element, clamped to h2 to h6. The size follows the level in the content, so the look does not change. |
renderers | RichRenderers | No | None | Snippets keyed by node type (paragraph, heading, list, item, quote, image, code, rule, text, link). Each receives the node and a context with a children snippet holding the default rendering of its children, plus level for headings and the checked href for links. |
linkRel | string | No | 'noopener noreferrer' | rel on links to another site (absolute http or https) and on links that open a new tab. An empty string sets none. |
onUnknownNode | (node: unknown) => void | No | None | Called with each node of an unsupported type, or missing a field it needs (an image with no usable src). Those nodes render nothing. Runs during the server render and again in the browser. |
density | 'spacious' | 'compact' | No | 'spacious' | Spacious is 16 px text on 28 px leading for articles; compact is 14 px on tighter spacing for product descriptions and panels. |
class | string | No | None | Extra classes on the root, for width and placement. The root renders only when there is content. |
Customization#
On this pageRetone it with six --rich-text-display-* variables; by default everything is mixed from the surrounding text colour, so on a dark or tinted page set the text colour and leave the tokens alone. Override any node type with a snippet through renderers.
- Colour: the text takes the surrounding colour; muted is a 68% mix of it, hairlines 12% and the code surface 4%. On a dark band set the band's text colour (
text-zinc-50onbg-zinc-950) and every tone follows. - Links: by default they are the text colour with a 40% underline that turns solid on hover. Set
--rich-text-display-accentto your brand colour (the blue palette uses#1d4ed8) and keep it at 4.5:1 against the background; it also colours the focus ring. - Quote rule:
--rich-text-display-quote-bordercolours the one-pixel line beside quotes; it is the text colour by default. - Measure: the reading column is minmax(0, 30em) in the root's grid, about 65 characters. Images and code use
col-span-fullto step out to the root's full width; give the root a max width (max-w-3xl) to set how far. - Rhythm: four spacing steps are variables on the root (--_flow-item, --_flow-tight, --_flow, --_flow-section), with smaller values under .rich-text-display--compact. Change them there rather than adding margins to parts.
- Overrides:
renderers.codereceives the node and a context; render your highlighter's output and addcol-span-fullto keep the step-out. Your markup gets the same rhythm as the defaults because the spacing rules target the root's direct children. - New node types: add a part under parts/, a case in
Blocks.svelteandprepare.ts, and the type totypes.ts.
Public CSS variables
| Variable | Token |
|---|---|
--rich-text-display-accent | accent |
--rich-text-display-ink | ink |
--rich-text-display-muted | muted |
--rich-text-display-hairline | hairline |
--rich-text-display-surface | surface |
--rich-text-display-quote-border | quoteBorder |
Accessibility#
On this page- Headings render as h2 to h6 only: a level 1 node is treated as level 2, and
headingOffsetshifts them under your page heading. Keep one h1 on the page yourself. - Lists are native ul and ol with li children; ordered lists keep their start number. Quotes are a blockquote inside a figure, with the attribution in a figcaption and the source URL in cite.
- Images with
decorative: truerender alt="". An image with no alt that is not marked decorative renders without an alt attribute, so audits catch it, and logs a warning in development. - Links are underlined at rest, so they do not rely on colour (WCAG 1.4.1). Links with
newTabadd target="_blank" and visually hidden text "(opens in new tab)". Focus shows a 2 px ring in the accent with a 2 px offset. - Code blocks are focusable regions named by their caption and language, so a keyboard user can scroll a long line into view. Code is set left to right inside right-to-left content.
- Long words and URLs wrap with overflow-wrap: anywhere, so content never forces horizontal scrolling at 320 px.
- Letter-spacing is removed under dir="rtl" and for Chinese, Japanese and Korean; Arabic and CJK get more leading. Set lang and dir on the root or an ancestor for content in another language.
Known limitations
- The component cannot check that alt text is accurate or that headings are in order; that is the content's job.
- Colour contrast follows the surrounding text colour and is not checked by the component; check any token you set.
Release details#
On this page- Integration
- Presentational
- Works without client-side JavaScript
- Server-side rendering supported
- Dependencies
- No additional runtime packages beyond Svelte and Tailwind CSS
- License
MIT. Default license approval is pending; see the license status before adopting the source.
- Version history
- 1.0.0 (Published) Current release · 21 October 2026
Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.