sveltelean
Version v0.17.4

shadcn-svelte copies components built on Bits UI into the application; Bits UI ships them as a package. Both describe a widget as a tree of Svelte components. Svelte Lean describes it as the HTML element the browser already has, marked with data-slean attributes, and adds a shared behavior only where the platform has none. The two models can run side by side, so the move is made screen by screen; Compared with shadcn-svelte and Compared with Bits UI explain when not to move at all.

Before you start

  1. Install @svelte-lean/primitives, @svelte-lean/styles and, as a dev dependency, @svelte-lean/vite (Installation).
  2. Add the Vite plugin before sveltekit(); it registers each behavior on the pages that use it.
  3. Import the tokens, the base stylesheet and the stylesheet of each primitive you use.
  4. Keep the existing components. Replace one screen, check it with the keyboard and a screen reader, then remove the components no screen imports any more.
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()] });
+layout.svelte
// src/routes/+layout.svelte (script): tokens and base once, then one file per primitive used
import '@svelte-lean/styles/tokens.css';
import '@svelte-lean/styles/base.css';
import '@svelte-lean/styles/button.css';
import '@svelte-lean/styles/dialog.css';

What changes

Concernshadcn-svelte, Bits UISvelte Lean
A widgetA component per part: Root, Trigger, Content, ItemThe native element with data-slean on the root and data-slean-part where a part needs a name
Open statebind:open on RootThe browser's own state: <dialog open>, the popover's open state, <details open>, driven by commandfor/command and popovertarget
Valuebind:value on Rootbind:value on the native element (<select>, <input>); the native value is also the form value
CallbacksonValueChange, onOpenChange, onSelectNative events (change, toggle, close) and slean:* events such as slean:select and slean:change
Portals and layeringA Portal componentThe top layer of <dialog> and popover; no portal
PositioningFloating UI, side and align propsCSS anchor positioning; data-slean-side and data-slean-align
StylingTailwind classes and cn() in the copied filesDesign tokens, one stylesheet per primitive, data-variant, data-color, data-size; Tailwind still works on the elements
Custom elementschild snippet or asChildWrite the element you want; the attributes go on it
IdsGenerated by the componentAn author id on the root; selectParts(id), tabsParts(id) and the other helpers derive the part ids from it

Component map

The tier is read from the package: Tier 0 is the browser's own behavior with no Svelte Lean JavaScript, Tier 1 a shared listener, Tier 2 a controller created on first use (Runtime tiers).

shadcn-svelte, Bits UISvelte LeanTierWhat to write
AccordionaccordionTier 0<details name="…"> per item; the shared name makes one open at a time
CollapsibleaccordionTier 0One <details> without a name
AlertalertTier 0A message block with role="status" or role="alert" and a color
Alert DialogdialogTier 0<dialog role="alertdialog" closedby="none">, opened with command="show-modal"
AvataravatarTier 0An image with initials behind it
BadgebadgeTier 0A <span data-slean="badge">
BreadcrumbbreadcrumbTier 0<nav> with an ordered list
ButtonbuttonTier 0<button data-slean="button">, data-variant and data-color for the look
Calendar, Range CalendarcalendarTier 1One primitive; mode single, range or multiple
CardcardTier 0<article data-slean="card">
CarouselcarouselTier 0A CSS scroll-snap list
CheckboxcheckboxTier 0<input type="checkbox" data-slean="checkbox">
SwitchcheckboxTier 0The same input with role="switch"
ComboboxselectTier 2A select with data-slean-search; Autocomplete when free text is allowed
Command––No dedicated primitive: an input and a Listbox, or Autocomplete
Context MenumenuTier 1A Menu opened by data-slean-contextmenu on the target
Data Table, Table––@svelte-lean/table (DataTable)
Date Pickerdate-pickerTier 2A native date input with a calendar popup
DialogdialogTier 0<dialog>, opened with commandfor and command="show-modal"
Drawer, SheetdialogTier 0The same <dialog> with data-slean-side="right|left|top|bottom"
Dropdown MenumenuTier 1A popover with data-slean="menu"; slean:select reports the item
FormfieldTier 0A native <form> with Field for label, hint and error
Hover Cardhover-cardTier 1A card that a link names with interestfor; shown on hover and keyboard focus
Input, TextareainputTier 1Native <input> and <textarea>; input.css draws both
Input OTPinputTier 1One <input autocomplete="one-time-code" data-slean="input">, drawn as cells
Label––The native <label>
MenubarmenubarTier 1Application menus
Navigation Menu––No primitive; Menubar is for application menus, not site navigation
PaginationpaginationTier 0<nav> with links
PopoverpopoverTier 0The popover attribute and popovertarget
ProgressprogressTier 0The native <progress>
Radio Groupradio-groupTier 0Native radios in a <fieldset>
ResizablesplitterTier 2Panes with a drag handle
Scroll Areascroll-areaTier 0A styled overflow container
SelectselectTier 2A bare <select data-slean="select">, or the owned field with selectParts()
SeparatorseparatorTier 0The native <hr>
SidebarnavTier 0A <nav> of links and <details> groups; data-slean-collapsed for the icon rail
SkeletonskeletonTier 0A placeholder block
SlidersliderTier 1The native range input
Sonner, ToasttoastTier 2One region per page and showToast()
TabstabsTier 1Roles and ids from tabsParts(id)
ToggletoggleTier 1A <button aria-pressed>
Toggle Grouptoggle-groupTier 1Buttons in a group with one shared value
TooltiphintTier 1data-slean-hint="…" on the control

Examples

Each pair renders the same widget. The first block is shadcn-svelte as its documentation writes it; the second is the Svelte Lean markup, with the stylesheets of the primitive imported once.

Dialog

Before: shadcn-svelte
<script lang="ts">
	import * as Dialog from '$lib/components/ui/dialog/index.js';
	import { Button } from '$lib/components/ui/button/index.js';
</script>

<Dialog.Root>
	<Dialog.Trigger>Settings</Dialog.Trigger>
	<Dialog.Content>
		<Dialog.Header>
			<Dialog.Title>Settings</Dialog.Title>
			<Dialog.Description>Changes apply to this device.</Dialog.Description>
		</Dialog.Header>
		<Dialog.Footer>
			<Dialog.Close>Cancel</Dialog.Close>
			<Button onclick={save}>Save</Button>
		</Dialog.Footer>
	</Dialog.Content>
</Dialog.Root>
After: Svelte Lean
<button type="button" data-slean="button" commandfor="settings" command="show-modal">
	Settings
</button>
<dialog id="settings" data-slean="dialog" closedby="any" aria-labelledby="settings-title">
	<header>
		<h2 id="settings-title">Settings</h2>
		<p>Changes apply to this device.</p>
	</header>
	<footer>
		<button type="button" data-slean="button" data-variant="text" commandfor="settings" command="close">
			Cancel
		</button>
		<button type="button" data-slean="button" commandfor="settings" command="close" onclick={save}>
			Save
		</button>
	</footer>
	<button type="button" data-slean-part="close" commandfor="settings" command="close" aria-label="Close"></button>
</dialog>

Every option of this primitive: Dialog.

Before: shadcn-svelte
<script lang="ts">
	import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
</script>

<DropdownMenu.Root>
	<DropdownMenu.Trigger>Actions</DropdownMenu.Trigger>
	<DropdownMenu.Content>
		<DropdownMenu.Item onSelect={() => run('edit')}>Edit</DropdownMenu.Item>
		<DropdownMenu.Item onSelect={() => run('archive')}>Archive</DropdownMenu.Item>
		<DropdownMenu.Separator />
		<DropdownMenu.Item onSelect={() => run('delete')}>Delete</DropdownMenu.Item>
	</DropdownMenu.Content>
</DropdownMenu.Root>
After: Svelte Lean
<script lang="ts">
	import type { MenuSelectDetail } from '@svelte-lean/primitives/menu';
	import { on } from 'svelte/events';

	function onAction(event: Event) {
		run((event as CustomEvent<MenuSelectDetail>).detail.value ?? '');
	}
</script>

<button type="button" data-slean="button" popovertarget="actions-menu" aria-haspopup="menu">
	Actions
</button>
<div id="actions-menu" popover data-slean="menu" {@attach (node) => on(node, 'slean:select', onAction)}>
	<button type="button" data-slean-value="edit">Edit</button>
	<button type="button" data-slean-value="archive">Archive</button>
	<hr />
	<button type="button" data-slean-value="delete" data-color="error">Delete</button>
</div>

Every option of this primitive: Dropdown menu.

Tabs

Before: shadcn-svelte
<script lang="ts">
	import * as Tabs from '$lib/components/ui/tabs/index.js';
</script>

<Tabs.Root value="account">
	<Tabs.List>
		<Tabs.Trigger value="account">Account</Tabs.Trigger>
		<Tabs.Trigger value="security">Security</Tabs.Trigger>
	</Tabs.List>
	<Tabs.Content value="account">Name, email and avatar.</Tabs.Content>
	<Tabs.Content value="security">Password and sessions.</Tabs.Content>
</Tabs.Root>
After: Svelte Lean
<script lang="ts">
	import { tabsParts } from '@svelte-lean/primitives/tabs';

	const tabs = [
		{ value: 'account', label: 'Account', text: 'Name, email and avatar.' },
		{ value: 'security', label: 'Security', text: 'Password and sessions.' }
	];
	const t = tabsParts('settings');
</script>

<div id="settings" data-slean="tabs" data-slean-value="account">
	<div {...t.list} aria-label="Settings">
		{#each tabs as tab (tab.value)}<button {...t.tab(tab.value, 'account')}>{tab.label}</button>{/each}
	</div>
	{#each tabs as tab (tab.value)}
		<div {...t.panel(tab.value, 'account')} tabindex="0">{tab.text}</div>
	{/each}
</div>

Every option of this primitive: Tabs.

Select

Before: shadcn-svelte
<script lang="ts">
	import * as Select from '$lib/components/ui/select/index.js';

	const fruits = [
		{ value: 'apple', label: 'Apple' },
		{ value: 'mango', label: 'Mango' }
	];
	let fruit = $state('');
	const label = $derived(fruits.find((f) => f.value === fruit)?.label ?? 'Select a fruit');
</script>

<Select.Root type="single" name="fruit" bind:value={fruit}>
	<Select.Trigger>{label}</Select.Trigger>
	<Select.Content>
		{#each fruits as f (f.value)}
			<Select.Item value={f.value} label={f.label}>{f.label}</Select.Item>
		{/each}
	</Select.Content>
</Select.Root>
After: Svelte Lean
<script lang="ts">
	import { selectParts } from '@svelte-lean/primitives/select';

	const fruits = [
		{ value: 'apple', label: 'Apple' },
		{ value: 'mango', label: 'Mango' }
	];
	const p = selectParts('fruit');
	let fruit = $state('');
	const label = $derived(fruits.find((f) => f.value === fruit)?.label);
</script>

<label {...p.label}>Fruit</label>
<div id="fruit" data-slean="select">
	<select {...p.native} name="fruit" bind:value={fruit}>
		<option value="" hidden>Select a fruit</option>
		{#each fruits as f (f.value)}<option value={f.value}>{f.label}</option>{/each}
	</select>
	<button {...p.trigger}>{label ?? 'Select a fruit'}</button>
	<button {...p.clear}></button>
</div>

Every option of this primitive: Select.

Tooltip

Before: shadcn-svelte
<script lang="ts">
	import * as Tooltip from '$lib/components/ui/tooltip/index.js';
</script>

<Tooltip.Provider>
	<Tooltip.Root>
		<Tooltip.Trigger>Save</Tooltip.Trigger>
		<Tooltip.Content>Saves the draft (Ctrl+S)</Tooltip.Content>
	</Tooltip.Root>
</Tooltip.Provider>
After: Svelte Lean
<button type="button" data-slean="button" data-slean-hint="Saves the draft (Ctrl+S)">
	Save
</button>

Every option of this primitive: Tooltip.

Accordion

Before: shadcn-svelte
<script lang="ts">
	import * as Accordion from '$lib/components/ui/accordion/index.js';
</script>

<Accordion.Root type="single">
	<Accordion.Item value="shipping">
		<Accordion.Trigger>Shipping</Accordion.Trigger>
		<Accordion.Content>Orders ship within two working days.</Accordion.Content>
	</Accordion.Item>
	<Accordion.Item value="returns">
		<Accordion.Trigger>Returns</Accordion.Trigger>
		<Accordion.Content>Thirty days, with the original packaging.</Accordion.Content>
	</Accordion.Item>
</Accordion.Root>
After: Svelte Lean
<details data-slean="accordion" name="faq">
	<summary>Shipping</summary>
	Orders ship within two working days.
</details>
<details data-slean="accordion" name="faq">
	<summary>Returns</summary>
	Thirty days, with the original packaging.
</details>

Every option of this primitive: Accordion.

Checklist per screen

  • Every root has a unique id when a helper derives part ids from it.
  • data-slean="…" is written as a static attribute, so @svelte-lean/vite finds it in the source and injects the behavior.
  • Values bound with bind:value sit on the native element, not on a wrapper.
  • Open and close go through commandfor, popovertarget or the element's own methods, not through Svelte state.
  • The development console shows no [Svelte Lean: …] markup warning.
  • Tab, the arrow keys, Enter and Escape work as the primitive's Keyboard table says.

Note A primitive without a row in the map has no Svelte Lean equivalent yet: keep the shadcn-svelte or Bits UI component for it. Both models work in the same page.

Server state

Data fetching moves separately. From TanStack Query and From RTK Query map each API to @svelte-lean/query.

With an assistant

This page is also published as Markdown: give an assistant its Markdown version and the page of each primitive the screen uses, and ask it to convert one screen at a time. Markdown for AI lists every file.