fynejs
v1.2.0
Published
A tiny, fast, zero-dependency reactive UI framework for the browser
Maintainers
Readme
FyneJS
FyneJS is a tiny, fast, zero‑dependency reactive UI framework for the browser.
Why FyneJS
- Tiny & fast: zero dependencies, ~18.6KB gzipped, optimized for performance
- Engineered reactivity: lean, cached evaluations; fine‑grained computed invalidation
- Smart GC: precise cleanup for timers, observers, events, and component trees
- Built-in SPA router: client-side navigation with view transitions, prefetching, and lifecycle hooks
- Browser-native TypeScript: load
.tscomponent files directly—blazing fast type stripping (~34ms for 1000 lines) with zero build tools - Declarative power: rich directives and component composition without a build step
- SEO‑friendly by design: progressive enhancement on existing HTML; no client‑side rendering takeover, so your server content remains crawlable
- Signals API: lightweight child → parent messaging with payloads and bubbling
Capabilities (with examples)
Built-in SPA router with view transitions, prefetching, and navigation hooks (see Built-in SPA Router section)
Browser-native TypeScript: Load
.tscomponent files directly without build tools—blazing fast type stripping in ~34ms (see Browser-Native TypeScript section)Declarative directives: text, HTML, show/hide, if/else, loops, model binding, events, styles, classes, transitions
<div x-data="{ n: 0, items: [1,2,3], open: true }"> <button x-on:click="n++">+1</button> <span x-text="n"></span> <template x-if="open"><p>Shown</p></template> <!-- Transition example (class phases + end hook) --> <div x-show="open" x-transition:enter="transition ease-out duration-200" x-transition:enter-from="opacity-0 -translate-y-2" x-transition:enter-to="opacity-100 translate-y-0" x-transition:leave="transition ease-in duration-150" x-transition:leave-from="opacity-100 translate-y-0" x-transition:leave-to="opacity-0 -translate-y-2" x-transition:enter.after="({ el, phase, config }) => console.log('entered', config.duration)" >Animated block</div> <ul> <template x-for="(v,i) in items"><li>#<span x-text="i"></span>: <span x-text="v"></span></li></template> </ul> </div>Powerful events & modifiers: keys, buttons, touch, outside, once, passive, capture, combos, defer
<input x-on:keydown.enter.ctrl="save()"> <button x-on:click.once="init()">Init once</button> <div x-on:click.outside="open=false">Panel</div> <input x-on:input.defer="recompute()"> <!-- handler runs in a microtask -->Model binding: inputs, checkboxes (arrays), radios, selects (multiple)
<input type="text" x-model="form.name"> <input type="checkbox" value="admin" x-model="roles"> <!-- toggles 'admin' in roles --> <select multiple x-model="selected"> ... </select>Computed & watch: derived state and change observers
<div x-data="{ a: 2, b: 3 }" x-text="a*b"></div> <!-- via component API, you can also define computed and watch -->Lifecycle hooks: beforeMount, mounted, updated, beforeUnmount, unmounted
Transitions (x-transition)
Animate enter/leave for x-show and x-if:
<div x-data="{ open: false }">
<button x-on:click="open = !open">Toggle</button>
<div x-show="open" x-transition="opacity-0 transition-opacity duration-200" class="panel">Fade panel</div>
</div>Granular class phases + end callback:
<div
x-data="{ open: true }"
x-show="open"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-from="opacity-0 scale-95"
x-transition:enter-to="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-from="opacity-100 scale-100"
x-transition:leave-to="opacity-0 scale-95"
x-transition:enter.after="({ el, config }) => console.log(config.duration)"
>Modal</div>.after / .end modifiers run once per completed phase (never on cancellation) and receive { el, phase, config } where config.duration is the effective transition time including delays. For x-if branches, the original display value is preserved (including display: contents for template wrappers).
Slot & props: lightweight component composition
Seal and freeze: temporarily pause interactivity or lock state
<!-- Freeze via readonly attribute (no state changes, timers/listeners/observers paused, no renders) --> <component source="stats-card" readonly></component> <!-- Seal programmatically: allow internal state but suppress renders and side-effects --> <div x-data="{ paused:false, toggle(){ this.$seal(!(this.$isSealed)); this.paused = this.$isSealed; } }"> <button x-on:click="toggle()" x-text="$isSealed ? 'Resume' : 'Pause'"></button> </div>Freeze is fully read‑only (no state changes, no renders). Remove the readonly attribute (or call $seal(false) for sealed) to resume interactivity when appropriate.
No build required: works directly in the browser; enhanced builds are optional
$nextTick(): run code after DOM updatesthis.$nextTick(()=>{/* DOM updated */})Event delegation: scale to large UIs
<script> XTool.init({ delegate: true }); </script>Security sandbox: restrict globals in expressions
XTool.init({ sandboxExpressions: true, allowGlobals: ['setTimeout'] })
Quick start (CDN)
Include the minified build from jsDelivr or unpkg:
<script src="https://cdn.jsdelivr.net/npm/fynejs@latest/dist/x-tool.min.js"></script>
<!-- or -->
<script src="https://unpkg.com/fynejs@latest/dist/x-tool.min.js"></script>
<script>
XTool.init({
debug: false,
router: { enabled: true } // Optional: enable SPA routing
});
// Optional: Load external components (supports .js and .ts files!)
XTool.loadComponents([
'components/header.js',
'components/user-card.ts' // TypeScript works directly in browser!
]);
</script>
<div x-data="{ count: 0 }">
<button x-on:click="count++">+1</button>
<span x-text="count"></span>
</div>TypeScript usage
There are two easy ways to use FyneJS with TypeScript:
1) Bundlers (recommended)
Install the package and import the API. The package exposes clean ESM/CJS entry points and ships its own types.
// main.ts
import XTool, { html } from 'fynejs';
XTool.init({ debug: false, delegate: true });
XTool.registerComponent({
name: 'hello-world',
data: { msg: 'Hello FyneJS' },
// Use the tagged template for editor highlighting in TS
template: html`<div x-text="msg"></div>`
});
// Somewhere in your HTML template or via DOM APIs:
// <component source="hello-world"></component>- Works with Vite/Rollup/Webpack/ts-node/tsx without extra config.
- Types are resolved automatically via the package
exports(no triple-slash needed). - Tip: Import the
htmlhelper fromfynejsfor tagged template literals without TS errors and with IDE HTML highlighting.
2) CDN + types
If you’re using the CDN build in the browser and still want editor types, reference the declarations manually:
/// <reference path="./types/x-tool/types.d.ts" />You can copy the file into your repo and point typeRoots to it, or vendor the shipped types.d.ts.
Core concepts
Auto-discovery with x-data
<div x-data="{ message: 'Hello' }" x-text="message"></div>Events and modifiers
<button x-on:click.prevent.stop="submit()">Save</button>
<input x-on:keydown.enter="save()">Two-way binding
Signals (component messaging)
Signals are lightweight, component-scoped broadcasts for child → parent notifications and other local messaging. Emissions start at the current component and bubble up to ancestors; any ancestor that connected a handler for the signal name will receive the event. Bubbling can be stopped via evt.stopPropagation().
Core API (available on the component context):
this.Signals.emit(name, payload?): emit and bubble upward.this.Signals.connect(name, handler): register a handler on the current component.this.Signals.disconnect(name, handler): unregister the handler.
Handlers receive { name, payload, stopPropagation } and run with the component method context, so this has access to data/computed/methods.
Connect just-in-time, emit, then disconnect:
<div x-data="{ log: [],
handler(evt){ this.log = [...this.log, 'got:'+evt.payload]; },
run(){ this.Signals.connect('hello', this.handler); this.Signals.emit('hello','x'); this.Signals.disconnect('hello', this.handler); }
}">
<button x-on:click="run()">fire</button>
<span x-text="log.join(',')"></span>
</div>Bubbling to ancestors (no stopPropagation):
<section x-data="{ heard: [], mounted(){ this.Signals.connect('hello', (evt)=>{ this.heard = [...this.heard, evt.payload]; }); } }">
<div x-data>
<button x-on:click="Signals.emit('hello','X')">emit</button>
</div>
<span x-text="heard.join(',')"></span>
</section>Notes:
- Signals handlers are cleared on component destroy to avoid leaks.
- You can also connect in lifecycle to listen continuously, or connect/disconnect around a single operation.
<input type="text" x-model="form.name">
<input type="checkbox" value="admin" x-model="roles"> <!-- adds/removes 'admin' in roles array -->
<select multiple x-model="selected"> ... </select>Lists and conditionals
<template x-if="items.length === 0">
<p>No items</p>
</template>
<ul>
<template x-for="(todo, i) in todos">
<li>
<span x-text="todo.title"></span>
</li>
</template>
### Built-in SPA Router
FyneJS includes a lightweight client-side router with view transitions and navigation hooks:
```js
XTool.init({
router: {
enabled: true,
transtionName: 'slide', // CSS view transition name
before: (to, from, info) => {
// Check auth, analytics, etc.
// Return false to cancel navigation
return true;
},
after: (to, from, info) => {
// Update UI, scroll, analytics
console.log(`Navigated from ${from} to ${to}`);
},
error: (error, to, from) => {
console.error('Navigation error:', error);
},
prefetchOnHover: true // Smart link prefetching
}
});Transitions (x-transition)
Animate enter/leave for x-show and x-if.
Simple toggle form:
<div x-show="open" x-transition="opacity-0 transition-opacity duration-200">Fade</div>Configuration object form:
<div
x-show="open"
x-transition="{ duration: 300, easing: 'ease-out', enter: 'transition', enterFrom: 'opacity-0 scale-95', enterTo: 'opacity-100 scale-100', leave: 'transition', leaveFrom: 'opacity-100 scale-100', leaveTo: 'opacity-0 scale-95' }"
x-transition:enter.after="({ config }) => console.log(config.duration)"
>Dialog</div>Granular phases + end hook:
<div
x-show="open"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-from="opacity-0 scale-95"
x-transition:enter-to="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-from="opacity-100 scale-100"
x-transition:leave-to="opacity-0 scale-95"
x-transition:enter.after="({ el, phase, config }) => console.log(phase, config.duration, el)"
>Modal</div>Notes:
.after/.endmodifiers run once per completed phase (never on cancellation)- Handlers receive
{ el, phase, config }whereconfig.durationis the effective time including delays - With
x-if, original display is preserved (includingdisplay: contentsfor template wrappers)
Use x-link directive for SPA navigation:
<nav>
<a href="/index.html" x-link>Home</a>
<a href="/about.html" x-link>About</a>
<a href="/contact.html" x-link>Contact</a>
</nav>
<!-- With prefetching -->
<a href="/dashboard.html" x-link x-prefetch="hover">Dashboard</a>The router intercepts link clicks, updates the URL, and loads new pages without full refreshes—perfect for multi-page apps that feel like SPAs.
Dynamic component file loading
Load external component files (.js or .ts) with flexible loading strategies:
// Preload immediately (default for string entries)
XTool.loadComponents([
'components/stats-card.js',
{ path: 'components/chat-panel.js', mode: 'preload' }
]);
// Defer: ensure file loads before initial auto-discovery (blocks first scan)
XTool.loadComponents([
{ path: 'components/large-dashboard.js', mode: 'defer' }
]);
// Lazy: only fetch when a <component source="name"> appears in the DOM
XTool.loadComponents([
{ path: 'components/order-book.js', mode: 'lazy', name: 'order-book' },
// name can be omitted; filename (without extension) is used
{ path: 'components/advanced-calculator.js', mode: 'lazy' }
]);
// Later in HTML (triggers lazy fetch on first encounter)
// <component source="order-book"></component>Lazy mode details:
- Registration does not fetch the file until the component is actually used.
- If a matching
<component source="...">already exists at registration time, the file is fetched in an idle callback. - After load, auto-discovery re-runs so the component mounts automatically.
Defer mode details:
- Files are fetched before the framework performs the initial DOM scan, guaranteeing definitions are available when components are first discovered.
Return value:
loadComponents resolves with { settled, failed } counting only immediate (preload + defer) operations; lazy entries are not counted until they actually load.
TypeScript components work too! FyneJS automatically strips TypeScript type annotations from .ts files:
XTool.loadComponents([
'components/user-card.ts', // TypeScript file
'components/data-chart.ts', // TypeScript file
'components/modal.js' // Regular JavaScript
]);HTML Component Files
Write components in native .html files with full IDE syntax highlighting—no template strings needed!
<!-- components/greeting.html -->
<template>
<div class="greeting">
<h2 x-text="message"></h2>
<button x-on:click="greet()">Say Hello</button>
</div>
</template>
<script setup>
const message = data('Hello World');
function greet() {
message.value = 'Hello, FyneJS!';
}
expose({ message, greet });
onMounted(() => console.log('Mounted!'));
</script>Multiple components in one file:
<!-- components/widgets.html -->
<template name="widget-header">
<header x-text="title"></header>
</template>
<script setup name="widget-header">
const title = data('Header');
expose({ title });
</script>
<template name="widget-footer">
<footer x-text="copyright"></footer>
</template>
<script setup name="widget-footer">
const copyright = data('© 2025');
expose({ copyright });
</script>Load like any other component:
XTool.loadComponents([
'components/greeting.html',
'components/widgets.html' // loads both widget-header & widget-footer
]);Available helpers in HTML components: data(), computed(), watch(), expose(), onMounted(), onBeforeMount(), onUnmounted(), onBeforeUnmount().
Programmatic Component Mounting
Mount registered components on any DOM element programmatically:
// Register a component
XTool.registerComponent({
name: 'user-card',
template: '<div><h3 x-text="name"></h3></div>',
data: { name: 'Guest' }
});
// Mount on any element with optional props
const container = document.getElementById('dynamic-area');
const instance = XTool.mountComponent('user-card', container, {
name: 'John Doe'
});
// Later: instance.$destroy() to unmountElement References (x-ref)
Create named references to DOM elements accessible via $refs:
<div x-data="{ focusInput() { $refs.myInput.focus(); } }">
<input x-ref="myInput" type="text">
<button x-on:click="focusInput()">Focus</button>
</div>$refs.namereturns the element (or array if multiple elements share the name)$ref(name)function form to get a ref$ref(name, value)to register any value as a ref- Refs bubble up the component tree—child components can access parent refs
Shorthand Attribute Binding
Three equivalent syntaxes for dynamic attributes:
<div x-data="{ url: '/image.png', active: true }">
<!-- Full syntax -->
<img x-bind:src="url">
<!-- x: shorthand -->
<img x:src="url">
<!-- : shorthand (shortest, Vue-style) -->
<img :src="url">
<button :disabled="!active">Click</button>
</div>DOM Helpers ($attr, $css)
Programmatically set attributes and CSS on the target element:
<div x-data>
<button x-on:click="$attr('data-clicked', true)">Mark Clicked</button>
<div x-on:click="$css('background', 'red')">Click to color</div>
<!-- Object syntax for multiple -->
<button x-on:click="$attr({ 'data-x': 1, 'data-y': 2 })">Set Both</button>
<div x-on:click="$css({ color: 'white', background: 'blue' })">Style Me</div>
<!-- Glob pattern -->
<div x-on:click="$attr('[width,height]', 100)">Set width & height</div>
</div>Browser-Native TypeScript (Zero Build)
Unique to FyneJS: Load TypeScript component files directly in the browser without any build step or compilation!
// Load TypeScript components just like JavaScript
XTool.loadComponents([
{ path: 'components/user-dashboard.ts', mode: 'preload' },
{ path: 'components/analytics-chart.ts', mode: 'lazy' }
]);<!-- Use TypeScript components seamlessly -->
<component source="user-dashboard"></component>
<component source="analytics-chart"></component>How it works:
- Token-based type stripping using a single-pass scanner
- Blazingly fast: ~34ms for 1000 lines of TypeScript
- Handles interfaces, types, generics, enums, access modifiers, and more
- No compilation, no waiting, no build process
- Works with simple and complex TypeScript patterns
What gets removed:
- Type annotations (variables, parameters, return types)
- Interface, type, namespace, and declare declarations
- Generics in functions and classes
- Import/export statements (including type-only imports)
- Non-null assertions (
!) - Access modifiers (public, private, protected, readonly)
- Type assertions (
assyntax) - Enum declarations
implementsclauses
Important notes:
- This is type stripping, not type checking—use an IDE (VS Code, WebStorm) for type safety during development
- Best suited for well-formed TypeScript code
- Simpler types work better than very complex type constructs
- Consider file size for large codebases (performance is excellent for typical component files)
Example TypeScript component:
// components/user-card.ts
interface User {
id: number;
name: string;
email: string;
}
XTool.registerComponent<{ user: User }>({
name: 'user-card',
data: {
user: null as User | null,
loading: false
},
methods: {
async loadUser(id: number): Promise<void> {
this.loading = true;
const response = await fetch(`/api/users/${id}`);
this.user = await response.json();
this.loading = false;
}
},
template: html`
<div class="card">
<template x-if="loading">Loading...</template>
<template x-if="!loading && user">
<h3 x-text="user.name"></h3>
<p x-text="user.email"></p>
</template>
</div>
`
});The TypeScript code above is automatically stripped to valid JavaScript and executed in the browser—no build step required!
Next tick
XTool.init();
XTool.createComponent?.({ /* if using programmatic API */ });
// In methods:
this.$nextTick(() => {
// DOM is updated
});Event delegation (large lists)
<script>
XTool.init({ delegate: true });
</script>Delegates click, input, change, keydown, keyup at the container level to reduce listeners.
Expression sandbox (optional)
XTool.init({
sandboxExpressions: true,
allowGlobals: ['setTimeout', 'requestAnimationFrame'] // whitelist if needed
});When enabled, expressions do not see window/document unless whitelisted.
Async component templates
<component source="async-card" x-prop="{ id: 42 }"></component>
<script>
XTool.registerComponent({
name: 'async-card',
template: () => fetch('/card.html').then(r => r.text()),
mounted() { /* ... */ }
});
</script>Until the Promise resolves, the component element stays empty; when it resolves, the template is applied and directives are parsed.
Components: registration, props, slots, dynamic mounting
Register once, reuse anywhere:
<component source="fancy-card" x-prop="{ title: 'Hi' }"></component>
<script>
XTool.registerComponent({
name: 'fancy-card',
template: `
<div class="card">
<h3 x-text="title"></h3>
<slot></slot>
</div>
`,
data: { title: '' }
});
</script>Dynamic mounting: change the source attribute and the framework mounts the new component and cleans up the old one automatically.
<div x-data="{ src: 'fancy-card' }">
<button x-on:click="src = src==='fancy-card' ? 'simple-card' : 'fancy-card'">Swap</button>
<component x:source="src"></component>
</div>Props are reactive; slots distribute original child nodes to <slot>/<slot name="...">.
Async templates are supported: template can be a string, a Promise, or a function returning a Promise.
XTool.registerComponent({
name: 'delayed-card',
template: () => fetch('/fragments/card.html').then(r=>r.text())
});Global API (window.XTool)
XTool.init(config?: {
container?: string; // default: 'body'
debug?: boolean; // enable debug logging
staticDirectives?: boolean; // optimize static directives
prefix?: string; // default: 'x'
delegate?: boolean; // event delegation for performance
sandboxExpressions?: boolean; // restrict globals in expressions
allowGlobals?: string[]; // whitelist globals when sandboxed
router?: { // SPA routing configuration
enabled: boolean;
transtionName?: string; // CSS view transition name
before?: (to: string, from: string, info: {source: string}) => boolean | Promise<boolean>;
after?: (to: string, from: string, info: {source: string}) => void;
error?: (error: unknown, to: string, from: string) => void;
prefetchOnHover?: boolean; // smart link prefetching
}
});
XTool.directive(name: string, impl: { bind?, update?, unbind? }): void;
XTool.registerComponent({ name, data, methods, computed, propEffects, template, ... }): void;
XTool.loadComponents(sources: Array<string | { path: string; mode?: 'preload' | 'defer' | 'lazy'; name?: string }>): Promise<{ settled: number; failed: number }>;
// Optional: custom directive prefix (not hardcoded to "x")
XTool.init({ prefix: 'u' }); // use u-data, u-text, u-on:click, ...Documentation
For complete documentation, guides, and interactive examples, visit:
- Getting Started Guide
- Directives Reference
- Components Guide
- Router Documentation
- TypeScript Support
- API Reference
- Examples
License
MIT
