Skip to content
Download ZIP

Neutral palette · 18.8 KB ZIP File receipt View as Markdown View code

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.
cmp_rich_text_display_01 · version 1.0.0 · Neutral palette
Using the PageSugar MCP server, fetch component cmp_rich_text_display_01 version 1.0.0 with variant "neutral", 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
Neutral
Version
1.0.0
Digest
Full digest
sha256-5425b69915b6f550f40c95b55f98cc56ac0d274b85b41c794b54a7795043d5ad
parts/Blocks.svelte Svelte · 928 B Raw
<script lang="ts">
	import type { RichBlock } from '../types';
	import CodeBlock from './CodeBlock.svelte';
	import Figure from './Figure.svelte';
	import Heading from './Heading.svelte';
	import List from './List.svelte';
	import Paragraph from './Paragraph.svelte';
	import Quote from './Quote.svelte';
	import Rule from './Rule.svelte';

	interface Props {
		/** Checked blocks from prepare(); unknown nodes are already gone. */
		blocks: RichBlock[];
	}

	let { blocks }: Props = $props();
</script>

{#each blocks as node, i (i)}
	{#if node.type === 'paragraph'}
		<Paragraph {node} />
	{:else if node.type === 'heading'}
		<Heading {node} />
	{:else if node.type === 'list'}
		<List {node} />
	{:else if node.type === 'quote'}
		<Quote {node} />
	{:else if node.type === 'image'}
		<Figure {node} />
	{:else if node.type === 'code'}
		<CodeBlock {node} />
	{:else if node.type === 'rule'}
		<Rule {node} />
	{/if}
{/each}

Pass 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.md shows 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 in Blocks.svelte.
  • Code is rendered as plain text. Highlighting belongs in a renderers.code override.
  • 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-ink and --rich-text-display-muted.

Example

Svelte
<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.

Svelte
<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#

TypeScript
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.

Svelte
{#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 }} />

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:

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

Svelte
<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
NameTypeRequiredDefaultDescription
contentRichNode[]YesNoneThe 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.
headingOffset0 | 1 | 2No0Added 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.
renderersRichRenderersNoNoneSnippets 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.
linkRelstringNo'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) => voidNoNoneCalled 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.
classstringNoNoneExtra classes on the root, for width and placement. The root renders only when there is content.

Customization#

On this page

Retone 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-50 on bg-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-accent to 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-border colours 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-full to 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.code receives the node and a context; render your highlighter's output and add col-span-full to 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.svelte and prepare.ts, and the type to types.ts.

Public CSS variables

VariableToken
--rich-text-display-accentaccent
--rich-text-display-inkink
--rich-text-display-mutedmuted
--rich-text-display-hairlinehairline
--rich-text-display-surfacesurface
--rich-text-display-quote-borderquoteBorder

Accessibility#

On this page
  • Headings render as h2 to h6 only: a level 1 node is treated as level 2, and headingOffset shifts 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: true render 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 newTab add 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.