A small, reactive web-component framework built on browser standards. Nisli combines fine-grained signals, typed component factories, light-DOM templates, dependency injection, routing, and static generation without a virtual DOM or a framework compiler.
Nisli started inside backlog-mcp's web viewer rather than as a standalone framework. Two public records show that origin and its production use:
- 117 Pull Requests Later, It Wasn’t a Task Manager Anymore traces how the backlog produced the framework while its viewer was being built.
- gkoreli.com is a production publication rendered with
@nisli/core.
These are implementation records from the maintainer, not benchmarks.
npm install @nisli/core@nisli/core has no runtime dependencies. It works with TypeScript or plain
JavaScript and does not require a framework-specific build step.
See the framework tenets for design constraints.
import { component, html, signal } from '@nisli/core';
const Counter = component('x-counter', () => {
const count = signal(0);
return html`
<button @click=${() => count.value++}>
Count: ${count}
</button>
`;
});
html`${Counter({})}`.mount(document.body);Signals are read with .value in TypeScript and passed directly to templates.
Only the bound text, attribute, class, or child slot updates when a signal
changes.
html and el() synchronize native value (input, textarea, select), checked
(input), and selected (option) properties as well as their attributes. An
external signal change therefore replaces a user-edited value without a ref or
an application effect:
const name = signal('');
html`<input value=${name} @input=${(event: Event) => {
name.value = (event.target as HTMLInputElement).value;
}}>`;Bindings remain one-way; native events update your model only through handlers.
Equal text-value writes preserve the caret. Select values initialize after
options and reconcile in a mutation-observer microtask when options change.
A select value is scalar; bind option selected states for multiple selections.
Nonempty file values cannot populate uploads; an empty value can clear them.
Form reset keeps native defaults: input value/checked and option selected attributes, textarea child text, and select option defaults. Reset does not write signals. Ordinary attributes and custom-element props keep their existing paths.
component() registers a standard custom element and returns its typed factory.
Factory callers can pass plain values or signals. The optional attrs map makes
selected host attributes live, so plain-HTML consumers and factory consumers
share the same component implementation.
import {
children,
component,
html,
type ComponentAttrs,
} from '@nisli/core';
interface DisclosureProps {
open?: boolean;
children?: unknown;
}
const attrs = {
open: 'boolean',
} satisfies ComponentAttrs<DisclosureProps>;
const Disclosure = component<DisclosureProps, typeof attrs>(
'x-disclosure',
(props) => {
const content = children('Nothing to show');
return html`
<section class:open=${props.open}>
${content}
</section>
`;
},
{ attrs },
);html`${Disclosure({ open: true, children: 'Factory content' })}`;<x-disclosure open>Plain HTML content</x-disclosure>Declared string, boolean, number, and forwarded attributes react to
setAttribute() after mount. children(fallback?) projects factory children,
initial light-DOM children, and late parser children through one reactive slot.
Use resource() when a local value is derived asynchronously and does not need
query caching or invalidation policy. Only the synchronous source function is
tracked; stale work is aborted and cannot overwrite a newer result.
import { component, html, resource } from '@nisli/core';
const Markdown = component<{ content: string }>('x-markdown', (props) => {
const rendered = resource(
() => props.content.value || undefined,
(content, signal) => renderMarkdown(content, { signal }),
);
return html`<article html:inner=${rendered.data}></article>`;
});data, loading, and error are readonly signals. refresh() reruns the
current source; component teardown disposes automatically, while standalone
callers can use dispose(). Returning undefined from the source disables the
resource and clears its current state.
The published @nisli/core root gives bundlers standard process.env.NODE_ENV
guards. Vite production builds and esbuild browser bundles with full minification
remove optional diagnostics and their bookkeeping. Other bundlers should use
their normal production replacement and dead-code elimination settings.
Native browser ESM needs no application build: serve dist/index.js for
development or dist/index.production.js for production, and map @nisli/core
to that one URL for the entire application. Component imports stay unchanged.
setDevMode() controls available development checks; it cannot restore code
removed from a production artifact. Template safety and failure containment
remain enabled in production.
See the core diagnostics and migration guide for the import map, runtime overrides, and production error behavior.
Standalone Nisli disposables support using on runtimes with native explicit
resource management:
{
using stop = effect(() => console.log(count.value));
using tasks = query(() => ['tasks'], fetchTasks);
}This applies to effect(), signal/computed subscribe(), Emitter.on(),
resource(), and query(). Existing callable disposers and .dispose()
methods are unchanged. Nisli only attaches the guarded Symbol.dispose alias;
it never polyfills the platform. Apps that downlevel using for older runtimes
must provide their own Symbol.dispose polyfill.
viewTransition() wraps a state update in a native View Transition, calling
flush() inside the browser's update callback so the frame it captures is
Nisli's flushed DOM rather than the next microtask:
import { viewTransition } from '@nisli/core';
viewTransition(() => { items.value = sorted; }, { types: ['reorder'] });The router opts in per application or per navigation, and wraps only the commit — an awaited loader never blocks paint:
defineRouter(catalog, { viewTransitions: { enabled: true } });
router.navigate('/blog', { viewTransition: false });Static builds get the cross-document lane with no runtime JS at all:
buildStaticSite({ viewTransitions: true }) emits the
@view-transition { navigation: auto } opt-in both pages need, plus optional
speculation rules, and whenActive() from @nisli/ssg/client holds observable
work until a prerendered page is actually shown.
Everything degrades: no startViewTransition and the update still applies,
flushed and unanimated, returning null. Nisli ships no stylesheet, and
prefers-reduced-motion is answered in CSS so the swap stays atomic while the
motion is cut.
- Fine-grained reactivity:
signal,computed,effect,untrack,flush, and awaitabletick. - Typed web components: composition-style synchronous setup, signal-backed props, typed factories, and live attribute declarations.
- Safe light-DOM templates: signal bindings, typed events and modifiers,
class:*, brandedhtml:inner(trustedraw()or sanitizer-routedsanitized()), refs, and dynamic HTML tags throughel(). - Stable control flow: lazy
when()branches and keyedeach()lists retain component state and DOM identity. Reorders usemoveBefore()where available; the fallback keeps a retained focused row attached without refocusing it. Other moved rows can lose native state such as iframe documents or popovers on the fallback path; document Selection ranges are not preserved. - Two scopes of dependency injection: app-wide singleton services with
inject/provide, and portal-safe subtree state withcreateContext. - Lifecycle and resilience:
onMount,onCleanup,useHostEvent, automatic effect/event cleanup, move-resilient disconnects, and component error boundaries. - Async work:
resourcehandles local async derivations;queryandQueryClientadd shared caching, invalidation, and reactive refetching. - Typed application events with
Emitterand automatic component cleanup.
This repository contains four independently versioned packages and the site that exercises them together:
@nisli/core— component authoring and browser runtime.@nisli/router— one typed route catalog for browser, Vite, static builds, and edge/Worker matching. It includes codecs, redirects, managed SEO metadata, render-separated catalogs, and accessible outlets.@nisli/ssg— renders normal Nisli templates and the shared application router to static output.@nisli/ui— “shadcn for Nisli”: a CLI and source registry that copy accessible, Tailwind v4 components into your project.packages/www— the private nisli.dev application and end-to-end integration surface.
npm install -D @nisli/ui tailwindcss tw-animate-css
npx nisli-ui init
npx nisli-ui add button dialog tabsImport tailwindcss, tw-animate-css, and the copied
nisli-ui/styles/theme.css in that order. The copied ui-* components support
typed Nisli factories and plain HTML; your application owns the resulting
source.
import { html } from '@nisli/core';
import { defineRouter, route } from '@nisli/router';
export const AppRouter = defineRouter({
home: route('/', {
metadata: { title: 'Home' },
render: () => html`<h1>Home</h1>`,
}),
});Mount AppRouter({}) in the browser, pass the same router to
nisliRoutes(AppRouter) for Vite direct-route fallback, and pass it to
buildStaticSite({ router: AppRouter }) for static output. Larger applications
can author an environment-neutral catalog from @nisli/router/catalog and
attach client renderers later with bindRenders().
import { defineConfig } from 'vite';
import { nisliHmr } from '@nisli/core/vite-hmr';
export default defineConfig({
plugins: [nisliHmr()],
});The development-only plugin remounts an edited component in place without a full page reload.
pnpm install
pnpm build
pnpm test
pnpm typecheck
pnpm exec playwright install chromium firefox webkit
pnpm --filter @nisli/core test:packageArchitecture decisions live in docs/adr. Package-specific
usage and release history live beside each package.
Nisli stands on the shoulders of React's component model, Solid's fine-grained reactivity, Lit's web components and templates, Angular's dependency injection, and Vue's composition-style authoring.
MIT