An HTML-first UI framework where the component tree is real DOM. Built on
native Web Components, signal reactivity, and a section-based authoring language
— no virtual DOM, no <div id="root">, no class soup.
<!-- View Source shows your components, not a black box -->
<app-shell>
<ingredient-list>
<ingredient-row>…</ingredient-row>
</ingredient-list>
</app-shell>- Visible tree. Every component is a real custom element. View Source and
DevTools show
<ingredient-row>, not an opaque render root. You style by tag, inspect by tag, test by tag. - Signals, not dependency arrays.
state()is a reactive Proxy;derived()computes;effect()re-runs when what it read changes. NouseMemo, no deps lists, no compiler tracking magic. - Frame-batched. Reactive re-runs and DOM writes coalesce into one paint per frame via a 4-phase scheduler; heavy work is deferred off the tick.
- Two ways to author, one runtime. A Markdown single-file component or the
.dtxDSL — both compile to the same custom elements. - Batteries included. Forms, typed HTTP + OpenAPI codegen, a file-based router, i18n, drag & drop, windowed lists, a Semantic-UI kit, and a Vite plugin.
import { state, derived, effect } from 'ui-detox';
const s = state({ count: 0, items: [1, 2] });
const doubled = derived(() => s.count * 2);
effect(() => console.log(s.count, doubled.value)); // re-runs on change
s.count++; // ▶ 1 2 → effect fires
s.items.push(3); // arrays are reactive toobatch(fn) coalesces writes; shallow() skips deep tracking for big payloads;
untrack(fn) reads without subscribing.
Markdown SFC (Counter.md):
---
name: Counter
tag: app-counter
---
```html template
<div class="counter">
<button @click=${dec}>–</button>
<span>${s.count}</span>
<button @click=${inc}>+</button>
</div>
```
```ts script
const s = state({ count: props.start ?? 0 });
function inc() { s.count++; }
function dec() { s.count--; }
```.dtx DSL — the same component:
component Counter export tag app-counter
props
number start
end props
script
const s = state({ count: props.start ?? 0 });
end script
actions
function inc() { s.count++; }
function dec() { s.count--; }
end actions
template
<div class="counter">
<button #dec @click=${dec}>–</button>
<span>${s.count}</span>
<button #inc @click=${inc}>+</button>
</div>
end template
end component
Bindings by prefix: ${x} text, @click=${fn} event, .prop=${v} property,
?disabled=${v} boolean. #name / name="…" auto-bind element refs into
ctx.refs.
<if>, <for>, <case> compile away; in production they anchor on text nodes,
leaving no <div> scaffolding.
<case on=${list.status}>
<when is="loading"><spinner-el/></when>
<when is="error"><p>Failed</p></when>
<else>
<for each=${list.data.items} item="ing" key="ing.id">
<ingredient-row data=${ing}/>
</for>
</else>
</case>Rendering a long list kills scrolling. UIDetox virtualizes it by flipping one
attribute on <for> — same loop, same key, now it renders only the visible
slice plus a small overscan and recycles rows as you scroll. DOM node count stays
bounded no matter how long the array grows.
<for each=${ingredients} <!-- 8 000 items -->
item="ing" key="ing.id"
viewport="virtual" <!-- ← turns on windowing -->
row-height="48"
overscan="6">
<ingredient-row data=${ing}/>
</for>plain <for> → 8 000 <ingredient-row> in the DOM, janky scroll
<for viewport=…> → ~15 in the DOM, smooth at any length
- Keyed reconcile — a row with the same
keykeeps its DOM node across scroll, so focus, inputs and selection survive. - Bounded:
(visible + 2·overscan)rows, whatevereach.lengthis; total scroll height is preserved via a spacer. - Scroll API: grab the element with a
#ref→el.scrollToKey('ing-42'),el.scrollToIndex(1000). - Works when the element itself scrolls or an ancestor does (
scroll-parent), and inside<select>/<table>.<virtual-for>is an alias for the same thing.
Full guide: docs/patterns/virtual-for.md · example: examples/virtual/IngredientList.dtx.
const fm = form({
schema: f.object({
name: f.string().min(2),
nutrients: f.array(f.object({ code: f.string().min(1) })).min(1),
}),
initial: { name: '', nutrients: [] },
onSubmit: async (v) => api.ingredients.create(v),
});<form @submit=${fm.submit}>
<input bind=${fm.field('name')} placeholder="Назва"/>
<field-error .of=${fm.field('name')}></field-error>
<button ?disabled=${!fm.valid || fm.submitting}>Зберегти</button>
</form>Validation runs off-tick and coalesced; server errors flow in with
fm.applyServerErrors(err).
ui-detox openapi --input ./openapi.json --output ./src/api.ts
const list = resource(
(signal) => api.ingredients.list({ query, signal }),
{ key: () => JSON.stringify(query) }, // re-fetches on change, auto-aborts on unmount
);
// list.status · list.data · list.error · list.reload()Auth with single-flight 401 refresh, mutation() with optimistic rollback, and
command() for CQRS writes — all in ui-detox/http.
routes.dtx compiles to a RouteEntry[]:
import Login from "pages.Login"
import Dashboard from "pages.Dashboard"
import requireAuth from "lib.auth-guard"
router AppRoutes export
routes
"/login" -> Login
group layout=AppShell guard=requireAuth
"/" -> Dashboard
"/recipes/:id" -> RecipeCard { id: string }
end group
"**" -> NotFound status=404
end routes
end router
navigate('/x') and <a data-nav href="/x"> for SPA transitions; routeState()
exposes the reactive current match (params, layout).
| Import | What |
|---|---|
ui-detox |
reactivity, components, directives, registry (DI), router, traits, filters, dnd, animations, SSR/islands, DevTools |
ui-detox/forms |
schema DSL, form(), bind=, <field-error> |
ui-detox/http |
createHttpClient, resource, mutation, command, OpenAPI codegen |
ui-detox/i18n |
setLocale, fmt.{number,percent,qty,date,relative}, unit conversion |
ui-detox/ui |
Semantic-UI primitive kit (ui-button, ui-card, ui-modal, …) |
ui-detox/vite |
Vite plugin — load .dtx/.md, dotted-module resolve, HMR, TS shims |
Also: reusable traits (use="draggable"), filters (${v | qty:'g'}),
hierarchical registry DI, islands (render: 'never' + hydrate), FLIP
animations, and <lazy-load>.
pnpm install
pnpm test # 540+ tests
pnpm build # tsc → dist/// vite.config.ts
import uidetox from 'ui-detox/vite';
export default { plugins: [uidetox()] };- REFERENCE.md — the full manual, every feature with examples.
Deep-dive guides in docs/patterns/:
| Guide | Topic |
|---|---|
| forms.md | schema DSL, form(), bind=, validation |
| http.md | HTTP client, resource, mutation, command, OpenAPI |
| navigation.md | router, navigate(), <a data-nav>, routeState() |
| virtual-for.md | windowed list rendering |
| drag-and-drop.md | draggable / droppable / sortable traits |
| i18n.md | locale-aware number / date / unit formatting |
| ui-kit.md | Semantic-UI primitive components |
| shallow-batch.md | shallow(), batch(), untrack() |
| island-wrapper.md | wrapping imperative libraries (canvas, maps) |
| vite-plugin.md | ui-detox/vite — load .dtx/.md, HMR, TS shims |
Runnable examples in examples/:
| Example | Shows |
|---|---|
| hello/ | minimal Markdown SFC (App.md) |
| todo/ | SFC with colocated test blocks (Todo.md) |
| dsl/ | .dtx components, traits, filters |
| forms/ | IngredientForm.dtx — schema + bind= + arrays |
| virtual/ | IngredientList.dtx — viewport="virtual" |
| dnd/ | Palette.dtx — draggable → droppable canvas |
| island/ | CanvasClock — imperative canvas island |
| island-cytoscape/ | GraphEditor — canvas-graph island (props↔lib, emits, drop) |
| routing/ | file-based routes |
| include/ | <include> partials |
| showcase/ | traits + filters + docs page |
| culinary-lite/ | full Vite app: router, layout, pages, detox.toml |
| vitest-setup/ | Vitest recipe with uidetoxEsbuild |
