shadcn-vue-inertia について
Inertia.js と Vue 3 を使う Rails アプリ向けに、shadcn-vue の適用方法をまとめたコーディングスキルです。フレームワークごとの差異やコンポーネントの使い方、注意点を扱い、Nuxt は対象外です。
主な機能
- 01Inertia.js のフォームとナビゲーション
- 02shadcn-vue コンポーネントの指針
- 03ダークモードとトースト設定の注意点
- 04Vue のリアクティビティとダイアログの注意点
活用シーン
- 01Nuxt 向け例を Rails 用に置き換える
- 02Inertia のエラー処理を使ったフォーム構築
- 03古いプロパティやダイアログの挙動の調査
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ inertia-rails/ skills --skill shadcn-vue-inertiaインストール後、Claude Code で/shadcn-vue-inertiaを入力して読み込まれたか確認します。
同梱ファイルreferences/components.mdreferences/flash-toast.md
詳細
Inertia Rails 向け shadcn-vue
Inertia.js + Rails + Vue 3 向けに調整した shadcn-vue のパターンです。Nuxt ではありません。
shadcn-vue の例を使う前に、次の点を確認してください。
- Nuxt 固有の API を使っていますか?(
useRouter、useFetch、<NuxtLink>)→ Inertia のrouter、サーバープロパティ、<Link>に置き換えます。 vee-validate+zodを使っていますか? → Inertia の<Form>+name属性に置き換えます。Inertia が CSRF、エラー、リダイレクト、処理状態を処理します。
Nuxt のデフォルトとの主な違い
| shadcn-vue のデフォルト(Nuxt) | Inertia での相当機能 |
|---|---|
useFetch / useAsyncData | コントローラー経由でサーバーレンダリングされるプロパティ |
useRouter()(Nuxt) | @inertiajs/vue3 の router |
<NuxtLink> | @inertiajs/vue3 の <Link> |
vee-validate + zod | Inertia の <Form> コンポーネント |
FormField、FormItem、FormMessage | プレーンな <Input name="..."> + errors.field |
useHead()(Nuxt) | @inertiajs/vue3 の <Head> |
shadcn-vue の FormField、FormItem、FormLabel、FormMessage コンポーネントは絶対に使わないでください —
これらは内部で vee-validate のフォームコンテキストに依存しており、コンテキストがないとクラッシュします。
Inertia の <Form> 内では、name 属性を持つプレーンな shadcn-vue の Input/Label/Select を使い、
スコープ付きスロットの errors オブジェクトからエラーを表示してください。
セットアップ
npx shadcn-vue@latest init を実行します。存在しない場合は、tsconfig.json に @/ の解決エイリアスを追加します。
vite.config.ts に @/ の解決エイリアスを追加しないでください — vite-plugin-ruby がすでに提供しています。
Inertia の <Form> で使う shadcn-vue の入力コンポーネント
Inertia の <Form> 内では、name 属性を持つプレーンな shadcn-vue の Input/Label/Button を使います。
<Form> API の詳細については、inertia-rails-forms スキル(および references/vue.md)を参照してください。
基本パターン: shadcn-vue の FormField/FormItem/FormMessage を、プレーンな
コンポーネント + 手動でのエラー表示に置き換えます。
<script setup lang="ts">
import { Form } from '@inertiajs/vue3'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
</script>
<template>
<Form method="post" action="/users">
<template #default="{ errors, processing }">
<div class="space-y-4">
<div>
<Label for="name">Name</Label>
<Input id="name" name="name" />
<p v-if="errors.name" class="text-sm text-destructive">{{ errors.name }}</p>
</div>
<div>
<Label for="email">Email</Label>
<Input id="email" name="email" type="email" />
<p v-if="errors.email" class="text-sm text-destructive">{{ errors.email }}</p>
</div>
<Button type="submit" :disabled="processing">
{{ processing ? 'Creating...' : 'Create User' }}
</Button>
</div>
</template>
</Form>
</template>
Inertia の <Form> との連携には、<Select> に name プロパティが必要です。
<template>
<Select name="role" default-value="member">
<SelectTrigger><SelectValue placeholder="Select role" /></SelectTrigger>
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="member">Member</SelectItem>
</SelectContent>
</Select>
</template>
Inertia ナビゲーションを使うダイアログ
<script setup lang="ts">
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { router } from '@inertiajs/vue3'
defineProps<{ open: boolean; user: User }>()
</script>
<template>
<Dialog
:open="open"
@update:open="(isOpen) => { if (!isOpen) router.replaceProp('show_dialog', false) }"
>
<DialogContent>
<DialogHeader>
<DialogTitle>{{ user.name }}</DialogTitle>
</DialogHeader>
</DialogContent>
</Dialog>
</template>
サーバーサイドソートを使うテーブル
<script setup lang="ts">
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { router } from '@inertiajs/vue3'
defineProps<{ users: User[]; sort: string }>()
const handleSort = (column: string) => {
router.get('/users', { sort: column }, { preserveState: true })
}
</script>
<template>
<Table>
<TableHeader>
<TableRow>
<TableHead class="cursor-pointer" @click="handleSort('name')">
Name {{ sort === 'name' ? '↑' : '' }}
</TableHead>
<TableHead>Email</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="user in users" :key="user.id">
<TableCell>{{ user.name }}</TableCell>
<TableCell>{{ user.email }}</TableCell>
</TableRow>
</TableBody>
</Table>
</template>
SPA ナビゲーションを維持するため、行リンクには <a> ではなく <Link> を使います。
フラッシュメッセージを使うトースト
フラッシュの設定(flash_keys)は inertia-rails-controllers にあります。フラッシュへのアクセス
(usePage().flash)は inertia-rails-pages にあります。このセクションでは、トースト UI の接続方法のみを説明します。
必須 — ファイル全体を読むこと。Sonner でフラッシュベースのトーストを実装する場合は、次を参照してください。
references/flash-toast.md(約80行)— useFlash
コンポーザブルと Sonner トーストプロバイダーの全体です。トースト UI なしでフラッシュ値のみを読む場合は、読み込まないでください。
ダークモード(Nuxt color-mode なし)
npx shadcn-vue@latest init は、ライト/ダーク用の CSS 変数と、
CSS(Tailwind v4)内の @custom-variant dark (&:is(.dark *)); を生成します。変数自体に追加の
セットアップは必要ありません。
重要 — 間違ったテーマのちらつき(FOUC)を防ぐ: Vue のハイドレーション前に、
<head> にインラインスクリプトを追加します。
<%# 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>
Nuxt color-mode の代わりに、useAppearance コンポーザブル(ライト/ダーク/システムモード、localStorage への永続化、
matchMedia リスナー)を使います。<html> の .dark クラスで切り替えます — プロバイダーは不要です。
Vue 固有の注意点
Inertia の <Form> では v-model は動作しません — <Form> は送信時に
Vue のリアクティビティシステムではなく、入力の name 属性から値を読み取ります。v-model を使うと、
<Form> が無視する第2の信頼できる情報源が作られます。
<Form method="post" action="/users">
<Input v-model="name" />
</Form>
<Form method="post" action="/users">
<Input name="name" />
</Form>
v-model は useForm と組み合わせる場合にのみ使います(その場合は form.name を明示的に管理します)。
usePage() はリアクティブなオブジェクトを返します — 派生値には computed() を使います。
<script setup lang="ts">
import { usePage } from '@inertiajs/vue3'
import { computed } from 'vue'
const page = usePage()
// BAD — not reactive, won't update when page changes:
// const user = page.props.auth.user
// GOOD — reactive, updates on navigation:
const user = computed(() => page.props.auth.user)
</script>
computed() を使わないと、分割代入した値は初期状態で固定され、Inertia のナビゲーション後に更新されません。
ダイアログでは @update:open と @close のどちらを使うか — shadcn-vue の Dialog が発行するのは
close ではなく update:open です。@close を使っても何も起こりません。
<Dialog @close="handleClose">
<Dialog :open="open" @update:open="(isOpen) => { if (!isOpen) handleClose() }">
トラブルシューティング
| 症状 | 原因 | 修正方法 |
|---|---|---|
FormField/FormMessage がクラッシュする | vee-validate に依存する shadcn-vue のフォームコンポーネントを使用している | 通常の Input/Label と errors.field の表示に置き換えます |
Select の値が送信されない | name プロパティが指定されていない | <Select> に name="field" を追加します |
| ダイアログが予期せず閉じる | @update:open ハンドラーが指定されていない、または誤っている | @update:open="(open) => { if (!open) closeHandler() }" を使用します |
| 誤ったテーマが一瞬表示される(FOUC) | <head> にインラインの <script> がない | スタイルシートより前にダークモード用のスクリプトを追加します |
v-model の値が送信されない | <Form> は Vue のリアクティブな状態ではなく、name 属性を読み取る | name 属性を使用し、v-model は useForm の場合にのみ使用します |
| ナビゲーション後、共有プロパティが古いままになる | computed() を使わずに usePage() を分割代入している | 派生値を computed(() => ...) でラップします |
関連スキル
- フォームコンポーネント →
inertia-rails-forms+references/vue.md(<Form>のスコープ付きスロット、useForm) - Flash の設定 →
inertia-rails-controllers(flash_keys イニシャライザー) - Flash へのアクセス →
inertia-rails-pages+references/vue.md(usePage().flash) - URL によるダイアログ →
inertia-rails-pages+references/vue.md(router.get パターン)
参考資料
上記に示されていない shadcn-vue コンポーネント(Accordion、Sheet、Tabs、DropdownMenu、
Inertia パターンを使用する AlertDialog)を構築する場合は、references/components.md(約200行)を読み込みます。
基本的な Form、Select、Dialog、Table の使い方では、components.md を読み込まないでください —
上記の例で十分です。