introduction #
fuz_ui is a Svelte UI library with components and helpers for making zippy websites. It's built on fuz_css and provides includes a documentation system built on svelte-docinfo. It's in early alpha with breaking changes ahead.
Usage #
npm i -D @fuzdev/fuz_ui To import the Svelte components:
<script>
import Alert from '@fuzdev/fuz_ui/Alert.svelte';
import Card from '@fuzdev/fuz_ui/Card.svelte';
import Dialog from '@fuzdev/fuz_ui/Dialog.svelte';
</script> See the nav for the available components and other helpers.
mdz is a markdown dialect enhanced with Svelte components, autolinked identifiers, and other integrations. To compile static content at build time instead of parsing at runtime with slower dynamic rendering, use svelte_preprocess_mdz:
// svelte.config.js
import {svelte_preprocess_mdz} from '@fuzdev/fuz_ui/svelte_preprocess_mdz.js';
export default {
preprocess: [
svelte_preprocess_mdz(),
// ...other preprocessors
],
};theming #
Fuz bases its theme support on fuz_css, which is based on semantic HTML and CSS custom properties. For usage docs see ThemeRoot.
api #
Browse the full api docs.
svelte_preprocess_mdz #
svelte_preprocess_mdz is a Svelte preprocessor that compiles static mdz content to Svelte markup at build time. Instead of parsing mdz at runtime and rendering dynamically, the preprocessor replaces the Mdz component with MdzPrecompiled containing pre-rendered children.
Setup #
Add the preprocessor, svelte_preprocess_mdz.ts, to svelte.config.js:
import {svelte_preprocess_mdz} from '@fuzdev/fuz_ui/svelte_preprocess_mdz.js';
export default {
preprocess: [
svelte_preprocess_mdz({
components: {Alert: '$lib/Alert.svelte'},
elements: ['aside', 'details'],
}),
// ...other preprocessors
],
}; The preprocessor should run before other preprocessors like vitePreprocess() so it can parse the original Svelte source. The input to svelte_preprocess_mdz is SveltePreprocessMdzOptions.
How it works #
The preprocessor transforms static content at build time:
<!-- Before -->
<Mdz content="**bold** and `some_fn`" />
<!-- After -->
<MdzPrecompiled><p><strong>bold</strong> and <DocsLink reference={'some_fn'} /></p></MdzPrecompiled> For ternary expressions with static branches, it generates Svelte control flow:
<!-- Before -->
<Mdz content={show ? '**a**' : '**b**'} />
<!-- After -->
<MdzPrecompiled>{#if show}<p><strong>a</strong></p>{:else}<p><strong>b</strong></p>{/if}</MdzPrecompiled> The preprocessor also manages imports automatically:
What gets transformed #
The preprocessor handles these static content patterns:
- string attributes:
content="**bold**" - JS string expressions:
content={'**bold**'} - template literals without interpolation:
content={`**bold**`} - const variable references:
const msg = '**bold**';content={msg} - ternary chains:
content={show ? '**a**' : '**b**'} - nested ternaries:
content={a ? 'x' : b ? 'y' : 'z'}
Relative paths and the base attribute #
Content with relative auto-links (./grammar, ../spec) needs to know
its base path to resolve those at compile time. Add a static base attribute to
the Mdz tag:
<Mdz base="/docs/mdz/" content="see ./grammar and ../spec" /> The preprocessor reads base, resolves relative paths to absolute via resolve_relative_path(), and emits the resolved href values. base must be a static string literal — dynamic expressions cause the call to fall back
to runtime rendering.
Without base, relative paths are kept as raw hrefs and the browser resolves them
against the current URL at click time. This is a preprocessor-only attribute; at runtime Mdz accepts a base prop with the same meaning.
Skip conditions #
The preprocessor falls back to runtime rendering when:
- the file is excluded via
exclude - no matching import source is found for Mdz
- the import is
import type(not a runtime import) - MdzPrecompiled is already imported from a different source
- the
contentprop is dynamic (variable, function call,$state,$derived) - spread attributes are present (
{...props}) - content references unconfigured components or elements
- a ternary branch has dynamic content or unconfigured tags
vite_plugin_pkg_json #
vite_plugin_pkg_json is a Vite plugin that serves a publish-safe subset
of your package.json as the virtual module 'virtual:pkg.json'. The default export is typed PkgJson from fuz_util, and contains
package identity plus Fuz extension fields, with everything else excluded.
The plugin strips package.json to the allowlist and serves only that, so info
like name/version/repository and the Fuz extension fields like logo are available to your
code. The docs system around the content you're reading relies on it. Importing the root package.json directly instead inlines the whole file -- scripts, dependencies, private config -- into the client bundle and trips SvelteKit's server.fs.allow on a cold HMR reload; serving the curated subset avoids both.
Setup #
Register the plugin, vite_plugin_pkg_json.ts:
// vite.config.ts
import {defineConfig} from 'vite';
import {sveltekit} from '@sveltejs/kit/vite';
import {vite_plugin_pkg_json} from '@fuzdev/fuz_ui/vite_plugin_pkg_json.js';
export default defineConfig({
plugins: [sveltekit(), vite_plugin_pkg_json()],
}); The plugin uses enforce: 'pre' so any order works. For TypeScript it requires
ambient declarations, like in src/app.d.ts:
// src/app.d.ts
declare module 'virtual:pkg.json' {
import type {PkgJson} from '@fuzdev/fuz_util/pkg_json.js';
const pkg_json: PkgJson;
export default pkg_json;
} You may then import the default export anywhere in client or server code:
import pkg_json from 'virtual:pkg.json';Usage #
fuz_ui has optional patterns that leverage the feature. One example is adding SiteState at the root layout, so glyph and repo_url come from package.json instead of being
hardcoded:
// +layout.svelte or some other root
import pkg_json from 'virtual:pkg.json';
import {SiteState, site_context} from '@fuzdev/fuz_ui/site.svelte.js';
// glyph + repo_url derive from pkg_json.glyph and pkg_json.repository
site_context.set(new SiteState({pkg_json})); It's also the curated pkg_json half of a LibraryJson rendered by LibraryDetail. The fuz_ui docs pattern combines it with the analyzed modules from virtual:svelte-docinfo (svelte-docinfo.fuz.dev):
// src/routes/library.ts
import {library_json_from_modules} from '@fuzdev/fuz_util/library_json.js';
import {modules} from 'virtual:svelte-docinfo';
import pkg_json from 'virtual:pkg.json';
export const library_json = library_json_from_modules(pkg_json, modules);What gets served #
By default the plugin keeps only the keys in pkg_json_keys, including package identity values and some Fuz extension fields. Everything else is
dropped:
nameversionprivatedescriptiontagline: Fuz extension, like description but snappierglyph: Fuz extension, emoji or character iconlogo: Fuz extension, logo image pathlogo_alt: Fuz extension, logo alt textlicensehomepagerepositoryfundingexports
Custom keys #
The set of picked fields defaults to pkg_json_keys, and you can extend or replace them:
// src/routes/pkg_json_keys.ts — one shared const for all three sites
import {pkg_json_keys} from '@fuzdev/fuz_util/pkg_json.js';
export const custom_keys = [...pkg_json_keys, 'keywords'] as const; Because library_json_from_modules re-strips at runtime, the same list must reach
all three places (the plugin, that runtime call, and the virtual:pkg.json ambient type), or the extras get dropped:
// vite.config.ts
vite_plugin_pkg_json({keys: custom_keys});
// src/routes/library.ts
library_json_from_modules(pkg_json, modules, custom_keys); For type safety, widen the src/app.d.ts ambient type to match -- the same custom_keys const drives it via Pick over PackageJson:
// src/app.d.ts
declare module 'virtual:pkg.json' {
import type {PackageJson} from '@fuzdev/fuz_util/package_json.js';
import type {custom_keys} from '$routes/pkg_json_keys.js';
const pkg_json: Pick<PackageJson, (typeof custom_keys)[number]>;
export default pkg_json;
}csp #
Fuz supports SvelteKit's config for Content Security Policies with the create_csp_directives helper. Fuz also provides related helpers, types, and CSP data.
The API is designed to read as an audit log: every user-added source is named at exactly one
site in the source code. There's no implicit promotion of sources across directives. If you
want a domain on script-src, you write script-src. Library defaults
are inherited unless you opt out via replace_defaults.
Example usage:
import {create_csp_directives, type CspDirectives} from '@fuzdev/fuz_ui/csp.js';
// Default CSP — restrictive defaults from `csp_directive_value_defaults`.
const csp = create_csp_directives();
// Use in svelte.config.js:
// export default {kit: {csp: {directives: csp}}}
// Layer in your own sources per directive:
const csp_with_sources = create_csp_directives({
extend: [
{
'img-src': ['https://*.my.domain/'],
'connect-src': ['https://api.my.domain/'],
// Putting a source on script-src requires naming script-src here.
'script-src': ['https://cdn.my.domain/'],
},
],
});
// Compose multiple "shared lib" objects (e.g. a vendor's directive map plus your own):
import {csp_directives_of_fuzdev} from '@fuzdev/fuz_ui/csp_of_fuzdev.js';
const csp_composed = create_csp_directives({
extend: [
csp_directives_of_fuzdev,
{'connect-src': ['https://api.my.domain/']},
],
});
// Replace a directive wholesale via the final-pass `overrides`:
const csp_replaced = create_csp_directives({
extend: [{'connect-src': ['https://api.my.domain/']}],
overrides: {
// Wins over extend; `null` removes a directive entirely.
'frame-src': ['none'],
'report-to': null,
},
});
// Start from your own defaults instead of the library defaults:
const csp_custom_defaults = create_csp_directives({
replace_defaults: {
'default-src': ['none'],
'script-src': ['self'],
'connect-src': ['self', 'https://api.my.domain/'],
},
// `extend` and `overrides` still layer on top.
});
// Start blank — fully declarative, no library defaults at all:
const csp_blank = create_csp_directives({
replace_defaults: {},
overrides: {
'script-src': ['self'],
'img-src': ['self', 'data:'],
},
});Pipeline #
Three stages run in order, and each is independent. Use the one that matches your intent.
- CreateCspDirectivesOptions
replace_defaults— the starting state. Omitted, it's csp_directive_value_defaults. Provided, it replaces the library defaults wholesale: exactly the directives you list, nothing inherited.{}starts blank;nullthrows (avoid the null/undefined footgun where a conditional silently disables defaults). extend— sources to append per directive, layered left to right. Values append (and deduplicate) to the result ofreplace_defaultsand prior entries. Boolean directives (e.g.upgrade-insecure-requests) are excluded by the type. Only array-typed directives can be extended. Compose multiple shared maps in one array.overrides— final-pass per-directive replace or remove. Highest precedence. Passnullto drop a directive from the output entirely.
Adding sources via extend #
extend is the common path: take a starting state and add per-directive sources. Sources
land only on the directives you name. There's no cross-directive promotion.
create_csp_directives({
extend: [
{
'img-src': ['https://cdn.fuz.dev/'],
'connect-src': ['https://api.fuz.dev/'],
},
],
}); Multiple entries compose left to right, deduplicating across layers. Useful for combining a "shared lib" object with app-specific extras:
import {csp_directives_of_fuzdev} from '@fuzdev/fuz_ui/csp_of_fuzdev.js';
create_csp_directives({
extend: [
csp_directives_of_fuzdev,
{
'connect-src': ['https://api.fuz.dev/'],
'img-src': ['https://media.fuz.dev/'],
},
],
}); Default-deny directives (those whose default value is ['none'], including default-src, object-src, base-uri, script-src-attr, and child-src) cannot be extended. Attempting to extend them throws. Opting in must go through replace_defaults or overrides so the opt-in is visible at the call site. Note that overrides cannot rescue an extend for a default-deny directive in
the same call: extend runs first and throws before overrides would replace the
value. Move the sources into overrides directly, or opt in via replace_defaults and then extend.
Replacing values via overrides #
The final-pass overrides option replaces a directive's value or removes it
entirely. Highest precedence. Wins over replace_defaults and extend.
create_csp_directives({
extend: [{'connect-src': ['https://api.fuz.dev/']}],
overrides: {
// Wholesale replace — drops the connect-src extend output above.
'connect-src': ['self'],
// Remove a directive entirely from the output.
'report-to': null,
// Set a default-deny directive's value explicitly.
'object-src': ['none'],
// Boolean directives are supported.
'upgrade-insecure-requests': false,
},
});Custom defaults via replace_defaults #
replace_defaults sets the starting state. The default is the library's curated csp_directive_value_defaults. To use your own foundation, pass a
complete map. Anything you don't list is absent from the starting state,
including security defaults like default-src: ['none']. Use extend and overrides for per-directive tweaks while keeping the library defaults.
// Fully declarative — no library defaults at all.
const csp = create_csp_directives({
replace_defaults: {
'default-src': ['none'],
'script-src': ['self'],
'connect-src': ['self'],
},
});
// assert.deepEqual(csp, {
// 'default-src': ['none'],
// 'script-src': ['self'],
// 'connect-src': ['self'],
// });
// Same shape as a hand-written directives map — still gets input and output validation.
create_csp_directives({replace_defaults: {}, overrides: {/* ... */}}); Use overrides for tweaks (replace one directive while keeping the library
defaults), and replace_defaults for full ownership of the starting state. null is rejected (top-level or per-key). Omit the option for library defaults,
pass {} to start blank, or use overrides to remove a specific directive.
Validation #
create_csp_directives validates inputs and outputs at build time. Misconfigurations throw rather than producing a silently broken policy.
- Unknown directive keys in any of
replace_defaults,extend, oroverridesthrow with the offending name. - Extending a directive whose current value is
['none']throws. Opt in viareplace_defaultsoroverridesinstead. nullforreplace_defaults(top-level or per-key) throws. Omit the option for library defaults, pass{}to start blank, or useoverridesto remove a specific directive.nullper-key inextendthrows with a pointer tooverrides.extendonly appends, so removal lives onoverrides.undefinedper-key in any of the three stages is treated as omitted (no-op). This lets conditional patterns like{'connect-src': is_prod ? [API_URL] : undefined}work naturally.- Non-object entries in
extend(e.g.extend: [undefined]) throw a library error pointing at the option, instead of a cryptic nativeTypeError. - The output is validated to ensure
'none'never appears alongside other tokens (an invalid CSP that browsers reject). - The output is validated to ensure no directive ends up with an empty array. Use
['none']to forbid all sources, or omit the directive entirely. Empty arrays can be silently dropped or fall back todefault-src, widening the policy. - Source arrays are validated to contain only strings. Non-string elements (slipped through
via
as any) would render asundefinedor[object Object]in the emitted header.
Directive specs #
The exported csp_directive_specs has JSON data about the CSP directives. Fuz omits deprecated directives.
| directive | fallback | fallback of |
|---|---|---|
| default-src | script-src, script-src-elem, script-src-attr, style-src, style-src-elem, style-src-attr, img-src, media-src, font-src, manifest-src, child-src, connect-src, worker-src, object-src | |
| script-src | default-src | script-src-elem, script-src-attr, worker-src |
| script-src-elem | script-src, default-src | |
| script-src-attr | script-src, default-src | |
| style-src | default-src | style-src-elem, style-src-attr |
| style-src-elem | style-src, default-src | |
| style-src-attr | style-src, default-src | |
| img-src | default-src | |
| media-src | default-src | |
| font-src | default-src | |
| manifest-src | default-src | |
| child-src | default-src | frame-src, worker-src |
| connect-src | default-src | |
| frame-src | child-src | |
| frame-ancestors | ||
| form-action | ||
| worker-src | child-src, script-src, default-src | |
| object-src | default-src | |
| base-uri | ||
| upgrade-insecure-requests | ||
| report-to | ||
| require-trusted-types-for | ||
| trusted-types | ||
| sandbox |
intersect #
The intersect helper in intersect.svelte.ts creates an attachment that observes when an element enters or leaves the viewport using the Intersection Observer API.
Uses the lazy function pattern to optimize reactivity: callbacks can update without recreating the observer, preserving state.
import {intersect} from '@fuzdev/fuz_ui/intersect.svelte.js'; <div {@attach intersect(() => ({intersecting}) => {
console.log(intersecting ? 'entered' : 'left');
})}>
scroll me into view
</div> The callback receives intersecting (boolean), intersections (number
count), el, observer, and disconnect.
threshold: 0 (default) #
Triggers when the element enters the viewport by at least a pixel. Scroll to see items change state.
<div {@attach intersect(() => ({
onintersect: ({intersecting, el}) => {
el.classList.toggle('intersecting', intersecting);
}
}))}>
content
</div> - item 0
- item 1
- item 2
- item 3
- item 4
- item 5
- item 6
- item 7
- item 8
- item 9
- item 10
- item 11
- item 12
- item 13
- item 14
threshold: 0.5 #
Triggers when 50% of the element is visible.
<div {@attach intersect(() => ({
onintersect: ({intersecting, el}) => {
el.classList.toggle('intersecting', intersecting);
},
options: {threshold: 0.5}
}))}>
content
</div> - item 0
- item 1
- item 2
- item 3
- item 4
- item 5
- item 6
- item 7
- item 8
- item 9
- item 10
- item 11
- item 12
- item 13
- item 14
threshold: 1 #
Triggers only when the element is fully visible.
<div {@attach intersect(() => ({
onintersect: ({intersecting, el}) => {
el.classList.toggle('intersecting', intersecting);
},
options: {threshold: 1}
}))}>
content
</div> - item 0
- item 1
- item 2
- item 3
- item 4
- item 5
- item 6
- item 7
- item 8
- item 9
- item 10
- item 11
- item 12
- item 13
- item 14
count: 1 #
Disconnects after the first intersection cycle (enter and leave). A count of 0 disables observation. Negative or undefined never disconnects. (the
default)
<div {@attach intersect(() => ({
onintersect: ({intersecting, el}) => {
el.classList.toggle('intersecting', intersecting);
},
count: 1
}))}>
content
</div> - item 0
- item 1
- item 2
- item 3
- item 4
- item 5
- item 6
- item 7
- item 8
- item 9
- item 10
- item 11
- item 12
- item 13
- item 14
count: 2 #
Disconnects after two intersection cycles.
<div {@attach intersect(() => ({
onintersect: ({intersecting, el}) => {
el.classList.toggle('intersecting', intersecting);
},
count: 2
}))}>
content
</div> - item 0
- item 1
- item 2
- item 3
- item 4
- item 5
- item 6
- item 7
- item 8
- item 9
- item 10
- item 11
- item 12
- item 13
- item 14
Configurable #
Try different parameter combinations. Positive count values disconnect after N
cycles. 0 disables observation. Negative or undefined never disconnects. (the
default)
- item 0
- item 1
- item 2
- item 3
- item 4
- item 5
- item 6
- item 7
- item 8
- item 9
- item 10
- item 11
- item 12
- item 13
- item 14
Full API docs at intersect.svelte.ts.
logos #
Fuz includes a number of logos available as data that can be mounted with the Svg component. Only the ones you use are included in your bundle.
-
<Svg data={logo_fuz} /> -
<Svg data={logo_fuz_ui} /> -
<Svg data={logo_fuz_css} /> -
<Svg data={logo_fuz_template} /> -
<Svg data={logo_fuz_code} /> -
<Svg data={logo_fuz_blog} /> -
<Svg data={logo_fuz_mastodon} /> -
<Svg data={logo_fuz_gitops} /> -
<Svg data={logo_fuz_util} /> -
<Svg data={logo_gro} /> -
<Svg data={logo_github} /> -
<Svg data={logo_mdn} />
mdz #
mdz is a small markdown dialect that supports Svelte components, auto-detected URLs prefixed
with https://, /, ./, and ../, and Fuz
integration like linkifying `backtick-wrapped` declarations and modules. The goal
is to securely integrate markdown with the environment's capabilities, while being simple and
friendly to nontechnical users.
mdz prioritizes predictability with one canonical pattern per feature, preferring false negatives over false positives to minimize surprise. It also streams: MdzStreamParser renders incoming chunks (e.g. from an LLM) optimistically as a stream of append-only opcodes, never re-parsing.
For better performance with static content, mdz can be compiled at build time with svelte_preprocess_mdz.
Usage #
import Mdz from '@fuzdev/fuz_ui/Mdz.svelte';Playground #
Bold and italic and strikethrough text.
Inline links to identifiers using backticks: mdz_parse, Mdz
A heading
A paragraph with links: fuz homepage, ./grammar
const y = 1336;Streaming #
Use Mdz when you have complete content upfront. For content that arrives
incrementally (e.g. from an LLM), use MdzStreamParser with MdzStreamState and MdzStream. The
parser emits opcodes as rendering instructions - never re-parsing - and the state applies
them as fine-grained Svelte mutations. This is an implementation of the design described by pngwn in this bluesky post. See streaming and opcodes for the full opcode design
and rendering paths. (temporarily AI-generated)
Try it -- each character is fed one at a time to show how constructs build incrementally:
(press to begin)
opcodes (0)
import {MdzStreamParser} from '@fuzdev/fuz_ui/mdz_stream_parser.js';
import {MdzStreamState} from '@fuzdev/fuz_ui/mdz_stream_state.svelte.js';
const parser = new MdzStreamParser();
const state = new MdzStreamState();
// feed chunks as they arrive
parser.feed(chunk);
state.apply_batch(parser.take_opcodes());
// when done
parser.finish();
state.apply_batch(parser.take_opcodes()); <MdzStream {state} />Basic formatting #
Supports bold, italic, and strikethrough:
<Mdz content="**Bold** and _italic_ and ~strikethrough~ text." /> Bold and italic and strikethrough text.
All inline formatting can nest:
<Mdz content="**~_All_ three~ combi**_ned_" /> All three combined
Inline code auto-linking #
Backtick code automatically links to identifiers and modules:
To parse markdown directly, use `mdz_parse` from module `mdz.ts`. Non-identifiers become plain code elements:
This `identifier` does not exist. This identifier does not exist.
Links #
mdz supports four kinds of links:
- standard markdown link syntax
- external URLs starting with
https://orhttp:// - absolute paths starting with
/ - relative paths starting with
./or../
[Fuz API docs](https://fuz.dev/docs/api) and https://fuz.dev/docs/api and /docs/api Relative paths are resolved against the base context (set via MdzRoot) when provided, producing correct absolute paths. Without base, they use raw hrefs (the browser resolves them against the current URL):
See ./grammar and ./spec and ../mdz for relative paths. Line breaks and paragraphs #
Single newlines create line breaks:
First line.
Second line.
Third line. First line. Second line. Third line.
Double newlines create paragraph breaks:
First paragraph.
Second paragraph.
Linebreak in second paragraph. First paragraph.
Second paragraph. Linebreak in second paragraph.
Triple newlines create paragraphs with a blank line between:
First paragraph.
Second paragraph separated by an extra newline. First paragraph.
Second paragraph separated by an extra newline.
Headings #
Use 1-6 hashes followed by a space:
#### h4 ~with~ _italic_ h4 with italic
Must start at column 0 and have a space after hashes. No blank lines are required around headings. Headings can include inline formatting.
Code blocks #
Use three or more backticks with optional language hint:
```ts
const z: number = 43;
``` const z: number = 43;Must start at column 0 and closing fence must match opening length. No blank lines are required around code blocks.
Horizontal rules #
Use exactly three hyphens (---) at the start of a line to create a horizontal
rule. No blank lines are required around it. mdz has no setext headings, so --- after a paragraph is always an HR:
Section one.
---
Section two. Section one.
Section two.
Preserves whitespace #
mdz preserves and renders all whitespace exactly as written, minimizing surprise for nontechnical users:
<Mdz content=" see
how
whitespace
is preserved " /> see how whitespace is preserved
HTML elements #
mdz supports an opt-in set of HTML elements for semantic markup and styling.
<aside>This is _italicized <code>code</code>_ inside an `aside`.</aside> <marquee>use it or lose it</marquee> Elements must be registered:
<MdzRoot elements={new Map([['code', true], ['aside', true], ['marquee', true]])}>
<Mdz content="<aside>text</aside>" />
</MdzRoot> Unregistered elements render as <tag-name /> placeholders for security.
Svelte components #
mdz supports Svelte components to a minimal (and possibly expanding) degree. Components are distinguished from HTML elements by their uppercase first letter:
<Alert>This is an `Alert` with _italicized <code>code</code>_ inside.</Alert> code inside.Components must be registered:
<MdzRoot components={new Map([['Alert', Alert]])}>
<Mdz content="<Alert>warning</Alert>" />
</MdzRoot> Unregistered components render as <ComponentName /> placeholders.
Advanced usage #
For more control, use mdz_parse directly with MdzNodeView:
import {mdz_parse} from '@fuzdev/fuz_ui/mdz.js';
import MdzNodeView from '@fuzdev/fuz_ui/MdzNodeView.svelte';
const nodes = mdz_parse(content); <div class="custom white-space:pre-wrap">
{#each nodes as node}
<MdzNodeView {node} />
{/each}
</div> For example you may want white-space:pre to avoid wrapping in some circumstances.
Compatibility with other markdowns #
mdz supports fewer syntax variants than CommonMark/GFM:
- bold:
**text**only - italic:
_text_only
In CommonMark, *text* is italic. In mdz, single * has no special meaning
and renders as literal text. This choice creates a clear visual distinction between bold and italics.
Block elements (headings, HR, codeblocks) can interrupt paragraphs without blank lines,
while inline formatting prefers false negatives over false positives. For example, ``` must have no preceding spaces or characters to start a code block.
More docs #
- streaming and opcodes — rendering paths, opcode design, and the streaming model
- specification — feature-by-feature guide with examples
- grammar — formal grammar rules
- fixtures debug page — renders every test fixture
Alert #
import Alert from '@fuzdev/fuz_ui/Alert.svelte'; <Alert>info</Alert> With custom icon #
icon can be a string prop or snippet:
<Alert icon="▷">
icon as a string prop
</Alert> <Alert>
{#snippet icon(t)}{t}◡{t}{/snippet}
icon as a snippet
</Alert> As optional button #
Alerts can be buttons by including an onclick prop. This API may change because
it's a bit of a mess - a separate AlertButton may be better.
<Alert onclick={() => clicks++}>
alerts can be buttons{'.'.repeat(clicks)}
</Alert> clicks: 0
With custom status #
The status prop, which defaults to 'inform', changes the default
icon and color.
// @fuzdev/fuz_ui/alert.js
export type AlertStatus = 'inform' | 'help' | 'error'; <Alert status="error">
the computer is mistaken
</Alert> <Alert status="help">
here's how to fix it
</Alert> <Alert status="help" color="var(--color_d_50)">
the <code>color</code> prop overrides the status color
</Alert> color prop overrides the status colorBreadcrumb #
import Breadcrumb from '@fuzdev/fuz_ui/Breadcrumb.svelte'; <Breadcrumb /> Default icon from context #
The root link defaults to the site_context icon, rendered as an Svg. Set the SiteState once at your app's root
layout and every Breadcrumb picks it up. It falls back to the site_context glyph, then a bullet.
import {SiteState, site_context} from '@fuzdev/fuz_ui/site.svelte.js';
import {logo_fuz} from '@fuzdev/fuz_ui/logos.js';
site_context.set(new SiteState({icon: logo_fuz, glyph: '🧶'}));With custom icon #
<Breadcrumb>🏠</Breadcrumb> With custom separator #
<Breadcrumb>
{#snippet separator()}.{/snippet}
</Breadcrumb> With custom paths #
<Breadcrumb
path="/a/b/c/d"
selected_path="/a/b"
base_path={resolve('/docs/Breadcrumb')}
>
<span class="font_size_xl">🔡</span>
{#snippet separator()}.{/snippet}
</Breadcrumb> Card #
import Card from '@fuzdev/fuz_ui/Card.svelte'; <Card>
just<br />
a card
</Card> a card
With a custom icon #
<Card>
custom<br />
icon
{#snippet icon()}📖{/snippet}
</Card> icon
As a link #
<Card href="/">
a<br />
link
</Card> link
As the selected link #
<Card href="/docs/Card">
href is<br />
selected
</Card> selected
With a custom HTML tag #
<Card tag="button">
custom<br />
tag
</Card> With custom alignment #
<Card align="right">
align<br />
icon right
</Card> icon right
<Card align="above">
align<br />
icon above
</Card> icon above
<Card align="below">
align<br />
icon below
</Card> icon below
Contextmenu #
Introduction #
Fuz provides a customizable contextmenu that overrides the system contextmenu to provide helpful capabilities to users. Popular websites with similar features include Google Docs and Discord. Below are caveats about this breaking some user expectations, and a workaround for iOS Safari support. See also the contextmenu_event docs and w3 spec.
When you rightclick inside a ContextmenuRoot, or longpress on touch devices, it searches the DOM tree for behaviors defined with Contextmenu starting from the target element up to the root. If any behaviors are found, the Fuz contextmenu opens, showing all contextually available actions. If no behaviors are found, the default system contextmenu opens.
Here's a ContextmenuRoot with a Contextmenu inside another Contextmenu:
view code
This simple hierarchical pattern gives users the full contextual actions by default -- not just the actions for the target being clicked, but all ancestor actions too. This means users don't need to hunt for specific parent elements to find the desired action, unlike many systems -- instead, all actions in the tree are available, improving UX convenience and predictability at the cost of more noisy menus. Developers can opt out of this inheritance behavior by simply not nesting Contextmenu declarations, and submenus are useful for managing complexity.
Mouse and keyboard:
- rightclick opens the Fuz contextmenu and not the system contextmenu (minus current exceptions for input/textarea/contenteditable)
- holding Shift opens the system contextmenu, bypassing the Fuz contextmenu
- keyboard navigation and activation should work similarly to the W3C APG menubar pattern
Touch devices:
- longpress opens the Fuz contextmenu and not the system contextmenu (minus current exceptions for input/textarea/contenteditable)
- tap-then-longpress opens the system contextmenu or performs other default behavior like selecting text, bypassing the Fuz contextmenu
- tap-then-longpress can't work for clickable elements like links; longpress on the first contextmenu entry for those cases (double-longpress)
All devices:
- opening the contextmenu on the contextmenu itself shows the system contextmenu, bypassing the Fuz contextmenu
- opening the contextmenu attempts haptic feedback with
navigator.vibrate
Disable default behaviors #
Check the boxes below to disable automatic a link detection and copy text detection, and see how the contextmenu behaves.
<ContextmenuRoot> When no behaviors are defined, the system contextmenu is shown instead.
Expected: the Fuz contextmenu will open and show:
- custom "some custom entry" entry
- "copy text" entry when text is selected
- link entry when clicking on a link
iOS compatibility #
Fuz provides two versions of the contextmenu root component with different tradeoffs due to iOS
Safari not supporting the contextmenu event as of October 2025, see WebKit bug #213953.
Use ContextmenuRoot by default for better performance and haptic feedback. Use ContextmenuRootForSafariCompatibility only if you need iOS Safari support.
ContextmenuRoot
- standard, default implementation
- relies on the browser's contextmenu event
- much simpler, better performance with fewer and less intrusive event handlers, fewer edge cases that can go wrong
- does not work on iOS Safari until WebKit bug #213953 is fixed
ContextmenuRootForSafariCompatibility
- opt-in for iOS
- some browsers (including mobile Chrome) block
navigator.vibratehaptic feedback due to the timeout-based gesture detection (because it's not a direct user action) - implements custom longpress detection to work around iOS Safari's lack of
contextmenuevent support - works on all devices including iOS Safari
- more complex implementation with custom touch event handling and gesture detection
- a longpress is cancelled if you move the touch past a threshold before it triggers
- opt into this version only if you need iOS Safari support
Caveats #
The Fuz contextmenu provides powerful app-specific UX, but it breaks from normal browser behavior by replacing the system contextmenu.
To mitigate the downsides:
- The Fuz contextmenu only replaces the system contextmenu when the DOM tree has defined
behaviors. Note that
alinks have default contextmenu behaviors unless disabled. Other interactive elements may have default behaviors added in the future. - The Fuz contextmenu does not open on elements that allow clipboard pasting like inputs, textareas, and contenteditables -- however this may change for better app integration, or be configurable.
- To bypass on devices with a keyboard, hold Shift while rightclicking.
- To bypass on touch devices (e.g. to select text), use tap-then-longpress instead of longpress.
- Triggering the contextmenu inside the Fuz contextmenu shows the system contextmenu.
See also the contextmenu_event docs and the w3 spec.
Details #
The Details component is an alternative to the details element. By default it's lazy, and you can pass eager to render the children immediately
like the base element.
Benefits of lazy children:
- children are transitioned in/out with an animation (TODO this may be doable with eager
children, if so it would probably be the better default, and then the prop should be swapped
to
lazy) - improved performance, can significantly improve UX in some cases
Tradeoffs:
ctrl+fdoes not work to find text and auto-open the details- you may desire some behavior caused by mounting the children
With lazy rendering by default #
<Details>
{#snippet summary()}summary content{/snippet}
lazy children content
</Details> summary content
With eager rendering #
<Details eager>
{#snippet summary()}summary content{/snippet}
eager children content
</Details> summary content
eager children contentWith the base details element #
<details>
<summary>a summary element instead of a snippet</summary>
the plain details
</details> a summary element instead of a snippet
the plain detailsDialog #
A modal that overlays the entire page. Uses Teleport to allow usage from any component without inheriting styles.
<button onclick={() => (opened = true)}>
open a dialog
</button>
{#if opened}
<Dialog onclose={() => (opened = false)}>
{#snippet children(close)}
<div class="box">
<div class="pane p_xl box">
<h1>attention</h1>
<p>this is a dialog</p>
<button onclick={close}>ok</button>
</div>
</div>
{/snippet}
</Dialog>
{/if}HueInput #
import HueInput from '@fuzdev/fuz_ui/HueInput.svelte';With bind:value #
bind:value<HueInput bind:value /> bind:value === 180With children #
children<HueInput>
Some colorful hue input
</HueInput> Docs #
Docs is the top-level component behind fuz_ui's docs system, which is
used to construct this page, and all of the other /docs pages in the Fuz stack.
It has a three-column responsive layout with managed navigation and uses ordinary SvelteKit
patterns: it takes an array of Tomes and renders the current page
as children, so it lives in a +layout.svelte wrapping your docs routes.
It requires two contexts: site_context (a SiteState for
components like Breadcrumb) set once at the root layout (typically the
root, anywhere up the tree works), and library_context (a Library) set in the docs layout:
<!-- src/routes/+layout.svelte -->
<script lang="ts">
import {SiteState, site_context} from '@fuzdev/fuz_ui/site.svelte.js';
import pkg_json from 'virtual:pkg.json';
site_context.set(new SiteState({pkg_json}));
</script> <!-- src/routes/docs/+layout.svelte -->
<script lang="ts">
import Docs from '@fuzdev/fuz_ui/Docs.svelte';
import {Library, library_context} from '@fuzdev/fuz_ui/library.svelte.js';
import {library_json} from '$routes/library.js';
import {tomes} from '$routes/docs/tomes.js';
const {children} = $props();
library_context.set(new Library(library_json));
</script>
<Docs {tomes}>
{@render children()}
</Docs> For live and complete examples, see the docs layouts in fuz_ui or fuz_css.
Library #
Library is the reactive wrapper around a LibraryJson. It's
constructed from the package.json subset 'virtual:pkg.json' served by vite_plugin_pkg_json plus the analyzed modules from virtual:svelte-docinfo.
import {library_json_from_modules} from '@fuzdev/fuz_util/library_json.js';
import {Library} from '@fuzdev/fuz_ui/library.svelte.js';
import {modules} from 'virtual:svelte-docinfo';
import pkg_json from 'virtual:pkg.json';
const library_json = library_json_from_modules(pkg_json, modules);
const library = new Library(library_json); Set it into library_context at the docs layout. Docs reads it for
navigation, and LibraryDetail and LibrarySummary render
it:
<!-- src/routes/docs/+layout.svelte -->
<script lang="ts">
import {Library, library_context} from '@fuzdev/fuz_ui/library.svelte.js';
import {library_json} from '$routes/library.js';
library_context.set(new Library(library_json));
</script> These docs you're reading are the live example. fuz_ui sets its own Library as described above. See Docs for the surrounding layout, and LibraryDetail/LibrarySummary for the components that render it.
LibraryDetail #
LibraryDetail renders the full metadata for a library and its repo, including
its module and declaration index. See the Library for how to construct one from vite_plugin_pkg_json and virtual:svelte-docinfo:
import LibraryDetail from '@fuzdev/fuz_ui/LibraryDetail.svelte'; <LibraryDetail {library} />npm i -D @fuzdev/fuz_ui
LibrarySummary #
LibrarySummary renders a compact card for a library and its repo. See
the Library for how to construct one from vite_plugin_pkg_json and virtual:svelte-docinfo:
import LibrarySummary from '@fuzdev/fuz_ui/LibrarySummary.svelte'; <LibrarySummary {library} />PendingAnimation #
import PendingAnimation from '@fuzdev/fuz_ui/PendingAnimation.svelte'; <PendingAnimation /> ••• The default animation has text children, so they scale with font-size.
Set size with custom properties:
<PendingAnimation --font_size="var(--font_size_xl5)" /> Set size with classes:
<PendingAnimation attrs={{class: 'font_size_xl3'}} /> Size is inherited by default:
<div class="font_size_xl4"><PendingAnimation /></div> With inline #
inline<PendingAnimation inline /> with inline={} •••
With custom children #
<PendingAnimation --font_size="var(--font_size_xl6)">
{🐢}
</PendingAnimation> with children
With children index prop #
index prop<PendingAnimation>
{#snippet children(index)}
<div class="box">
{🐸}
{index}
<span class="font_size_xl5">
{⏳}
</span>}
</div>
{/snippet}
</PendingAnimation> with running={}
and children
With custom duration #
<PendingAnimation --animation_duration="var(--duration_6)" --font_size="var(--font_size_xl4)" /> PendingButton #
Preserves a button's normal width while animating.
import PendingButton from '@fuzdev/fuz_ui/PendingButton.svelte'; <PendingButton
pending={false}
onclick={() => (pending_1 = !pending_1)}
>
do something async
</PendingButton> <PendingButton
pending={true}
onclick={() => (pending_2 = !pending_2)}
>
do another
</PendingButton> Redirect #
Adds a redirect for a page using a meta tag with the refresh header. Includes a rendered link and JS navigation fallback.
import Redirect from '@fuzdev/fuz_ui/Redirect.svelte'; <Redirect auto={false} /> redirect to /docs
<Redirect
host="https://www.felt.dev"
path="/docs"
let:url
auto={false}
>
the redirect url is {url}
</Redirect> the redirect url is https://www.felt.dev/docsSvg #
import Svg from '@fuzdev/fuz_ui/Svg.svelte'; <Svg data={logo_fuz} /> Fills available space by default:
With custom size #
Set size: (see the fuz_css typography docs)
<Svg data={logo_fuz} size="var(--icon_size_xl)" /> <Svg data={logo_fuz} size="var(--icon_size_sm)" /> Set --font_size on the component or a parent:
<span style:--font_size="var(--icon_size_xl)"><Svg data={logo_fuz} /></span> With custom color #
Set fill: (see the fuz_css colors docs)
<Svg data={logo_fuz} fill="var(--color_d_50)" /> <Svg data={logo_fuz} fill="var(--color_b_50)" /> Set --text_color on the component or a parent, for svgs that have no default fill:
<span style:--text_color="var(--color_i_50)"><Svg data={logo_github} /></span> Teleport #
Relocates elements in the DOM, in the rare cases that's useful and the best solution. The Dialog uses this to mount dialogs from any component without inheriting styles.
import Teleport from '@fuzdev/fuz_ui/Teleport.svelte'; <Teleport to={swap ? teleport_1 : teleport_2}>
🐰
</Teleport>
<div class="teleports">
<div class="panel" bind:this={teleport_1} />
<div class="panel" bind:this={teleport_2} />
</div>
<button onclick={() => (swap = !swap)}>
teleport the bunny
</button>ThemeRoot #
Fuz provides UI components that use fuz_css' theming system for dark mode and custom themes.
ThemeRoot adds global support for both the browser's color-scheme and custom themes based on fuz_css style variables, which use CSS custom properties. ThemeRoot is a singleton component that's mounted at the top-level of the page:
import ThemeRoot from '@fuzdev/fuz_ui/ThemeRoot.svelte'; <!-- +layout.svelte -->
<ThemeRoot>
{@render children()}
</ThemeRoot> Why the singleton?
Why nested children?
Color scheme #
ThemeRoot defaults to automatic color-scheme detection with prefers-color-scheme, and users can also set it directly:
import ColorSchemeInput from '@fuzdev/fuz_ui/ColorSchemeInput.svelte'; <ColorSchemeInput /> Pass props to override the default:
<ColorSchemeInput
value={{color_scheme: 'auto'}}
onchange={...}
/> The builtin themes support both dark and light color schemes. Custom themes may support one or both color schemes.
More about ColorSchemeInput
Builtin themes #
A theme is a simple JSON collection of fuz_css style variables that can be transformed into CSS that set custom properties. Each variable can have values for light and/or dark color schemes. In other words, "dark" isn't a theme, it's a mode that any theme can implement.
Example usage #
Themes are plain CSS that can be sourced in a variety of ways.
To use Fuz's base theme:
<!-- +layout.svelte -->
<script>
import '@fuzdev/fuz_css/style.css';
import '@fuzdev/fuz_css/theme.css';
import ThemeRoot from '@fuzdev/fuz_ui/ThemeRoot.svelte';
import type {Snippet} from 'svelte';
const {children}: {children: Snippet} = $props();
</script>
<!-- enable theme and color-scheme support -->
<ThemeRoot>
{@render children()}
</ThemeRoot> ThemeRoot can be customized with the the nonreactive prop theme_state:
import {ThemeState} from '@fuzdev/fuz_ui/theme_state.svelte.js';
const theme_state = new ThemeState(...); <ThemeRoot {theme_state}>
{@render children()}
</ThemeRoot> ThemeRoot sets the theme_state in the Svelte context:
// get values from the Svelte context provided by
// the nearest `ThemeRoot` ancestor:
import {theme_state_context} from '@fuzdev/fuz_ui/theme_state.svelte.js';
const get_theme_state = theme_state_context.get();
const theme_state = $derived(get_theme_state());
theme_state.theme.name; // 'base'
theme_state.color_scheme; // 'auto' For a more complete example, see fuz_template.
More details #
ThemeRoot initializes the system's theme support. Without it, the page
will not reflect the user's system color-scheme. By default, ThemeRoot applies the base theme
to the root of the page via create_theme_setup_script. It uses JS to add the .dark CSS class to the :root element.
This strategy enables color scheme and theme support with minimal CSS and optimal performance for most use cases. The system supports plain CSS usage that can be static or dynamic, or imported at buildtime or runtime. It also allows runtime access to the underlying data like the style variables if you want to pay the performance costs. Scoped theming to one part of the page is planned.
The theme setup script interacts with sync_color_scheme to save the user's
preference to localStorage. See also ColorSchemeInput.
The setup script avoids flash-on-load due to color scheme, but currently themes flash in after loading. We'll try to fix this when the system stabilizes.