Docs Start
Installation
One package per product, an optional Vite plugin for the behaviors that need a runtime, and one stylesheet per primitive. Markup that uses only native primitives needs no plugin and no import.
On this page
Requirements
- Node
^20.19.0 || >=22.12.0for the build (theenginesrange 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 on22.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 Svelte5.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 Vite7.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
moduleResolutionset tobundlerornode16for 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/primitivespnpm add @svelte-lean/primitivesyarn add @svelte-lean/primitivesbun add @svelte-lean/primitivesnpm install @svelte-lean/stylespnpm add @svelte-lean/stylesyarn add @svelte-lean/stylesbun add @svelte-lean/stylesnpm install --save-dev @svelte-lean/vitepnpm add -D @svelte-lean/viteyarn add -D @svelte-lean/vitebun add -d @svelte-lean/vitenpm install @svelte-lean/tablepnpm add @svelte-lean/tableyarn add @svelte-lean/tablebun add @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.
<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
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, 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.
<!-- 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>// 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.
// 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.
# 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// 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.
// 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// 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.
<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.
<!-- 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
/// <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
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
export default defineConfig({
plugins: [svelteLean({ report: true }), sveltekit()]
});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.jsonThe 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.