Docs Migration
From shadcn-svelte and Bits UI
Move an application from shadcn-svelte or Bits UI components to native elements with Svelte Lean attributes, one screen at a time.
Markdown version of this page, for assistants (Markdown for AI)
On this page
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
- Install
@svelte-lean/primitives,@svelte-lean/stylesand, as a dev dependency,@svelte-lean/vite(Installation). - Add the Vite plugin before
sveltekit(); it registers each behavior on the pages that use it. - Import the tokens, the base stylesheet and the stylesheet of each primitive you use.
- 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
import { sveltekit } from '@sveltejs/kit/vite';
import { svelteLean } from '@svelte-lean/vite';
import { defineConfig } from 'vite';
export default defineConfig({ plugins: [svelteLean(), sveltekit()] });// 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
| Concern | shadcn-svelte, Bits UI | Svelte Lean |
|---|---|---|
| A widget | A component per part: Root, Trigger, Content, Item | The native element with data-slean on the root and data-slean-part where a part needs a name |
| Open state | bind:open on Root | The browser's own state: <dialog open>, the popover's open state, <details open>, driven by commandfor/command and popovertarget |
| Value | bind:value on Root | bind:value on the native element (<select>, <input>); the native value is also the form value |
| Callbacks | onValueChange, onOpenChange, onSelect | Native events (change, toggle, close) and slean:* events such as slean:select and slean:change |
| Portals and layering | A Portal component | The top layer of <dialog> and popover; no portal |
| Positioning | Floating UI, side and align props | CSS anchor positioning; data-slean-side and data-slean-align |
| Styling | Tailwind classes and cn() in the copied files | Design tokens, one stylesheet per primitive, data-variant, data-color, data-size; Tailwind still works on the elements |
| Custom elements | child snippet or asChild | Write the element you want; the attributes go on it |
| Ids | Generated by the component | An 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 UI | Svelte Lean | Tier | What to write |
|---|---|---|---|
| Accordion | accordion | Tier 0 | <details name="…"> per item; the shared name makes one open at a time |
| Collapsible | accordion | Tier 0 | One <details> without a name |
| Alert | alert | Tier 0 | A message block with role="status" or role="alert" and a color |
| Alert Dialog | dialog | Tier 0 | <dialog role="alertdialog" closedby="none">, opened with command="show-modal" |
| Avatar | avatar | Tier 0 | An image with initials behind it |
| Badge | badge | Tier 0 | A <span data-slean="badge"> |
| Breadcrumb | breadcrumb | Tier 0 | <nav> with an ordered list |
| Button | button | Tier 0 | <button data-slean="button">, data-variant and data-color for the look |
| Calendar, Range Calendar | calendar | Tier 1 | One primitive; mode single, range or multiple |
| Card | card | Tier 0 | <article data-slean="card"> |
| Carousel | carousel | Tier 0 | A CSS scroll-snap list |
| Checkbox | checkbox | Tier 0 | <input type="checkbox" data-slean="checkbox"> |
| Switch | checkbox | Tier 0 | The same input with role="switch" |
| Combobox | select | Tier 2 | A select with data-slean-search; Autocomplete when free text is allowed |
| Command | – | – | No dedicated primitive: an input and a Listbox, or Autocomplete |
| Context Menu | menu | Tier 1 | A Menu opened by data-slean-contextmenu on the target |
| Data Table, Table | – | – | @svelte-lean/table (DataTable) |
| Date Picker | date-picker | Tier 2 | A native date input with a calendar popup |
| Dialog | dialog | Tier 0 | <dialog>, opened with commandfor and command="show-modal" |
| Drawer, Sheet | dialog | Tier 0 | The same <dialog> with data-slean-side="right|left|top|bottom" |
| Dropdown Menu | menu | Tier 1 | A popover with data-slean="menu"; slean:select reports the item |
| Form | field | Tier 0 | A native <form> with Field for label, hint and error |
| Hover Card | hover-card | Tier 1 | A card that a link names with interestfor; shown on hover and keyboard focus |
| Input, Textarea | input | Tier 1 | Native <input> and <textarea>; input.css draws both |
| Input OTP | input | Tier 1 | One <input autocomplete="one-time-code" data-slean="input">, drawn as cells |
| Label | – | – | The native <label> |
| Menubar | menubar | Tier 1 | Application menus |
| Navigation Menu | – | – | No primitive; Menubar is for application menus, not site navigation |
| Pagination | pagination | Tier 0 | <nav> with links |
| Popover | popover | Tier 0 | The popover attribute and popovertarget |
| Progress | progress | Tier 0 | The native <progress> |
| Radio Group | radio-group | Tier 0 | Native radios in a <fieldset> |
| Resizable | splitter | Tier 2 | Panes with a drag handle |
| Scroll Area | scroll-area | Tier 0 | A styled overflow container |
| Select | select | Tier 2 | A bare <select data-slean="select">, or the owned field with selectParts() |
| Separator | separator | Tier 0 | The native <hr> |
| Sidebar | nav | Tier 0 | A <nav> of links and <details> groups; data-slean-collapsed for the icon rail |
| Skeleton | skeleton | Tier 0 | A placeholder block |
| Slider | slider | Tier 1 | The native range input |
| Sonner, Toast | toast | Tier 2 | One region per page and showToast() |
| Tabs | tabs | Tier 1 | Roles and ids from tabsParts(id) |
| Toggle | toggle | Tier 1 | A <button aria-pressed> |
| Toggle Group | toggle-group | Tier 1 | Buttons in a group with one shared value |
| Tooltip | hint | Tier 1 | data-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
<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><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.
Dropdown menu
<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><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
<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><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
<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><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
<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><button type="button" data-slean="button" data-slean-hint="Saves the draft (Ctrl+S)">
Save
</button>Every option of this primitive: Tooltip.
Accordion
<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><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
idwhen a helper derives part ids from it. data-slean="…"is written as a static attribute, so@svelte-lean/vitefinds it in the source and injects the behavior.- Values bound with
bind:valuesit on the native element, not on a wrapper. - Open and close go through
commandfor,popovertargetor 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.