shadcn-svelte for Inertia Rails
shadcn-svelte (bits-ui) patterns adapted for Inertia.js + Rails + Svelte. NOT SvelteKit.
Before using a shadcn-svelte example, ask:
- Does it use SvelteKit-specific APIs? (
goto,$app/navigation,loadfunctions,+page.svelte) → Replace with Inertiarouter, server props, page components - Does it use
sveltekit-superforms+zod? → Replace with Inertia<Form>+nameattributes. Inertia handles CSRF, errors, redirects, processing state.
Key Differences from SvelteKit Defaults
| shadcn-svelte default (SvelteKit) | Inertia equivalent |
|---|---|
goto() from $app/navigation | router from @inertiajs/svelte |
load functions | Server-rendered props via Rails controller |
+page.svelte / +layout.svelte | Default exports with module script layout |
sveltekit-superforms + zod | Inertia <Form> component |
<svelte:head> (SvelteKit auto-manages) | <svelte:head> (same — no Inertia <Head> in Svelte) |
Setup
npx shadcn-svelte@latest init. Add @/ resolve aliases to tsconfig.json if not present.
Do NOT add @/ resolve aliases to vite.config.ts — vite-plugin-ruby already provides them.
shadcn-svelte Inputs in Inertia <Form>
Use plain shadcn-svelte Input/Label/Button with name attributes inside Inertia <Form>.
See inertia-rails-forms skill (+ references/svelte.md) for full <Form> API.
The key pattern: Use {#snippet} to access form state:
svelte<script lang="ts"> import { Form } from '@inertiajs/svelte' import { Input } from '$lib/components/ui/input' import { Label } from '$lib/components/ui/label' import { Button } from '$lib/components/ui/button' </script> <Form method="post" action="/users"> {#snippet children({ errors, processing })} <div class="space-y-4"> <div> <Label for="name">Name</Label> <Input id="name" name="name" /> {#if errors.name}<p class="text-sm text-destructive">{errors.name}</p>{/if} </div> <div> <Label for="email">Email</Label> <Input id="email" name="email" type="email" /> {#if errors.email}<p class="text-sm text-destructive">{errors.email}</p>{/if} </div> <Button type="submit" disabled={processing}> {processing ? 'Creating...' : 'Create User'} </Button> </div> {/snippet} </Form>
Svelte 4: <Form let:errors let:processing> instead of {#snippet}.
<Select> requires name prop for Inertia <Form> integration:
svelte<Select name="role" value="member"> <SelectTrigger><SelectValue placeholder="Select role" /></SelectTrigger> <SelectContent> <SelectItem value="admin">Admin</SelectItem> <SelectItem value="member">Member</SelectItem> </SelectContent> </Select>
Dialog with Inertia Navigation
svelte<script lang="ts"> import { Dialog, DialogContent, DialogHeader, DialogTitle } from '$lib/components/ui/dialog' import { router } from '@inertiajs/svelte' let { open, user }: { open: boolean; user: User } = $props() </script> <Dialog {open} onOpenChange={(isOpen) => { if (!isOpen) router.replaceProp('show_dialog', false) }} > <DialogContent> <DialogHeader> <DialogTitle>{user.name}</DialogTitle> </DialogHeader> <!-- content --> </DialogContent> </Dialog>
Svelte 4: on:openChange instead of onOpenChange.
Table with Server-Side Sorting
svelte<script lang="ts"> import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '$lib/components/ui/table' import { router } from '@inertiajs/svelte' let { users, sort }: { users: User[]; sort: string } = $props() const handleSort = (column: string) => { router.get('/users', { sort: column }, { preserveState: true }) } </script> <Table> <TableHeader> <TableRow> <TableHead class="cursor-pointer" onclick={() => handleSort('name')}> Name {sort === 'name' ? '↑' : ''} </TableHead> <TableHead>Email</TableHead> </TableRow> </TableHeader> <TableBody> {#each users as user (user.id)} <TableRow> <TableCell>{user.name}</TableCell> <TableCell>{user.email}</TableCell> </TableRow> {/each} </TableBody> </Table>
Use <Link> or use:inertia (not <a>) for row links to preserve SPA navigation.
Toast with Flash Messages
Flash config (flash_keys) is in inertia-rails-controllers. Flash access
($page.flash) is in inertia-rails-pages. This section covers toast UI wiring only.
MANDATORY — READ ENTIRE FILE when implementing flash-based toasts with Sonner:
references/flash-toast.md (~80 lines) — full flash
watcher and svelte-sonner integration. Do NOT load if only reading flash values without toast UI.
Dark Mode
npx shadcn-svelte@latest init generates CSS variables for light/dark and
@custom-variant dark (&:is(.dark *)); in your CSS (Tailwind v4).
CRITICAL — prevent flash of wrong theme (FOUC): Add an inline script in
<head> (before Svelte hydrates):
erb<%# app/views/layouts/application.html.erb — in <head>, before any stylesheets %> <script> document.documentElement.classList.toggle( "dark", localStorage.appearance === "dark" || (!("appearance" in localStorage) && window.matchMedia("(prefers-color-scheme: dark)").matches), ); </script>
Use a useAppearance pattern (light/dark/system modes, localStorage persistence,
matchMedia listener). Toggle via .dark class on <html>.
<svelte:head> Instead of <Head>
Svelte uses native <svelte:head> — there is no Inertia <Head> component for Svelte.
This applies in shadcn patterns too (e.g., setting page title in dialog views):
svelte<svelte:head> <title>{user.name} - Profile</title> </svelte:head>
Svelte-Specific Gotchas
bind:value does NOT work with Inertia <Form> — <Form> reads values
from input name attributes on submit, not from Svelte's reactive bindings.
Using bind:value creates a second source of truth that <Form> ignores:
svelte<!-- BAD — bind:value is ignored by <Form> on submit --> <Form method="post" action="/users"> <Input bind:value={name} /> </Form> <!-- GOOD — name attribute is what <Form> reads --> <Form method="post" action="/users"> <Input name="name" /> </Form>
Use bind:value only with useForm (where you explicitly manage $form.name).
$page store updates are reactive, but destructured values are not:
svelte<script lang="ts"> import { page } from '@inertiajs/svelte' // BAD — snapshot, won't update after navigation: // let user = $page.props.auth.user // GOOD — use $derived for reactive access: let user = $derived($page.props.auth.user) </script>
Svelte 4: use $: user = $page.props.auth.user (reactive statement).
use:inertia directive as alternative to <Link> — for elements that
can't be <Link> (e.g., table rows, custom components), use the action:
svelte<script lang="ts"> import { inertia } from '@inertiajs/svelte' </script> <tr use:inertia={{ href: `/users/${user.id}` }} class="cursor-pointer"> <td>{user.name}</td> </tr>
bits-ui transition props and Inertia navigation — bits-ui components with
transition* props may show stale content during Inertia page transitions if
the exit animation outlasts the navigation. Set short durations or use
forceMount on content that depends on page props.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Form components crash | Using shadcn-svelte form components that depend on superforms | Replace with plain Input/Label + errors.field display |
Select value not submitted | Missing name prop | Add name="field" to <Select> |
| Dialog closes unexpectedly | Missing or wrong onOpenChange handler | Use onOpenChange={(open) => { if (!open) closeHandler() }} |
| Flash of wrong theme (FOUC) | Missing inline <script> in <head> | Add dark mode script before stylesheets |
bind:value not submitted | <Form> reads name attrs, not Svelte bindings | Use name attribute; reserve bind:value for useForm only |
| Shared props stale after navigation | Destructured $page without $derived | Use $derived($page.props.auth.user) for reactive access |
Related Skills
- Form component →
inertia-rails-forms+references/svelte.md(<Form>snippet, useForm) - Flash config →
inertia-rails-controllers(flash_keys initializer) - Flash access →
inertia-rails-pages+references/svelte.md($page.flash) - URL-driven dialogs →
inertia-rails-pages+references/svelte.md(router.get pattern) use:inertiadirective →inertia-rails-pages+references/svelte.md
References
Load references/components.md (~200 lines) when building
shadcn-svelte components beyond those shown above (Accordion, Sheet, Tabs, DropdownMenu,
AlertDialog with Inertia patterns).
Do NOT load components.md for basic Form, Select, Dialog, or Table usage —
the examples above are sufficient.

