# From shadcn-svelte and Bits UI

> Move an application from shadcn-svelte or Bits UI to native elements with Svelte Lean attributes: what changes, a component map with tiers, before and after examples and a checklist per screen.

Page: https://svelte-lean.vercel.app/docs/migration/shadcn-svelte · Markdown: https://svelte-lean.vercel.app/docs/migration/shadcn-svelte.md · Index: https://svelte-lean.vercel.app/llms.txt

Move an application from shadcn-svelte or Bits UI components to native elements with Svelte Lean attributes, one screen at a time.

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](https://svelte-lean.vercel.app/docs/why-not-shadcn-svelte.md) and [Compared with Bits UI](https://svelte-lean.vercel.app/docs/why-not-bits-ui.md) 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](https://svelte-lean.vercel.app/docs/installation.md)).
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`

```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`

```ts
// 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](https://svelte-lean.vercel.app/docs/architecture/runtime-tiers.md)).

| shadcn-svelte, Bits UI | Svelte Lean | Tier | What to write |
| --- | --- | --- | --- |
| Accordion | [`accordion`](https://svelte-lean.vercel.app/primitives/accordion.md) | Tier 0 | \<details name="…"\> per item; the shared name makes one open at a time |
| Collapsible | [`accordion`](https://svelte-lean.vercel.app/primitives/accordion.md) | Tier 0 | One \<details\> without a name |
| Alert | [`alert`](https://svelte-lean.vercel.app/primitives/alert.md) | Tier 0 | A message block with role="status" or role="alert" and a color |
| Alert Dialog | [`dialog`](https://svelte-lean.vercel.app/primitives/dialog.md) | Tier 0 | \<dialog role="alertdialog" closedby="none"\>, opened with command="show-modal" |
| Avatar | [`avatar`](https://svelte-lean.vercel.app/primitives/avatar.md) | Tier 0 | An image with initials behind it |
| Badge | [`badge`](https://svelte-lean.vercel.app/primitives/badge.md) | Tier 0 | A \<span data-slean="badge"\> |
| Breadcrumb | [`breadcrumb`](https://svelte-lean.vercel.app/primitives/breadcrumb.md) | Tier 0 | \<nav\> with an ordered list |
| Button | [`button`](https://svelte-lean.vercel.app/primitives/button.md) | Tier 0 | \<button data-slean="button"\>, data-variant and data-color for the look |
| Calendar, Range Calendar | [`calendar`](https://svelte-lean.vercel.app/primitives/calendar.md) | Tier 1 | One primitive; mode single, range or multiple |
| Card | [`card`](https://svelte-lean.vercel.app/primitives/card.md) | Tier 0 | \<article data-slean="card"\> |
| Carousel | [`carousel`](https://svelte-lean.vercel.app/primitives/carousel.md) | Tier 0 | A CSS scroll-snap list |
| Checkbox | [`checkbox`](https://svelte-lean.vercel.app/primitives/checkbox.md) | Tier 0 | \<input type="checkbox" data-slean="checkbox"\> |
| Switch | [`checkbox`](https://svelte-lean.vercel.app/primitives/checkbox.md) | Tier 0 | The same input with role="switch" |
| Combobox | [`select`](https://svelte-lean.vercel.app/primitives/select.md) | 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`](https://svelte-lean.vercel.app/primitives/menu.md) | Tier 1 | A Menu opened by data-slean-contextmenu on the target |
| Data Table, Table | – | – | @svelte-lean/table (DataTable) |
| Date Picker | [`date-picker`](https://svelte-lean.vercel.app/primitives/date-picker.md) | Tier 2 | A native date input with a calendar popup |
| Dialog | [`dialog`](https://svelte-lean.vercel.app/primitives/dialog.md) | Tier 0 | \<dialog\>, opened with commandfor and command="show-modal" |
| Drawer, Sheet | [`dialog`](https://svelte-lean.vercel.app/primitives/dialog.md) | Tier 0 | The same \<dialog\> with data-slean-side="right\|left\|top\|bottom" |
| Dropdown Menu | [`menu`](https://svelte-lean.vercel.app/primitives/menu.md) | Tier 1 | A popover with data-slean="menu"; slean:select reports the item |
| Form | [`field`](https://svelte-lean.vercel.app/primitives/field.md) | Tier 0 | A native \<form\> with Field for label, hint and error |
| Hover Card | [`hover-card`](https://svelte-lean.vercel.app/primitives/hover-card.md) | Tier 1 | A card that a link names with interestfor; shown on hover and keyboard focus |
| Input, Textarea | [`input`](https://svelte-lean.vercel.app/primitives/input.md) | Tier 1 | Native \<input\> and \<textarea\>; input.css draws both |
| Input OTP | [`input`](https://svelte-lean.vercel.app/primitives/input.md) | Tier 1 | One \<input autocomplete="one-time-code" data-slean="input"\>, drawn as cells |
| Label | – | – | The native \<label\> |
| Menubar | [`menubar`](https://svelte-lean.vercel.app/primitives/menubar.md) | Tier 1 | Application menus |
| Navigation Menu | – | – | No primitive; Menubar is for application menus, not site navigation |
| Pagination | [`pagination`](https://svelte-lean.vercel.app/primitives/pagination.md) | Tier 0 | \<nav\> with links |
| Popover | [`popover`](https://svelte-lean.vercel.app/primitives/popover.md) | Tier 0 | The popover attribute and popovertarget |
| Progress | [`progress`](https://svelte-lean.vercel.app/primitives/progress.md) | Tier 0 | The native \<progress\> |
| Radio Group | [`radio-group`](https://svelte-lean.vercel.app/primitives/radio-group.md) | Tier 0 | Native radios in a \<fieldset\> |
| Resizable | [`splitter`](https://svelte-lean.vercel.app/primitives/splitter.md) | Tier 2 | Panes with a drag handle |
| Scroll Area | [`scroll-area`](https://svelte-lean.vercel.app/primitives/scroll-area.md) | Tier 0 | A styled overflow container |
| Select | [`select`](https://svelte-lean.vercel.app/primitives/select.md) | Tier 2 | A bare \<select data-slean="select"\>, or the owned field with selectParts() |
| Separator | [`separator`](https://svelte-lean.vercel.app/primitives/separator.md) | Tier 0 | The native \<hr\> |
| Sidebar | [`nav`](https://svelte-lean.vercel.app/primitives/nav.md) | Tier 0 | A \<nav\> of links and \<details\> groups; data-slean-collapsed for the icon rail |
| Skeleton | [`skeleton`](https://svelte-lean.vercel.app/primitives/skeleton.md) | Tier 0 | A placeholder block |
| Slider | [`slider`](https://svelte-lean.vercel.app/primitives/slider.md) | Tier 1 | The native range input |
| Sonner, Toast | [`toast`](https://svelte-lean.vercel.app/primitives/toast.md) | Tier 2 | One region per page and showToast() |
| Tabs | [`tabs`](https://svelte-lean.vercel.app/primitives/tabs.md) | Tier 1 | Roles and ids from tabsParts(id) |
| Toggle | [`toggle`](https://svelte-lean.vercel.app/primitives/toggle.md) | Tier 1 | A \<button aria-pressed\> |
| Toggle Group | [`toggle-group`](https://svelte-lean.vercel.app/primitives/toggle-group.md) | Tier 1 | Buttons in a group with one shared value |
| Tooltip | [`hint`](https://svelte-lean.vercel.app/primitives/hint.md) | 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

`Before: shadcn-svelte`

```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`

```svelte
<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](https://svelte-lean.vercel.app/primitives/dialog.md).

### Dropdown menu

`Before: shadcn-svelte`

```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`

```svelte
<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](https://svelte-lean.vercel.app/primitives/menu.md).

### Tabs

`Before: shadcn-svelte`

```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`

```svelte
<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](https://svelte-lean.vercel.app/primitives/tabs.md).

### Select

`Before: shadcn-svelte`

```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`

```svelte
<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](https://svelte-lean.vercel.app/primitives/select.md).

### Tooltip

`Before: shadcn-svelte`

```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`

```svelte
<button type="button" data-slean="button" data-slean-hint="Saves the draft (Ctrl+S)">
	Save
</button>
```

Every option of this primitive: [Tooltip](https://svelte-lean.vercel.app/primitives/hint.md).

### Accordion

`Before: shadcn-svelte`

```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`

```svelte
<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](https://svelte-lean.vercel.app/primitives/accordion.md).

## 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](https://svelte-lean.vercel.app/query/migration/tanstack-query.md) and [From RTK Query](https://svelte-lean.vercel.app/query/migration/rtk-query.md) map each API to `@svelte-lean/query`.

## With an assistant

This page is also published as Markdown: give an assistant [its Markdown version](https://svelte-lean.vercel.app/docs/migration/shadcn-svelte.md) and the page of each primitive the screen uses, and ask it to convert one screen at a time. [Markdown for AI](https://svelte-lean.vercel.app/docs/ai.md) lists every file.
