Vueのslotの使い方|名前付き・スコープ付きをコピペ部品で解説
Vueのslot(スロット)とは、親コンポーネントから子コンポーネントの「決められた場所」に、HTMLやコンポーネントを差し込む仕組みです。 props が「値」を渡すのに対し、スロットは「見た目の中身」を渡します。子は枠(レイアウト)だけを用意し、中身は使う側が決められるので、モーダルやカード、テーブルのような部品を1つ作れば、さまざまな画面で使い回せます。
この記事では、Vue 3.5 の <script setup> + TypeScript で、デフォルト・フォールバック・名前付き・スコープ付き・動的スロット名・条件付きスロットの6種類を対応表にまとめ、モーダル・カード・データテーブル・リストのコピペ部品として実装しました。Playwright で実際に描画された HTMLを確認し、defineSlots で型を付けたときに vue-tsc が出すエラーと、検出されないケースも載せています。Vue 2 の slot / slot-scope 属性からの書き換え表もあります。
Vueスロットの早見表
| 種類 | 子(受け取る側)の書き方 | 親(渡す側)の書き方 | 使いどころ |
|---|---|---|---|
| デフォルト | <slot /> |
<Child>中身</Child> |
本文を1か所に差し込む |
| フォールバック | <slot>既定の中身</slot> |
何も渡さない | 渡されなかったときの表示 |
| 名前付き | <slot name="header" /> |
<template #header>…</template> |
見出し・本文・フッターなど複数の場所 |
| スコープ付き | <slot :item="item" /> |
<template #default="{ item }"> |
子のデータを使って親が見た目を決める |
| 名前付き+スコープ付き | <slot name="row" :row="r" /> |
<template #row="{ row }"> |
テーブルの行・リストの各行 |
| 動的スロット名 | <slot :name="'cell-' + key" /> |
<template #[name]> / #cell-stock |
列ごとにカスタマイズ |
| 条件付き | <footer v-if="$slots.footer"> |
渡すかどうかで切り替わる | 渡されたときだけ枠を出す |
| 型付け | defineSlots<{ header?(p: {...}): any }>() |
— | 受け取る値の型チェック |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Vueのslotの使い方|名前付き・スコープ付きをコピペ部品で解説」
検証した環境
| 項目 | バージョン |
|---|---|
| Vue | 3.5.43 |
| Vite / @vitejs/plugin-vue | 8.3.1 / 6.0.9 |
| vue-tsc / TypeScript | 3.3.11 / 6.0.3 |
| Playwright(Chromium) | 1.56.1 |
| Node.js | 22.22.2 |
スロットの全体像
flowchart LR
subgraph 親["親コンポーネント(中身を決める)"]
A["本文"]
B["#header"]
C["#row の中身"]
end
subgraph 子["子コンポーネント(枠を決める)"]
D["slot(デフォルト)"]
E["slot name=header"]
F["slot name=row(row を渡す)"]
end
A --> D
B --> E
C --> F
F -. "row(子のデータ)" .-> C
矢印の実線は「親 → 子へ中身を渡す」、点線は「子 → 親へデータを渡す」です。 点線があるのがスコープ付きスロットで、ここが初めてだとつまずきやすいところです。「子がループや状態を持ち、親がその1件ごとの見た目を決める」と考えると整理しやすくなります。
デフォルトスロットとフォールバック:カード部品
<!-- BaseCard.vue -->
<script setup lang="ts">
defineSlots<{ default?(): any }>()
</script>
<template>
<article class="card">
<slot>内容はまだありません</slot><!-- ← フォールバック -->
</article>
</template>
<!-- 使う側 -->
<div id="card-empty"><BaseCard /></div>
<div id="card-filled"><BaseCard><p>在庫が補充されました</p></BaseCard></div>
Playwright で表示されたテキストを読んだ結果:
card-empty : 内容はまだありません
card-filled: 在庫が補充されました
<slot> と </slot> の間に書いた内容は、何も渡されなかったときだけ表示されます(フォールバック)。 渡された場合は置き換わります。
スロットの中身は親のスコープで評価されます。 親の <BaseCard>{{ message }}</BaseCard> の message は親の変数で、子の変数は見えません。子の値を親で使いたいときがスコープ付きスロットの出番です。
名前付きスロットと条件付きスロット:モーダル部品
見出し・本文・フッターの3か所に差し込めるモーダルです。フッターは渡されたときだけ <footer> の枠ごと出すようにしています。
<!-- BaseModal.vue -->
<script setup lang="ts">
const { open } = defineProps<{ open: boolean }>()
const emit = defineEmits<{ close: [] }>()
const slots = defineSlots<{
header?(props: { close: () => void }): any
default(): any
footer?(props: { close: () => void }): any
}>()
const close = () => emit('close')
</script>
<template>
<div v-if="open" class="backdrop" role="dialog">
<div class="modal">
<header>
<slot name="header" :close="close"><h2>お知らせ</h2></slot>
</header>
<slot />
<!-- footer が渡されたときだけ枠ごと出す(条件付きスロット) -->
<footer v-if="slots.footer" class="modal-footer">
<slot name="footer" :close="close" />
</footer>
</div>
</div>
</template>
<!-- 使う側 -->
<script setup lang="ts">
import { ref } from 'vue'
import BaseModal from './BaseModal.vue'
const modalA = ref(true)
const modalB = ref(true)
</script>
<template>
<div id="modal-a">
<BaseModal :open="modalA" @close="modalA = false">
<p>本文だけ渡したモーダル</p>
</BaseModal>
</div>
<div id="modal-b">
<BaseModal :open="modalB" @close="modalB = false">
<template #header="{ close }">
<h2>削除の確認</h2><button class="x" @click="close">×</button>
</template>
<p>このタスクを削除しますか?</p>
<template #footer="{ close }">
<button class="cancel" @click="close">キャンセル</button>
</template>
</BaseModal>
</div>
</template>
実行結果:
modal-a footer数: 0 | header: お知らせ
modal-b footer数: 1 | header: 削除の確認
×
cancel後 modal-b: 0
- modal-a はフッターを渡していないので
<footer>要素ごと描画されず(0個)、見出しはフォールバックの「お知らせ」 - modal-b は
#headerと#footerが差し込まれ、フッター内の「キャンセル」で子のcloseが呼ばれて閉じた
<template #header> 以外の要素(ここでは <p>)は、自動的にデフォルトスロットに入ります。<template #default> と明示しても同じです。
$slots と useSlots と defineSlots
条件付きスロットでは「スロットが渡されたか」を調べます。 書き方は3通りあり、どれも同じオブジェクトを指します。
| 書き方 | 使える場所 | 備考 |
|---|---|---|
$slots.footer |
テンプレート | いちばん手軽 |
const slots = useSlots() |
<script setup> |
import { useSlots } from 'vue' |
const slots = defineSlots<{...}>() |
<script setup> |
型付け+戻り値は useSlots と同じ(3.3+) |
slots.footer は関数で、中身の有無ではなく「渡されたかどうか」で判定されます。 <template #footer></template> のように空で渡した場合と、中身が v-if="false" で何も描画されない場合を確かめたところ、どちらも <footer> が1個描画されました(e1 footer: 1 / e2 footer: 1)。中身まで判定したいときは、渡す側で <template v-if="..." #footer> のようにスロット自体を条件付きで渡します。
スコープ付きスロット:子のデータを親で使う
リストの各行を親でカスタマイズ(ItemList)
スコープ付きスロットは、<slot> に属性として値を付けると、親の v-slot で受け取れる仕組みです。 子は「繰り返し」を担当し、1件ごとの見た目は親が決めます。generic を使うと、渡した配列の型がスロットにそのまま伝わります。
<!-- ItemList.vue -->
<script setup lang="ts" generic="T">
const { items } = defineProps<{ items: T[] }>()
defineSlots<{
item(props: { item: T; index: number }): any
}>()
</script>
<template>
<ul>
<li v-for="(item, index) in items" :key="index">
<slot name="item" :item="item" :index="index" />
</li>
</ul>
</template>
<!-- 使う側 -->
<script setup lang="ts">
import ItemList from './ItemList.vue'
const tasks = ['見積もり作成', 'レビュー依頼']
</script>
<template>
<div id="list">
<ItemList :items="tasks">
<template #item="{ item, index }">{{ index + 1 }}. {{ item }}</template>
</ItemList>
</div>
</template>
list: 1. 見積もり作成 / 2. レビュー依頼
{ item, index } は、子の <slot :item="item" :index="index"> で渡した値を分割代入で受け取ったものです。 ここでは表示だけのリストなので key に index を使っていますが、並び替えや削除がある場合はデータの id を key にします。
データテーブルの行を親でカスタマイズ(動的スロット名)
列ごとに #cell-列名 のスロットを用意すると、特定の列だけ見た目を変え、それ以外は既定の表示にできます。 スロット名を :name でバインドするのが動的スロット名です。
<!-- DataTable.vue -->
<script setup lang="ts" generic="T extends { id: number }, K extends keyof T & string">
const { rows, columns } = defineProps<{
rows: T[]
columns: { key: K; label: string }[]
}>()
defineSlots<{
// 列ごとの動的スロット名:#cell-name, #cell-stock など
[P in `cell-${K}`]?: (props: { row: T; value: T[K] }) => any
} & {
empty?(): any
}>()
</script>
<template>
<table>
<thead>
<tr><th v-for="c in columns" :key="c.key">{{ c.label }}</th></tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row.id" :data-testid="`row-${row.id}`">
<td v-for="c in columns" :key="c.key">
<slot :name="`cell-${c.key}`" :row="row" :value="row[c.key]">{{ row[c.key] }}</slot>
</td>
</tr>
<tr v-if="rows.length === 0">
<td :colspan="columns.length"><slot name="empty">データがありません</slot></td>
</tr>
</tbody>
</table>
</template>
<!-- 使う側 -->
<script setup lang="ts">
import { ref } from 'vue'
import DataTable from './DataTable.vue'
const products = ref([
{ id: 1, name: 'ボールペン', stock: 12 },
{ id: 2, name: '付箋', stock: 0 },
])
const columns = [
{ key: 'name', label: '品名' },
{ key: 'stock', label: '在庫' },
] as const
</script>
<template>
<div id="table">
<DataTable :rows="products" :columns="[...columns]">
<template #cell-stock="{ value }">
<strong v-if="value === 0" class="soldout">在庫切れ</strong>
<span v-else>{{ value }} 個</span>
</template>
</DataTable>
</div>
<div id="table-empty"><DataTable :rows="[]" :columns="[...columns]" /></div>
</template>
実行結果:
row-1: ボールペン 12 個
row-2: <td>付箋</td><td><strong class="soldout">在庫切れ</strong></td>
table-empty: データがありません
「在庫」列だけが親のテンプレートで描かれ、「品名」列はフォールバックの {{ row[c.key] }} のまま表示されました。 行が0件のときは #empty のフォールバックが出ています。親側でスロット名を変数にしたいときは <template #[slotName]="{ value }"> と書きます。
defineSlotsで型を付ける:vue-tscのエラー実例
defineSlots で宣言すると、親が受け取るスロットの値に型が付き、vue-tsc で間違いを検出できます。 わざと間違えたファイルで確認しました。
<!-- SlotErrors.vue -->
<script setup lang="ts">
import BaseModal from '../slot/BaseModal.vue'
import ItemList from '../slot/ItemList.vue'
</script>
<template>
<!-- 1. 存在しないスロットプロパティを受け取る -->
<BaseModal :open="true">
<template #header="{ onClose }">{{ onClose }}</template>
本文
</BaseModal>
<!-- 2. 受け取った値を型と違う使い方をする(item は string) -->
<ItemList :items="['a', 'b']">
<template #item="{ item }">{{ item.toFixed(1) }}</template>
</ItemList>
<!-- 3. 宣言していないスロット名 -->
<BaseModal :open="true">
<template #title>見出し</template>
本文
</BaseModal>
<!-- 4. 必須の default スロットを渡さない -->
<BaseModal :open="true" />
</template>
npx vue-tsc --noEmit
実行結果:
src/tserr/SlotErrors.vue(9,26): error TS2339: Property 'onClose' does not exist on type '{ close: () => void; }'.
src/tserr/SlotErrors.vue(14,40): error TS2551: Property 'toFixed' does not exist on type 'string'. Did you mean 'fixed'?
1 と 2(受け取る値の名前違い・型違い)は検出されました。 generic のおかげで、ItemList に文字列の配列を渡すと item が string と推論されています。
一方、3(宣言していないスロット名 #title)と 4(必須の default の渡し忘れ)は、既定の設定でも strictTemplates: true にしても、このバージョンでは検出されませんでした。 スロット名のつづり間違いは型チェックをすり抜けて「何も表示されない」形で現れるので、次のように補うと安心です。
| 検出したい間違い | vue-tsc 3.3 | 補う方法 |
|---|---|---|
| スロットの値の名前違い | 検出 | — |
| スロットの値の型違い | 検出 | generic で型を伝える |
| 宣言していないスロット名 | 検出されない | Playwright やコンポーネントテストで描画結果を確認 |
| 必須スロットの渡し忘れ | 検出されない | フォールバックを置く、または $slots で確認して開発時に警告 |
Vue 2 の slot / slot-scope から v-slot への書き換え
Vue 2.6 で v-slot が導入され、Vue 3 では旧来の slot 属性と slot-scope 属性は削除されました。
| やりたいこと | Vue 2(2.5 以前の書き方) | Vue 2.6 / Vue 3 |
|---|---|---|
| 名前付き | <template slot="header"> |
<template #header> |
| スコープ付き(デフォルト) | <template slot-scope="{ item }"> |
<template #default="{ item }"> |
| 名前付き+スコープ付き | <template slot="row" slot-scope="{ row }"> |
<template #row="{ row }"> |
| コンポーネントに直接 | <Child slot-scope="p"> |
<Child v-slot="p">(デフォルトのみのとき) |
| スロットの参照(JS) | this.$slots.header(配列)/this.$scopedSlots.row(...) |
slots.header?.()(関数に統一) |
| 動的な名前 | なし | <template #[name]> |
旧構文のままVue 3で動かすとどうなるか
移行途中で書き換え漏れがあったときの挙動を確認しました。
<BaseModal :open="true">
<template slot="header">旧構文の見出し</template>
<p>本文</p>
</BaseModal>
描画された .modal の innerHTML:
<header><h2>お知らせ</h2></header><template slot="header"></template><p>本文</p><!--v-if-->
警告もエラーも出ず、slot="header" はただの属性として扱われました。 見出しはフォールバックの「お知らせ」のまま、<template> は HTML の <template> 要素としてデフォルトスロット側に出力され、中身は表示されません。旧構文はエラーにならないので、移行時は slot= と slot-scope= を全文検索して書き換えるのが確実です。
レンダーレスコンポーネントとcomposableの使い分け
レンダーレスコンポーネントとは、自分では見た目を持たず、ロジックの結果をスコープ付きスロットで渡すだけのコンポーネントです。 同じことは composable(use〜 関数)でもできます。
// useMouse.ts(composable)
import { ref, onMounted, onUnmounted } from 'vue'
export function useMouse() {
const x = ref(0), y = ref(0)
const update = (e: MouseEvent) => { x.value = e.pageX; y.value = e.pageY }
onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
return { x, y }
}
<!-- MouseTracker.vue(レンダーレス) -->
<script setup lang="ts">
import { useMouse } from './useMouse'
const { x, y } = useMouse()
defineSlots<{ default(props: { x: number; y: number }): any }>()
</script>
<template>
<slot :x="x" :y="y" />
</template>
<!-- 使う側:2通り -->
<div id="tracker">
<MouseTracker v-slot="{ x, y }">スロット: {{ x }}, {{ y }}</MouseTracker>
</div>
<div id="composable">composable: {{ x }}, {{ y }}</div>
<!-- <script setup> で const { x, y } = useMouse() -->
Playwright でマウスを (120, 80) に動かした結果:
スロット: 120, 80 | composable: 120, 80
| 観点 | レンダーレスコンポーネント | composable |
|---|---|---|
| 値を使える範囲 | スロットの中だけ | <script> とテンプレート全体 |
computed や watch で加工 |
しにくい(テンプレート内のみ) | しやすい |
| コンポーネントの追加 | 1つ増える | 増えない |
| テンプレートで範囲が見える | 見える(タグで囲んだ範囲) | 見えない |
| 向いている場面 | 見た目の一部だけに状態を渡す、既存の Vue 2 資産 | ロジックの再利用全般(Vue 3 の基本) |
Vue 3 ではロジックの再利用は composable が基本で、公式ドキュメントでも同じ方針が示されています。 レンダーレスコンポーネントは「この範囲にだけ値を渡したい」とテンプレート上で表したい場合や、仮想スクロールのように描画範囲を管理する部品で役立ちます。
よくある失敗と直し方
| 症状 | 原因 | 直し方 |
|---|---|---|
| 名前付きスロットの中身が出ない | #title など子にないスロット名 |
子の <slot name> と名前を合わせる。描画結果をテストで確認 |
| デフォルトとスコープ付きの混在でコンパイルエラー | コンポーネントに直接 v-slot を付けつつ <template #header> も使った |
デフォルトも <template #default="{ ... }"> で書く |
| 子の変数を親のスロットで使うと undefined | スロットの中身は親のスコープで評価される | 子で <slot :item="item"> と渡し、#default="{ item }" で受け取る |
| フッター枠だけ空で表示される | v-if="$slots.footer" がない |
条件付きスロットにする |
| Vue 3 に移行したら見出しが消えた | 旧 slot="header" 属性が残っている |
#header に書き換え |
| 受け取る値に型が付かない | defineSlots がない、または配列の型を伝えていない |
defineSlots と generic を使う |
よくある質問
Q. propsとslotはどう使い分けますか? A. 文字列や数値などの「値」は props、HTML や他のコンポーネントを含む「見た目の中身」は slot が目安です。たとえばボタンのラベルが文字だけなら props、アイコン付きにしたいなら slot にすると、使う側で自由に組み立てられます。
Q. スコープ付きスロットはどんなときに必要ですか? A. 子がデータ(一覧やループ、マウス位置などの状態)を持っていて、その1件ごとの見た目を親が決めたいときです。データテーブルのセル、リストの各行、ドロップダウンの選択肢などが代表例です。
Q. v-slot と # は何が違いますか?
A. 同じです。v-slot:header の省略形が #header で、デフォルトスロットは #default と書きます。v-slot を引数なしで使う場合(<Child v-slot="{ item }">)は、省略形にすると #default="{ item }" になります。
Q. スロットにイベントを渡せますか?
A. 関数を値として渡せます。この記事のモーダルでは <slot name="footer" :close="close" /> として、親の「キャンセル」ボタンから子の close を呼び出しています。
まとめ
- slotは「見た目の中身」を親から子の決まった場所へ差し込む仕組み。値は props、中身は slot
<slot>既定</slot>でフォールバック、#headerで名前付き、v-if="$slots.footer"で条件付き- スコープ付きスロットは子のデータを親で使う仕組み。テーブルのセルやリストの各行に向く
:nameで動的スロット名にすると、特定の列だけカスタマイズできる- defineSlots + generic で値の名前違い・型違いを vue-tsc が検出。スロット名の間違いは描画テストで補う
- Vue 2 の
slot/slot-scopeは Vue 3 でエラーにならず無視されるので、全文検索して書き換える - ロジックの再利用は composable が基本。範囲を示したいときはレンダーレスコンポーネント
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Vueのslotの使い方|名前付き・スコープ付きをコピペ部品で解説」
株式会社RJCでは、再利用しやすいコンポーネントの作り方を、プロジェクトの中で仲間と工夫し合っています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。