sveltelean
Versionv0.2.0 GitHub

Requirements

  • Node ^20.19.0 || >=22.12.0 for the build (the engines range of @svelte-lean/vite, which is Vite's own); the other packages run in the browser or under any Node that runs the application. Working on the repository itself needs Node >=22.18.0; it develops and tests on 22.23.3.
  • Svelte ^5.0.0, the peer dependency of @svelte-lean/table; the primitives and the runtime are plain ES modules with no framework dependency. Tested with Svelte 5.57.1.
  • Vite ^6.0.0 || ^7.0.0 || ^8.0.0, the peer dependency of @svelte-lean/vite. The repository's suite runs on Vite 7.3.6, and the nightly workflow runs it against the latest Vite release; Vite 6 is in the range and not tested.
  • TypeScript 5 with moduleResolution set to bundler or node16 for the declaration files. SvelteKit is not required.

The versions above are read from the repository's package files at build time; the full matrix is on Compatibility.

Packages

Install the products you use. The primitives package holds every primitive as a subpath export; the styles package is optional and ships CSS only; the Vite plugin is a development dependency.

npm install @svelte-lean/primitives
npm install @svelte-lean/styles
npm install --save-dev @svelte-lean/vite
npm install @svelte-lean/table

@svelte-lean/core is a dependency of the primitives package and is installed with it. The packages are ESM-only with strict exports maps and are at a preview version; they are not published to npm yet.

Mode A: native only

A native primitive is platform markup. This dialog opens and closes through invoker commands, with the top layer, page inertness, Escape and focus return provided by the browser. Nothing is imported and no plugin is involved; the data-slean attributes are hooks for the optional stylesheet and for development validation, and the markup stays valid without them.

settings.svelte
<button type="button" commandfor="settings" command="show-modal">Settings</button>

<dialog id="settings" data-slean="dialog" aria-labelledby="settings-title">
	<header data-slean-part="header">
		<h2 id="settings-title" data-slean-part="title">Settings</h2>
	</header>
	<div data-slean-part="body">…</div>
	<footer data-slean-part="footer">
		<button type="button" commandfor="settings" command="close">Close</button>
	</footer>
</dialog>

The production fixture that uses button, dialog and popover markup with the stylesheet and the plugin emits a Svelte Lean chunk of 0 B (fixtures/native-only). The platform features the markup depends on, and what happens where one is missing, are on Browser support.

Mode B: package-managed behavior

Tabs and menus need JavaScript the browser does not provide. Add the plugin to the Vite configuration; it reads the Svelte markup before the compiler runs and appends one import per behavior it finds to the compiled module.

vite.config.ts
// vite.config.ts
import { sveltekit } from '@sveltejs/kit/vite';
import { svelteLean } from '@svelte-lean/vite';
import { defineConfig } from 'vite';

export default defineConfig({
	plugins: [svelteLean(), sveltekit()]
});
vite.config.ts
// vite.config.ts, plain Vite without SvelteKit
import { svelte } from '@sveltejs/vite-plugin-svelte';
import { svelteLean } from '@svelte-lean/vite';
import { defineConfig } from 'vite';

export default defineConfig({
	plugins: [svelteLean(), svelte()]
});

The author writes the markup of the contract with a static marker. The plugin does not touch the markup; it adds an import to the compiled JavaScript.

+page.svelte
<!-- src/routes/settings/+page.svelte -->
<div data-slean="tabs" data-slean-value="account">
	<div role="tablist" aria-label="Settings" data-slean-part="list">
		<button
			type="button"
			id="tab-account"
			role="tab"
			aria-controls="panel-account"
			aria-selected="true"
			tabindex="0"
			data-slean-part="trigger"
			data-slean-value="account"
		>
			Account
		</button>
		<button
			type="button"
			id="tab-security"
			role="tab"
			aria-controls="panel-security"
			aria-selected="false"
			tabindex="-1"
			data-slean-part="trigger"
			data-slean-value="security"
		>
			Security
		</button>
	</div>
	<div
		id="panel-account"
		role="tabpanel"
		aria-labelledby="tab-account"
		data-slean-part="panel"
		data-slean-value="account"
	>
		…
	</div>
	<div
		id="panel-security"
		role="tabpanel"
		aria-labelledby="tab-security"
		data-slean-part="panel"
		data-slean-value="security"
		hidden
	>
		…
	</div>
</div>
+page.svelte, compiled
// What the compiled module of that page receives from the plugin, appended after the
// compiled code (imports are hoisted, so it evaluates first):
import '@svelte-lean/primitives/tabs/register';

Bundlers de-duplicate modules, so a hundred files that use tabs still produce one copy of the registration module; registration is idempotent, so mixing injected and manual imports is safe. In the tabs-only production fixture the Svelte Lean chunk is 1521 B brotli.

Manual registration

Automatic discovery is never mandatory. Without the plugin, with a dynamic identifier such as data-slean={kind}, or in a build that is not Vite, import the registration module yourself, once, in a module that runs on the client.

+layout.svelte
// src/routes/+layout.svelte (or any module that runs once on the client)
import '@svelte-lean/primitives/tabs/register';

This is the same module the plugin injects, so the runtime behavior is identical. The manual-registration fixture ships a Svelte Lean chunk of 1521 B brotli against 1521 B brotli for the plugin path, with the same module set asserted by both fixtures.

Mode C: owned styles

The stylesheet of a primitive is a plain CSS file. Copy it into the application and edit it there; it keeps depending on tokens.css (or on your own tokens) and the behavior stays in the package, so an accessibility fix in the runtime reaches the project through npm. The slean add command that would do the copy is not available yet; today the copy is a file operation.

Terminal
# Mode C today: copy the file and own it. The behavior stays in the package.
cp node_modules/@svelte-lean/styles/css/tabs.css src/lib/styles/tabs.css
+layout.svelte
// The copied file keeps depending on the tokens; the behavior is still registered
// by the plugin (or by the manual import), not copied.
import '@svelte-lean/styles/tokens.css';
import '@svelte-lean/styles/base.css';
import '$lib/styles/tabs.css';

Copying behavior code is deliberately not offered: the runtime is accessibility-sensitive and is maintained in one place (ADR 0005). The three styling modes, cascade layers and variants are on Styling.

Styles

Granular imports are the primary path: the tokens and the base file, then one file per primitive you use. Every file declares the same cascade layer order, so the order between primitive files does not matter. A few files build on others and need them imported too: menu.css and context-menu.css need popover.css, button-group.css needs button.css; each primitive page lists its stylesheets in order.

+layout.svelte
// src/routes/+layout.svelte
import '@svelte-lean/styles/tokens.css'; // required by every primitive file
import '@svelte-lean/styles/base.css'; // required by every primitive file
import '@svelte-lean/styles/dialog.css';
import '@svelte-lean/styles/tabs.css';
import '@svelte-lean/styles/menu.css'; // imports popover.css itself
TypeScript
// Convenience only: every stylesheet, for prototypes
import '@svelte-lean/styles';

Every selector is wrapped in :where(), so any unlayered rule in the application wins. Dark values apply under prefers-color-scheme: dark or with data-theme="dark" on the root; this site's own palette is a set of token overrides. The table ships its own @svelte-lean/table/style.css, which reads the same tokens when they are defined.

Table

The table is a Svelte component with three required props and a stylesheet. It uses no behavior runtime and no plugin.

members.svelte
<script lang="ts">
	import { DataTable, type Column } from '@svelte-lean/table';
	import '@svelte-lean/table/style.css';

	type Member = { id: number; name: string; revenue: number };
	const data: Member[] = [
		{ id: 1, name: 'Amara Okafor', revenue: 1200 },
		{ id: 2, name: 'Jonas Lindqvist', revenue: 9119 }
	];
	const columns: Column<Member>[] = [
		{ id: 'name', header: 'Member' },
		{ id: 'revenue', header: 'Revenue', align: 'end' }
	];
</script>

<DataTable {data} {columns} getRowId={(row) => row.id} />

Grouping, pivot, clipboard, CSV export, server data sources and persisted state are separate entry points that stay out of the bundle until imported; the first render, state binding and the semantic output are on Getting started.

Events in Svelte

Behaviors report changes as cancelable DOM events on their root: slean:change, slean:select, slean:toggle and the others each contract lists. In Svelte 5 markup, listen with an event attribute: onslean:change={…} compiles to a listener for slean:change on that element, removed with it, with no bind:this and no addEventListener call.

+page.svelte
<!-- src/routes/settings/+page.svelte -->
<script lang="ts">
	let subscribed = $state(false);
</script>

<div
	data-slean="tabs"
	data-slean-value="account"
	onslean:change={(event) => {
		// detail is the union of every change detail; behavior narrows it.
		if (event.detail.behavior !== 'tabs') return;
		if (event.detail.value === 'billing' && !subscribed) event.preventDefault();
	}}
>
	…
</div>

Two one-time settings make this typed and quiet. The types-only entry @svelte-lean/primitives/svelte-elements adds the onslean:* attributes to Svelte's element types, so event.detail is typed (every detail carries a behavior field that narrows it) and a misspelled event name fails svelte-check. It also types data-slean as a primitive name where attributes are checked as props, for example on a component that forwards its rest props to an element; svelte-check does not type-check data-* attributes written directly on an element.

src/app.d.ts
// src/app.d.ts
/// <reference types="@svelte-lean/primitives/svelte-elements" />

declare global {
	namespace App {}
}

export {};

The Svelte compiler warns about every attribute name that contains a colon (attribute_illegal_colon), because it could be read as a directive. The attribute still compiles to the listener; filter that one warning for the onslean: attributes.

svelte.config.js
// svelte.config.js
export default {
	compilerOptions: {
		// onslean:change listens to slean:change; the compiler warns about every attribute name
		// with a colon, so the warning is silenced for the slean events only.
		warningFilter: (warning) =>
			!(warning.code === 'attribute_illegal_colon' && warning.frame?.includes('onslean:'))
	}
};

SvelteKit

Place svelteLean() before sveltekit() so the scanner sees the source before any preprocessor. In development the plugin keeps @svelte-lean/primitives and @svelte-lean/core out of Vite's dependency pre-bundling, so the first page that uses a behavior does not trigger a re-optimization and a full reload. Registration modules are SSR-safe: they touch neither document nor window at evaluation, and the plugin skips server transforms by default (ssr: 'skip') because the runtime does nothing on the server. Every route of this site is prerendered with the same configuration.

Ids in ARIA relationships (aria-controls, aria-labelledby, popovertarget, commandfor) are authored, never generated, so server-rendered markup is correct before hydration and hydration changes nothing.

Build verification

A production build writes a behavior manifest to node_modules/.svelte-lean/manifest.json: every behavior found, the module injected for it, and the files that use it. With report: true the plugin also prints a summary after the client bundle is written.

vite.config.ts
// vite.config.ts
export default defineConfig({
	plugins: [svelteLean({ report: true }), sveltekit()]
});
Build output
svelte-lean build report
  native-only  button, dialog
  runtime      tabs -> @svelte-lean/primitives/tabs/register
                 src/routes/settings/+page.svelte
  unknown      none
  dynamic      none
  manifest     node_modules/.svelte-lean/manifest.json

The report states what the scan observed. Bundle sizes and listener counts are measured by the consumer fixtures in the repository, not estimated by the plugin; the proof page shows the manifest of this site's own build and the listener count while mounting a thousand tabs roots.