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期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?