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:
<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:
<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
<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
<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):
<%# 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: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:
<!-- 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:
<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:
<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.