fuz_ui

friendly user zystem 🧶

Svelte UI library

repo 0.200.0 npm

npm i -D @fuzdev/fuz_ui

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 ], };

fuz_ui is part of the Fuz stack. See also the repo.

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:

  • adds imports required by the rendered content (e.g., DocsLink, Code, resolve, configured components)
  • removes the Mdz import when all usages are transformed
  • removes dead const bindings consumed only by transformed content

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 content prop 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:

  • name
  • version
  • private
  • description
  • tagline: Fuz extension, like description but snappier
  • glyph: Fuz extension, emoji or character icon
  • logo: Fuz extension, logo image path
  • logo_alt: Fuz extension, logo alt text
  • license
  • homepage
  • repository
  • funding
  • exports

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.

  1. 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; null throws (avoid the null/undefined footgun where a conditional silently disables defaults).
  2. extend — sources to append per directive, layered left to right. Values append (and deduplicate) to the result of replace_defaults and 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.
  3. overrides — final-pass per-directive replace or remove. Highest precedence. Pass null to 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, or overrides throw with the offending name.
  • Extending a directive whose current value is ['none'] throws. Opt in via replace_defaults or overrides instead.
  • null for replace_defaults (top-level or per-key) throws. Omit the option for library defaults, pass {} to start blank, or use overrides to remove a specific directive.
  • null per-key in extend throws with a pointer to overrides. extend only appends, so removal lives on overrides.
  • undefined per-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 native TypeError.
  • 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 to default-src, widening the policy.
  • Source arrays are validated to contain only strings. Non-string elements (slipped through via as any) would render as undefined or [object Object] in the emitted header.

Directive specs
#

The exported csp_directive_specs has JSON data about the CSP directives. Fuz omits deprecated directives.

directivefallbackfallback of
default-srcscript-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-srcdefault-srcscript-src-elem, script-src-attr, worker-src
script-src-elemscript-src, default-src
script-src-attrscript-src, default-src
style-srcdefault-srcstyle-src-elem, style-src-attr
style-src-elemstyle-src, default-src
style-src-attrstyle-src, default-src
img-srcdefault-src
media-srcdefault-src
font-srcdefault-src
manifest-srcdefault-src
child-srcdefault-srcframe-src, worker-src
connect-srcdefault-src
frame-srcchild-src
frame-ancestors
form-action
worker-srcchild-src, script-src, default-src
object-srcdefault-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 mdz.ts:

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

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:// or http://
  • 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.

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>
use it or lose it

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>

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
#

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>

Breadcrumb #

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>

With a custom icon
#

<Card> custom<br /> icon {#snippet icon()}📖{/snippet} </Card>

As a link
#

<Card href="/"> a<br /> link </Card>
🔗
a
link

As the selected link
#

<Card href="/docs/Card"> href is<br /> selected </Card>
🔗
href is
selected

With a custom HTML tag
#

<Card tag="button"> custom<br /> tag </Card>

With custom alignment
#

<Card align="right"> align<br /> icon right </Card>
<Card align="above"> align<br /> icon above </Card>
<Card align="below"> align<br /> icon below </Card>

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:

alert A -- rightclick or longpress here to open the contextmenu

alert B -- also inherits A

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

Selected root component:

Basic usage
#

Try opening the contextmenu on this panel with rightclick or tap-and-hold.

<ContextmenuRoot scoped> <Contextmenu> {#snippet entries()} <ContextmenuEntry run={() => (greeted = !greeted)}> Hello world <!-- false --> </ContextmenuEntry> <ContextmenuEntry run={() => (greeted_icon_snippet = !greeted_icon_snippet)}> {#snippet icon()}🌞{/snippet} Hello with an optional icon snippet <!-- false --> </ContextmenuEntry> <ContextmenuEntry run={() => (greeted_icon_string = !greeted_icon_string)} icon="🌚"> Hello with an optional icon string <!-- false --> </ContextmenuEntry> {/snippet} ...markup with the above contextmenu behavior... </Contextmenu> ...markup with only default contextmenu behavior... </ContextmenuRoot> ...markup without contextmenu behavior...
greeted = false
greeted_icon_snippet = false
greeted_icon_string = false

Default behaviors
#

<ContextmenuRoot scoped> ...<a href="https://ui.fuz.dev/"> a link like this one </a>... </ContextmenuRoot>

Opening the contextmenu on a link like this one has special behavior by default. To accesss your browser's normal contextmenu, open the contextmenu on the link inside the contextmenu itself or hold Shift.

Although disruptive to default browser behavior, this allows links to have contextmenu behaviors, and it allows you to open the contextmenu anywhere to access all contextual behaviors.

Select text
#

When the Fuz contextmenu opens and the user has selected text, the menu includes a copy text entry.

Try and then opening the contextmenu on it.

Opening the contextmenu on an input or textarea opens the browser's default contextmenu.

contenteditable likewise has your browser's default contextmenu behavior.

contenteditable

contenteditable="plaintext-only"

Disable default behaviors
#

Check the boxes below to disable automatic a link detection and copy text detection, and see how the contextmenu behaves.

<ContextmenuRoot>

Try and opening the contextmenu in this panel.

Try opening the contextmenu on this link.

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

Custom instance
#

The ContextmenuRoot prop contextmenu accepts a custom ContextmenuState instance, allowing you to observe its reactive state and control it programmatically.

const contextmenu = new ContextmenuState(); <ContextmenuRoot {contextmenu} scoped>...</ContextmenuRoot>

Try opening the contextmenu on this panel, then use the navigation buttons below to cycle through entries -- just like the arrow keys. The color entries return {ok: true, close: false} to keep the menu open after activation, allowing you to select multiple colors using the activate button (↵).

Reactive state:

  • contextmenu.opened === false
  • contextmenu.x === 0 && contextmenu.y === 0

Full example
#

🏠
😺Alyssa
😸Ben
🌄
View example source

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.vibrate haptic 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 contextmenu event 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

Selected root component:

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 a links 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+f does 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 content

With 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 details

Dialog #

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
#

<HueInput bind:value />
bind:value === 180

With 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} />
fuz_ui 🧶
Svelte UI library
friendly user zystem
npm i -D @fuzdev/fuz_ui
homepage repo npm version license
  • 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} />
    fuz_ui

    friendly user zystem 🧶

    Svelte UI library

    repo 0.200.0 npm

    npm i -D @fuzdev/fuz_ui

    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
    #

    <PendingAnimation inline />

    with inline={}

    With custom children
    #

    <PendingAnimation --font_size="var(--font_size_xl6)"> {🐢} </PendingAnimation>

    with children

    🐢🐢🐢

    With children index prop
    #

    <PendingAnimation> {#snippet children(index)} <div class="box"> {🐸} {index} <span class="font_size_xl5"> {} </span>} </div> {/snippet} </PendingAnimation>

    with running={}

    and children

    🐸 0
    🐸 1
    🐸 2

    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/docs

    Svg #

    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.