fuz

friendly user zystem 🧶

Svelte UI library

npm i -D @ryanatkn/fuz

introduction #

Fuz is a Svelte UI library that builds on my CSS framework Moss. It's in early alpha and there will be many breaking changes. I'm making it to support Zzz and my other projects that focus on end-users, so it'll continue to grow slowly as I find the time and usecases.

The goal is to make a fully-featured Svelte UI library, but it'll take extra time to get there. That lets us prioritize design above features, making it an interesting project for learning and experimentation. For more see the readme, and to get involved see contributing.md.

theming #

Fuz bases its theme support on my CSS framework Moss, which is based on CSS custom properties. For usage docs see Themed.

api #

Browse the full api docs.

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 goal is to provide a simple trust modeling system that balances safety+security+privacy with ergonomics, helping users maintain secure policies without unhelpful burden or restriction. It's restrictive by default and easy to set granular overrides, and there's tiered grants for convenience.

Example usage:

import {create_csp_directives, type CspSourceSpec} from '@ryanatkn/fuz/csp.js'; // Create the default CSP with no trusted sources except 'self' and some sensible fallbacks. // This tries to balance security and privacy with usability, // helping nonexperts write secure policies while still supporting advanced users. // More later on the details of the defaults. const csp = create_csp_directives(); // Use in svelte.config.js: // export default {kit: {csp}} // Create a CSP with some trusted sources, using Fuz's CSP default trust levels: export const my_csp_trusted_sources: Array<CspSourceSpec> = [ // Trust in yourself: {source: 'https://my.domain/', trust: 'high'}, // No scripting allowed on these subdomains: {source: 'https://*.my.domain/', trust: 'medium'}, // Low but allow scripting: {source: 'https://me.github.io/', trust: 'low', directives: ['script-src-elem']}, ]; const csp = create_csp_directives({ trusted_sources: my_csp_trusted_sources, }); // Create a CSP that opts out of using Fuz's trust abstraction: create_csp_directives({ directives: { 'img-src': ['self', 'https://*.my.domain/'], // ...your explicit directives }, // Simply omit `trusted_sources`, // but note the above directives extend the base defaults. }); // Create a CSP with no hidden base defaults, // so it's fully declarative and explicit, // like not using Fuz's CSP helpers at all: const precise_csp = create_csp_directives({ value_defaults_base: null, required_trust_defaults_base: null, value_defaults: { 'img-src': ['self', 'https://my.domain/'], 'connect-src': ['self', 'https://my.domain/'], }, }); // assert.deepEqual(precise_csp, { // 'img-src': ['self', 'https://my.domain/'], // 'connect-src': ['self', 'https://my.domain/'], // }); // Transform/extend directives by passing a function: const custom_csp = create_csp_directives({ trusted_sources: my_csp_trusted_sources, directives: { // Add additional domains to existing values: 'img-src': (v) => [...v, 'trusted.domain'], // extend trusted sources // Or completely replace values: 'connect-src': ['self', 'trusted.domain'], // no base trusted sources! 'connect-src': () => ['self', 'trusted.domain'], // equivalent // Example opt-in to eval: 'script-src-elem': (v) => [...v, 'unsafe-eval', 'wasm-unsafe-eval'], // alert alert // Returning `undefined` or `null` removes the directive, // all other values are passed through to SvelteKit. }, });

Auditability and transparency are key concerns for the API, but some features are designed to help you to trade away some directness for ergonomics, with the idea that we make it easy for nonexpert users to safely configure basic scenarios, and advanced users can opt into using the API with full declarative transparency (and more verbosity and information load).

Fuz defines an optional system with three levels of trust/risk/sensitivity (low/medium/high, CspTrustLevel) that can be configured for each trusted source to give blanket permissions at a specified tier. Granular overrides are straightforward and declarative.

I'm trying to design for full customizability with clear, intuitive boundaries with escalating security and privacy implications. Fuz includes a debatable set of defaults, and input is appreciated to help tune the tradeoffs.

Trust
#

Fuz provides an optional CSP abstraction with three trust levels (of type CspTrustLevel) with tiers of escalating risk and implied permission. Sources can opt-in to blanket permissions at a specific level:

export const my_csp_trusted_sources: Array<CspSourceSpec> = [ {source: 'https://a.domain/'}, // undefined `trust` - same as null {source: 'https://b.domain/', trust: null}, // no trust {source: 'https://c.domain/', trust: 'low'}, // passive resources only {source: 'https://d.domain/', trust: 'medium'}, // no script execution {source: 'https://e.domain/', trust: 'high'}, // arbitrary code execution ];
trust levelwhat it meansconfigured by required_trust_defaults_base
nullNo trust - used for directives that don't support sources'default-src', 'script-src-attr', 'manifest-src', 'child-src', 'object-src', 'base-uri', 'upgrade-insecure-requests', 'report-to', 'require-trusted-types-for', 'trusted-types', 'sandbox'
'low'Passive resources only - no script execution, no styling or UI control'img-src', 'media-src', 'font-src'
'medium'Content that may affect layout, styling, or embed external browsing contexts, but cannot directly run code in the page's JS execution environment'style-src', 'style-src-elem', 'style-src-attr', 'connect-src', 'frame-src', 'frame-ancestors', 'form-action', 'worker-src'
'high'Sources that can execute arbitrary code in the page's context'script-src', 'script-src-elem'

The trust system introduces opt-in abstraction and indirection, and a downside of the design is that it encourages over-permissioning at each individual tier. The maintainers currently feel that this granularity with 3 tiers offers an intuitive base that gets most of the important questions right most of the time for most users, and additional safeguards are available for those that want tighter control or less chance of error.

Explicit directives
#

The CSP helpers have a convenient, declarative API for defining directives per source. These override any defaults, and unlike trust, the directives do not depend on an abstraction layer, so WYSIWYG.

export const my_csp_trusted_sources: Array<CspSourceSpec> = [ {source: 'https://a.domain/'}, // No explicit directives, will use trust level if any {source: 'https://b.domain/', directives: null}, // Explicitly no directives {source: 'https://c.domain/', directives: ['img-src']}, // Only use for images {source: 'https://d.domain/', directives: ['connect-src', 'font-src']}, // Allow for connections and fonts ];

Explicit directives are additive with the trust system. For example, a source with trust: 'low' would normally not be allowed for connect-src, but you can explicitly permit this by including connect-src in the directives array.

// Example: explicitly allowing a source for specific directives regardless of trust export const my_csp_trusted_sources: Array<CspSourceSpec> = [ // Allow for specific directives (adds to what trust level allows): {source: 'https://a.domain/', trust: 'low', directives: ['connect-src']}, // Trust-level provides baseline permissions, explicit directives add specific ones: {source: 'https://b.domain/', trust: 'medium', directives: ['script-src-elem']}, // Both mechanisms work together - trust level provides baseline permissions // and explicit directives add specific permissions ];

Base defaults
#

The options value_defaults_base (defaults to csp_directive_value_defaults) and required_trust_defaults_base (defaults to csp_directive_required_trust_defaults) afford full control over defaults:

// Start with completely empty defaults (fully declarative): const minimal_csp = create_csp_directives({ // Set both base values to null or {} to reset defaults value_defaults_base: null, // or {} for same effect required_trust_defaults_base: null, // or {} for same effect // Define only what you need value_defaults: { 'script-src': ['self'], 'img-src': ['self', 'data:'], }, }); // The above is equivalent to not using Fuz's CSP abstraction at all: assert.deepEqual(minimal_csp, { 'script-src': ['self'], 'img-src': ['self', 'data:'], }); // Use your own custom base value defaults: create_csp_directives({ // Define your own value defaults base value_defaults_base: { 'default-src': ['none'], 'script-src': ['self'], 'img-src': ['self', 'data:'], }, // Override specific directives in the base value_defaults: { 'script-src': ['self', 'https://trusted.domain/'], } }); // Set custom trust requirements for directives: create_csp_directives({ // Define your own trust requirements base required_trust_defaults_base: { 'script-src': 'high', 'connect-src': 'medium', 'img-src': 'low', }, // Source will be added based on your custom trust requirements trusted_sources: [ // This source gets trusted for script-src and connect-src and no other directives. // If the `required_trust_defaults_base` were omitted, it would have the normal defaults. {source: 'https://somewhat.trusted.domain/', trust: 'medium'}, ] });

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 '@ryanatkn/fuz/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={zzz_logo} />
  • <Svg data={moss_logo} />
  • <Svg data={belt_logo} />
  • <Svg data={gro_logo} />
  • <Svg data={fuz_logo} />
  • <Svg data={webdevladder_logo} />
  • <Svg data={fuz_blog_logo} />
  • <Svg data={fuz_mastodon_logo} />
  • <Svg data={fuz_code_logo} />
  • <Svg data={fuz_gitops_logo} />
  • <Svg data={fuz_template_logo} />
  • <Svg data={earbetter_logo} />
  • <Svg data={spiderspace_logo} />
  • <Svg data={github_logo} />
  • <Svg data={mdn_logo} />
  • <Svg data={chatgpt_logo} />
  • <Svg data={claude_logo} />
  • <Svg data={gemini_logo} />

mdz #

mdz is a small markdown dialect that supports Svelte components, auto-detected URLs prefixed with https:// and /, and Fuz integration like linkified identifiers and modules in `backticks`. The goal is to securely integrate markdown with the environment's capabilities, while being simple and friendly to nontechnical users.

mdz prioritizes predictability: strict syntax with one canonical pattern per feature, preferring false negatives over false positives to minimize surprise.

Usage
#

import Mdz from mdz.ts:

import Mdz from '@ryanatkn/fuz/Mdz.svelte';

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

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

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.

Horizontal rules
#

Use exactly three hyphens (---) at the start of a line to create a horizontal rule. Must be separated from other content by blank lines (paragraph breaks), except at document start/end:

Section one. --- Section two.

Section one.


Section two.

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 three kinds of links:

  • standard markdown link syntax
  • external URLs starting with https:// or http://
  • internal paths starting with /
[Fuz API docs](https://fuz.dev/docs/api) and https://fuz.dev/docs/api and /docs/api

Note: Relative paths (./, ../) are not supported (currently, I think this will be changed). mdz content may be rendered at different URLs than where source files live (e.g., TSDoc comments from src/lib/foo.ts render at /docs/api/foo.ts). Root-relative paths (/docs/...) have unambiguous meaning regardless of render location, making them more portable. However it seems very useful to make ../ and ./ links work, maybe we can support it and make the renderer accept a custom base path?

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:

import {mdz_elements_context} from '@ryanatkn/fuz/mdz_components.js'; mdz_elements_context.set(new Map([ ['code', true], ['aside', true], ['marquee', true], ]));

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:

import {mdz_components_context} from '@ryanatkn/fuz/mdz_components.js'; import Alert from '@ryanatkn/fuz/Alert.svelte'; mdz_components_context.set(new Map([ ['Alert', Alert], ]));

Unregistered components render as <ComponentName /> placeholders.

Advanced usage
#

For more control, use mdz_parse directly with MdzNodeView:

import {mdz_parse} from '@ryanatkn/fuz/mdz.js'; import MdzNodeView from '@ryanatkn/fuz/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.

Headings
#

Use 1-6 hashes followed by a space:

#### h4 ~with~ _italic_

h4 with italic

Must start at column 0, have a space after hashes, and be followed by a blank line or EOF. 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, closing fence must match opening length, and be followed by a blank line or EOF.

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.

In general, mdz wants to minimize surprise to nontechnical users, so it's strict in what it accepts and prefers false negatives over false positives. For example, it requires a separating blank line and ``` with no preceding spaces or characters to start a code block.

Generated docs
#

For more see the generated mdz docs:

Alert #

import Alert from '@ryanatkn/fuz/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.

// @ryanatkn/fuz/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_5)"> the <code>color</code> prop overrides the status color </Alert>

Breadcrumb #

import Breadcrumb from '@ryanatkn/fuz/Breadcrumb.svelte'; <Breadcrumb />

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 '@ryanatkn/fuz/Card.svelte'; <Card> just<br /> a card </Card>
🪧
just
a card

With a custom icon
#

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

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>
align
icon right
🪧
<Card align="above"> align<br /> icon above </Card>
🪧
align
icon above
<Card align="below"> align<br /> icon below </Card>
align
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:

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://www.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 '@ryanatkn/fuz/HueInput.svelte';

With bind:value
#

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

With oninput
#

<HueInput oninput={(v) => (value_from_oninput = v)} />
value_from_oninput === undefined

With children
#

<HueInput> Some colorful hue input </HueInput>

Docs #

The Docs is the component behind these docs. Its docs are unfinished - for now see usage in Fuz or Moss.

LibraryDetail #

This is a component related to Gro's public packages features.

import LibraryDetail from '@ryanatkn/fuz/LibraryDetail.svelte'; <LibraryDetail {library} />
fuz 🧶
Svelte UI library
friendly user zystem
npm i -D @ryanatkn/fuz
homepage repo npm version license data
  • LibrarySummary #

    This is a component related to Gro's public packages features.

    import LibrarySummary from '@ryanatkn/fuz/LibrarySummary.svelte'; <LibrarySummary {library} />
    fuz
    friendly user zystem 🧶

    Svelte UI library

    npm i -D @ryanatkn/fuz

    PendingAnimation #

    import PendingAnimation from '@ryanatkn/fuz/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 '@ryanatkn/fuz/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 '@ryanatkn/fuz/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 '@ryanatkn/fuz/Svg.svelte'; <Svg data={fuz_logo} />

    Fills available space by default:

    With custom size
    #

    Set size: (see the Moss typography docs)

    <Svg data={fuz_logo} size="var(--icon_size_xl)" />

    <Svg data={fuz_logo} size="var(--icon_size_sm)" />

    Set --font_size on the component or a parent:

    <span style:--font_size="var(--icon_size_xl)"><Svg data={fuz_logo} /></span>

    With custom color
    #

    Set fill: (see the Moss colors docs)

    <Svg data={fuz_logo} fill="var(--color_d_5)" />

    <Svg data={fuz_logo} fill="var(--color_b_5)" />

    Set --text_color on the component or a parent, for svgs that have no default fill:

    <span style:--text_color="var(--color_i_5)"><Svg data={github_logo} /></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 '@ryanatkn/fuz/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>

    Themed #

    Fuz provides UI components that use Moss' theming system for dark mode and custom themes.

    Themed adds global support for both the browser's color-scheme and custom themes based on Moss style variables, which use CSS custom properties. Themed is a singleton component that's mounted at the top-level of the page:

    import Themed from '@ryanatkn/fuz/Themed.svelte'; <!-- +layout.svelte --> <Themed> {@render children()} </Themed>
    Why the singleton?
    Why nested children?

    Color scheme
    #

    Themed defaults to automatic color-scheme detection with prefers-color-scheme, and users can also set it directly:

    import ColorSchemeInput from '@ryanatkn/fuz/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 Moss 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 '@ryanatkn/moss/style.css'; import '@ryanatkn/moss/theme.css'; import Themed from '@ryanatkn/fuz/Themed.svelte'; import type {Snippet} from 'svelte'; const {children}: {children: Snippet} = $props(); </script> <!-- enable theme and color-scheme support --> <Themed> {@render children()} </Themed>

    Themed can be customized with the the nonreactive prop themer:

    import {Themer} from '@ryanatkn/fuz/themer.svelte.js'; const themer = new Themer(...); <Themed {themer}> {@render children()} </Themed>

    Themed sets the themer in the Svelte context:

    // get values from the Svelte context provided by // the nearest `Themed` ancestor: import {themer_context} from '@ryanatkn/fuz/themer.svelte.js'; const themer = themer_context.get(); themer.theme.name; // 'base' themer.color_scheme; // 'auto'

    For a more complete example, see fuz_template.

    More details
    #

    Themed initializes the system's theme support. Without it, the page will not reflect the user's system color-scheme. By default, Themed 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.