Migration Guide

How to migrate an existing Tailwind, shadcn-svelte, or Bits UI application to the design system incrementally.

Overview #

Treat migration as a product-shell and workflow migration, not a global class replacement. Start by putting the app inside Theme and AppShell, then move one route or surface at a time to design system primitives while keeping existing data, routing, and business logic intact.

Tailwind can coexist during migration. Use it for legacy wrappers and local layout while replacing interactive controls, navigation, command surfaces, forms, alerts, dialogs, and settings UI with components.

CLI Workflow #

Use the CLI as the migration checklist. Read the docs for the pattern you are about to touch, inspect a matching template skeleton, then read the exact component docs before editing.

Migration-oriented CLI pass
bash
astryx-svelte docs migration
astryx-svelte docs theme
astryx-svelte docs styling
astryx-svelte template --list --type block
astryx-svelte template AppShellTopNavWithSideNav --skeleton
astryx-svelte template PopoverSettingsPanel --skeleton
astryx-svelte component AppShell
astryx-svelte component SideNav
astryx-svelte component TopNav
astryx-svelte component CommandPalette
astryx-svelte component Button
astryx-svelte component TextInput

Use --dense when pasting output into an AI coding tool, and use --json when building automated migration reports.

Dense and JSON modes
bash
astryx-svelte docs migration --dense
astryx-svelte component Button --json

Theme and CSS Setup #

Mount Theme at the app root so every migrated component reads the same token set. Keep the mode in application state if users can switch between light and dark themes.

Root layout with explicit mode
svelte
<!-- src/routes/+layout.svelte -->
<script lang="ts">
import { Theme } from '@astryx-svelte/core';
import { neutralTheme } from '@astryx-svelte/theme-neutral';
import { setSettings } from '$lib/settings.svelte.js';
import '../app.css';
let { children } = $props();
let mode = $state<'system' | 'light' | 'dark'>('system');
setSettings({
get mode() {
return mode;
},
setMode: (next) => (mode = next)
});
</script>
<Theme theme={neutralTheme} {mode}>
{@render children()}
</Theme>

When Tailwind remains in the app, declare layer order once in the global CSS file. The design system base and theme CSS should load before Tailwind utilities so migrated components keep design system defaults while legacy utility classes still work.

Tailwind v4 coexistence
css
@layer reset, theme, base, astryx-base, astryx-theme, product, utilities;
@import 'tailwindcss/theme.css' layer(theme);
@import 'tailwindcss/preflight.css' layer(base);
@import '@astryx-svelte/core/base.css';
@import '@astryx-svelte/theme-neutral/theme.css';
@import 'tailwindcss/utilities.css' layer(utilities);

On Tailwind v3 there is no preflight.css to import, so wrap the @tailwind base directive in a named layer instead. Keep utilities unlayered so existing app utility classes still win everywhere.

Tailwind v3 coexistence
css
@layer reset, tw-preflight, astryx-base, astryx-theme;
@import '@astryx-svelte/core/base.css';
@import '@astryx-svelte/theme-neutral/theme.css';
@layer tw-preflight {
@tailwind base; /* layered: astryx-theme now wins over preflight */
}
@tailwind components;
@tailwind utilities; /* unlayered: legacy utility classes keep winning */

Cascade Layer Safety #

In a stylesheet with no layers at all, a zero-specificity reset like * { padding: 0 } loses to any class selector, so most developers treat resets as harmless. Layers change the rules twice: unlayered styles beat every named layer, and a later layer beats an earlier one, both regardless of specificity. The same reset therefore wins against every component style either by staying unlayered or by landing in a layer declared after astryx-base. Same CSS, opposite outcome, and no error or warning when it happens.

This is the most common way an adoption breaks, through one of two @import mechanisms. A top-level @import without the layer() keyword keeps the legacy reset unlayered, where it overrides every design system layer. And an @import nested inside a file that was itself imported into a layer inherits that surrounding layer, so a reset can silently land in a consumer layer above astryx-base. Either way the fix is the same: import the legacy reset into the lowest layer explicitly.

Legacy reset, explicitly layered
css
/* was: @import "./legacy-reset.css"; (unlayered: beats every layer) */
@import './legacy-reset.css' layer(reset);

Audit the layers around the design system with this checklist before building screens.

  • Declare the canonical @layer order once, before any @import. Vite inlines a CSS entry's imports in place, so putting the order declaration at the top of the file you import first (app.css) is enough; with a webpack-based bundler it must live in its own file imported first, because webpack hoists @import content above the inline CSS that follows it.
  • Audit every pre-existing global or reset stylesheet and assign each one to a layer deliberately. Top-level imports without layer() stay unlayered and beat every layer; imports nested inside a layered file inherit that layer.
  • Remove or demote the app legacy reset. @astryx-svelte/core/base.css contains its own :where() reset in the lowest layer, so any app reset belongs in that same reset layer and never in a layer above astryx-base.
  • Layer Tailwind preflight. On Tailwind v4, import preflight.css with layer(base). On Tailwind v3, wrap the @tailwind base directive in a named layer (see the snippet in Theme and CSS Setup). Unlayered preflight overrides theme CSS silently.
  • Set moduleResolution to bundler so subpath imports like @astryx-svelte/core/base.css resolve.
  • Theme with defineTheme and the accent family API instead of hand-writing individual color tokens. Derived tokens like --color-on-accent are generated from the accent scale automatically; hand-writing only --color-accent leaves --color-on-accent at its stale white default with no contrast guarantee against the new accent.
  • Run the foundation smoke test below and view a few components in both light and dark mode before migrating any route.

One more mental model shift: a class or utility class you write on a component still reaches the DOM either way, but whether it overrides the component is a layer question, not a source order question. Keep app utilities in the utilities layer so they keep winning. And a rule in a Svelte <style> block is unlayered by default, so it beats every astryx layer — deliberate, but worth knowing before you debug it.

Foundation Smoke Test #

A broken layer order fails silently and identically on every page, so catch it before feature work instead of after N migrated screens. Render one throwaway page with a few primitives as the first migration step.

Foundation check page
svelte
<!-- src/routes/foundation-check/+page.svelte -->
<script lang="ts">
import { Button, Card, Table, TextInput, VStack } from '@astryx-svelte/core';
let email = $state('');
</script>
<div data-foundation-check>
<VStack gap={4}>
<Button label="Primary action" variant="primary" />
<TextInput label="Email" placeholder="you@example.com" bind:value={email} />
<Card>One card with default padding</Card>
<Table
data={[{ name: 'Foundation', status: 'ok' }]}
columns={[
{ key: 'name', header: 'Name' },
{ key: 'status', header: 'Status' }
]}
/>
</VStack>
</div>

If the button renders with visible padding, a filled primary background, and the input and card have borders and internal spacing, the foundation is sound. For an assertion that can run in any test runner or a dev-only effect, check that a primitive keeps non-zero padding:

Foundation assertion
ts
const button = document.querySelector<HTMLButtonElement>(
'[data-foundation-check] button'
);
if (!button) {
throw new Error('Foundation check page did not render a button.');
}
if (getComputedStyle(button).paddingInline === '0px') {
throw new Error(
'Foundation broken: an unlayered reset or a later cascade layer is ' +
'overriding component styles. Check that no app reset sits outside ' +
'the reset layer.'
);
}

When this fails, the fix is almost always in the layer order: find the stylesheet that zeroes padding, and move it into the reset layer or delete it.

Move the App Frame First #

Start with AppShell so page migration happens inside the final navigation, spacing, surface, and responsive frame. This also exposes theme and color issues early because every route shares the same shell.

Legacy surfaceComponentNotes
Header TopNav Use for product identity, global actions, account entry, and command/search trigger.
Sidebar SideNav Use sections and nested nav items for route groups. Keep selection state driven by the router.
Main page wrapper AppShell + Layout Let the shell own persistent structure; let route components own page content. In SvelteKit the shell belongs in +layout.svelte.
Mobile drawer nav MobileNav or AppShell mobile behavior Verify focus, close behavior, and route changes on narrow viewports.
Settings menu Popover + Layout + Switch Use as the home for theme mode and app preferences.

Map shadcn-svelte and Bits UI Primitives #

Do not wrap old shadcn-svelte, Bits UI, Melt UI, or Skeleton components in design system styles. Replace the primitive with the component that owns the behavior, accessibility, state classes, and token usage.

Existing primitiveComponentMigration note
button / shadcn Button Button or IconButton Use Button for labeled commands and IconButton for icon-only toolbar actions.
input TextInput Keep validation state in status props rather than ad hoc border classes.
textarea TextArea Use when multiline editing is the primary action.
switch Switch Use for persisted boolean settings, including theme mode when represented as a binary choice.
checkbox CheckboxInput or CheckboxList Use list variants for grouped selection.
radio group RadioList Use when one option must be selected from a visible set.
select / combobox Selector or Typeahead Use Selector for bounded options and Typeahead for searchable async options.
tabs used as page nav TabList Use route state or current page state as the source of truth.
command dialog CommandPalette Keep app-specific search sources outside the shell and feed searchable items.
dropdown action menu DropdownMenu or MoreMenu Use MoreMenu for compact overflow actions.
alert / callout Banner or Toast Use Banner for page or section messages and Toast for transient feedback.
dialog Dialog or AlertDialog Use AlertDialog for destructive confirmation and Dialog for task flows.
card-like list row ListItem Prefer ListItem for selectable rows instead of styling Button as a row.

Command Palette, Settings, and Theme #

Move global search to CommandPalette once the shell exists. Treat the palette as a view over app commands: routes, contextual actions, create actions, filters, recent items, and entity results. Keep data normalization in app code so search sources always return arrays of searchable items.

Put light and dark mode controls in the settings popover or account menu. The switch or selector should update the mode passed to Theme, not toggle isolated body classes.

Settings popover theme control
svelte
<script lang="ts">
import { Switch } from '@astryx-svelte/core';
import { getSettings } from '$lib/settings.svelte.js';
const settings = getSettings();
const isDark = $derived(settings.mode === 'dark');
</script>
<Switch
label="Dark mode"
description="Use the dark color theme"
value={isDark}
onChange={(next) => settings.setMode(next ? 'dark' : 'light')}
/>

Verification Checklist #

  • Run the app in light and dark mode and check that surfaces, borders, text, icons, hover states, focus rings, and status colors flow together.
  • Open the command palette from the shell, type into it, select items by keyboard, and confirm focus returns to the trigger.
  • Check the SideNav at collapsed, expanded, active, hover, nested, and mobile states.
  • Verify settings popovers and dialogs in a real browser, not only in a DOM shim: the native dialog and Popover APIs are what these components are built on.
  • Search for leftover hardcoded Tailwind colors, arbitrary hex values, and one-off hover colors after each route migration.
  • Run component tests, build, and at least one browser screenshot pass for each migrated route.

AI Migration Prompt #

When using an AI coding agent, give it an explicit migration loop instead of asking for a full-app rewrite.

Paste this into your AI
text
We are migrating this existing Tailwind/shadcn-svelte app to Astryx incrementally.
First run:
- astryx-svelte docs migration --dense
- astryx-svelte docs theme --dense
- astryx-svelte docs styling --dense
- astryx-svelte template AppShellTopNavWithSideNav --skeleton
Then migrate one route or shell surface at a time. Keep business logic and routing intact. Replace shadcn-svelte/Bits UI/Tailwind primitives with Astryx components, remove hardcoded colors, verify light and dark mode, and take screenshots before moving to the next surface.