Skip to content

Vital UI

Data & logic helpers

Components are half of a UI. These helpers cover the other half — lists, forms, dialogs, async calls, formatting — so the code around the components stays short, typed and free of the usual bugs. Everything ships from the same barrel.

Rune helpers return an object with accessors: read view.visible, bind view.query, never destructure. They work inside a component or at module level as a shared store, and none of them uses an $effect. The pages under /demo/pages are built entirely on them.

createCollection

A keyed reactive list: get, add, update, remove, count and sum without a single find(x => x.id === id).

2h
5h
1h
1 of 3 done 8h estimated
tasks.svelte.ts ts
import { createCollection } from '@mirabelle-labs/vital-ui/state';
import { createSequence } from '@mirabelle-labs/vital-ui/data';

const tasks = createCollection(TASKS, { key: 'id' });
const ids = createSequence('T-', 104);

tasks.add(
	{ id: ids.next(), title, estimate: 1, done: false },
	{ prepend: true }
);
tasks.update(id, (task) => ({ done: !task.done }));
tasks.remove(id);

tasks.count((t) => t.done); // 1
tasks.sum('estimate');      // 8

createOverlay

One object for a Dialog or Drawer and the thing it is about. bind:open keeps it in sync when the user closes with Escape or the backdrop.

People.svelte svelte
import { createOverlay } from '@mirabelle-labs/vital-ui/state';

const detail = createOverlay<Person>();

<Button onclick={() => detail.show(person)}>Open</Button>

<Dialog bind:open={detail.open} title={detail.item?.name}>
	{#if detail.item}…{/if}
	{#snippet footer()}
		<Button onclick={detail.hide}>Close</Button>
		<Button onclick={() => detail.withItem(invite)}>
			Invite
		</Button>
	{/snippet}
</Dialog>

createForm + validation rules

Values, errors and touched state in one place. Errors only show after the first submit; the rules are plain functions you can reuse and compose.

invalid
RegisterService.svelte svelte
import { createForm } from '@mirabelle-labs/vital-ui/state';
import { validateWith, required } from '@mirabelle-labs/vital-ui/validation';
import { email, dns1123, port } from '@mirabelle-labs/vital-ui/validation';

const form = createForm(
	{ name: '', email: '', port: '8080' },
	{
		validate: validateWith({
			name: [required(), dns1123()],
			email: [required(), email()],
			port: [required(), port()]
		}),
		onSubmit: (values) => {
			toast.success({
				title: 'Your service is live',
				description: `${values.name} on port ${values.port}`
			});
		}
	}
);

<Field label="Name" error={form.error('name')}>
	{#snippet children(id)}
		<Input
			{id}
			bind:value={form.values.name}
			invalid={form.invalid('name')}
		/>
	{/snippet}
</Field>
<Button onclick={form.submit} disabled={form.submitting}>
	Register
</Button>

createListView

Search, filters, sort and pagination over any reactive source. The page goes back to 1 whenever the result set changes — derived, not effected.

12 of 12
Team Status
Ada Okafor ada@acme.co
platform active 82%
Bruno Sato bruno@acme.co
payments away 64%
Chloé Martin chloe@acme.co
growth active 41%
Dev Patel dev@acme.co
platform active 93%
Elin Berg elin@acme.co
growth offline 27%

Page 1 of 3

People.svelte svelte
import { createListView } from '@mirabelle-labs/vital-ui/state';

const view = createListView(() => PEOPLE, {
	search: ['name', 'email'],
	filters: {
		team: { field: 'team', initial: 'all' }
	},
	sort: {
		name: (a, b) => a.name.localeCompare(b.name),
		load: (a, b) => a.load - b.load
	},
	pageSize: 5
});

<SearchInput bind:value={view.query} />
<GroupChips bind:value={view.filters.team}>…</GroupChips>
<Th onclick={() => view.toggleSort('load')}>Load</Th>
{#each view.visible as person (person.id)}…{/each}
<Pagination bind:page={view.page} total={view.pageCount} />
<!-- any change to query/filters/sort resets the page -->

createAction

An async operation with loading, error and result, and a concurrency policy — declared in one object: run is the work, onSuccess and onError are what happens when it is over — you call toast there yourself, like anywhere else. Both receive the arguments run() was given. The AbortSignal run receives stops a restarted or cancelled call cleanly.

0 runs

Click Save twice quickly: the first run is aborted and only one toast shows (concurrent: 'restart').

Editor.svelte svelte
import { toast } from '@mirabelle-labs/vital-ui';
import { createAction } from '@mirabelle-labs/vital-ui/state';
import { delay } from '@mirabelle-labs/vital-ui/timing';

// One object, named fields: nothing is positional.
const save = createAction({
	// the AbortSignal comes first; ignore it if the call cannot be cancelled
	run: async (signal) => {
		await delay(1500, signal);
		return new Date();
	},
	// what the user sees is yours to say — the same toast as everywhere else
	onSuccess: () => {
		toast.success({
			title: 'Your changes are saved'
		});
	},
	concurrent: 'restart'
});

const publish = createAction({
	run: async (signal) => {
		await delay(600, signal);
		throw new Error('quota exceeded');
	},
	// never called for an abort; gets the error, then whatever run() was given
	onError: (e) => {
		toast.danger({
			title: 'We could not publish your changes',
			description: String(e)
		});
	}
});

<Button loading={save.loading} onclick={() => save.run()}>
	Save
</Button>
<Button variant="ghost" onclick={save.abort}>Cancel</Button>

createLog

A reactive ring buffer for event feeds: push to the front, the oldest entries fall off.

0 / 5

Nothing yet.

events.svelte.ts ts
import { createLog } from '@mirabelle-labs/vital-ui/state';

const events = createLog<string>({ max: 5 });

// the oldest entry falls off after five
events.push(`Deployed rev ${n}`);
events.items;   // newest first
events.clear();

Tones

Decide once how a status maps to a colour, then reuse it in every Badge, StatusPill and Progress. Typed: the result is exactly the set of tones you wrote.

active away offline

thresholdTone(72, { warning: 70, danger: 85 })

warning
+4.2% revenue −2.1% churn +1.5% error rate
tones.ts ts
import { toneMap, thresholdTone } from '@mirabelle-labs/vital-ui/data';
import { deltaTone } from '@mirabelle-labs/vital-ui/data';

const statusTone = toneMap({
	active: 'success',
	away: 'warning',
	offline: 'neutral'
});

<StatusPill status={statusTone(person.status)}>
	{person.status}
</StatusPill>

<Progress
	value={load}
	color={thresholdTone(load, { warning: 70, danger: 85 })}
/>

// deltaTone(-2.1, true) → 'success' (less churn is good)
<Stat delta={churn} … />

Format

Money, sizes, ages and dates. Pure, locale-aware, safe on the server; the Intl formatters are cached.

ExpressionResult
money(1234.5) $1,234.50
money(1234.5, { whole: true }) $1,235
money(1234.5, { currency: 'EUR', locale: 'fr-FR' }) 1 234,50 €
compact(12_400) 12.4K
percent(0.421) 42%
bytes(1_240_000) 1.2 MB
plural(3, 'item') 3 items
age(5_400) 1h
relativeTime(twoHoursAgo) 2 hours ago
shortDate('2026-09-06') Sep 6, 2026
shortDateTime('2026-09-06T10:40:00Z') Sep 6, 10:40 AM
format.ts ts
import { money, compact, bytes } from '@mirabelle-labs/vital-ui/format';
import { plural, age, shortDate } from '@mirabelle-labs/vital-ui/format';

money(1234.5)                   // $1,234.50
money(1234.5, { whole: true })  // $1,235
money(1234.5, { currency: 'EUR', locale: 'fr-FR' })
compact(12_400)                 // 12.4K
percent(0.421)                  // 42%
bytes(1_240_000)                // 1.2 MB
plural(3, 'item')               // 3 items
age(5_400)                      // 1h
relativeTime(twoHoursAgo)       // 2 hours ago
shortDate('2026-09-06')         // Sep 6, 2026

Data

Totals, averages, groups and partitions with a key or a picker function — readable in a getter, no reduce.

sumBy load: 749 avgBy load: 62.4 active: 7 overloaded: 3 fine: 9 busiest: Dev Patel
platform 4
payments 4
growth 4
report.ts ts
import { sumBy, avgBy, countBy } from '@mirabelle-labs/vital-ui/data';
import { tally, partition, sortBy } from '@mirabelle-labs/vital-ui/data';

sumBy(PEOPLE, 'load');       // 749
avgBy(PEOPLE, 'load');       // 62.4
countBy(PEOPLE, isActive);   // 7
tally(PEOPLE, 'team');       // → <BarList items={…} />
partition(PEOPLE, isBusy);   // [overloaded, fine]
sortBy(PEOPLE, 'load', 'desc')[0].name;

Timing

Timers that are no-ops on the server, return their disposer and cancel cleanly.

typed: — searched (300 ms later): —
Search.svelte svelte
import { debounce, after, every } from '@mirabelle-labs/vital-ui/timing';
import { delay } from '@mirabelle-labs/vital-ui/timing';

const search = debounce((q) => (results = find(q)), 300);

<Input oninput={(e) => search(e.currentTarget.value)} />

// both return their disposer, both no-op on the server
onMount(() => after(700, () => (loading = false)));
onMount(() => every(5000, () => tick(5)));

// rejects with an AbortError when the signal fires
await delay(1200, signal);

Toast shorthands

One call per toast type; the object form stays available.

anywhere.ts ts
import { toast } from '@mirabelle-labs/vital-ui';

// the title speaks to whoever just acted: their result, not a log line
toast.success({
	title: 'You sent an invitation',
	description: 'ada@acme.co'
});

toast.danger({
	title: 'You cannot delete this node',
	description: 'The autoscaler owns it.'
});

// every field is named: a custom duration, and meta carried along for whoever reads toasts.items
toast.info({
	title: 'Your rollout has started',
	description: 'v2.15.0',
	duration: 8000,
	meta: { release: 'v2.15.0' }
});

// the type known only at run time? the bare call takes the same object plus `type`
toast({
	type: level,
	title: 'Your quota is at 90%'
});