Modal
Modalは、画面全体にオーバーレイを重ねて、特定の状態やコンテンツを最前面に提示するコンポーネントです。
ユーザーに対して明示的な情報を提示したり、オプションを選択させるときに使用できます。
<script lang="ts">
import Button from '$lib/components/ui/atoms/Button.svelte';
import Label from '$lib/components/ui/atoms/Label.svelte';
import Modal from '$lib/components/ui/modals/Modal.svelte';
let isOpen = $state(false);
/** モーダルを開く */
function openModal(event) {
event.preventDefault();
event.stopPropagation();
isOpen = !isOpen;
}
</script>
<Button onclick={openModal}>Open Modal</Button>
<Modal class="w-full max-w-lg" bind:open={isOpen}>
<div class="flex flex-col gap-2">
<Label class="!text-lg !font-semibold max-md:mx-auto">ラベル</Label>
<p class="text-muted-foreground text-sm max-md:text-center">ここに補足文が入ります。</p>
</div>
</Modal>
プロパティ
Modalは、以下のプロパティをサポートしています。
| 名前 | 型 | デフォルト値 | 説明 |
|---|---|---|---|
open |
boolean |
false |
Modal を表示するかどうか。 |
dismissible |
boolean |
true |
背景クリックおよびEscキーで閉じる動作を無効にするかどうか。 |
hideCloseButton |
boolean |
false |
trueのとき、閉じるボタンを非表示にします。 |
unstyled |
boolean |
false |
trueのとき、モーダル本体のスタイルを適用しません。 |
インストールの手順
以下のコンポーネントのコードを、使いたいプロジェクトにコピー&ペーストします。
パスは実際のプロジェクトの構成にあわせて更新します。
modals/Modal.svelte
<!--
@component
## 概要
- ユーザーに対して情報を提示したり、オプションを選択させるためのコンポーネントです
## 機能
- 任意のコンテンツを配置できます
- 外側クリックやEscキーで閉じるかどうかを制御できます
- 閉じるボタンを表示できます
- モーダル本体を非表示にするかどうか
- モーダルを開いたとき、`data-auto-focus` 属性を付与した要素に自動でフォーカスします。フォーカスしたい要素に付与してください。付与しない場合は閉じるボタン、それもなければモーダル枠自体にフォーカスします
## Props
- dismissible: falseを指定すると、背景クリックおよびEscキーでモーダルが閉じなくなります 初期値はtrue
- hideCloseButton: trueを指定することで閉じるボタンを非表示にすることができます
- unstyled: trueを指定するとモーダル本体のスタイルを適用しません
## Usage
```svelte
<Modal bind:open={isOpen}>
{@render children()}
</Modal>
```
-->
<script module lang="ts">
import type { Snippet } from 'svelte';
import type { ClassValue } from 'svelte/elements';
import { cva, type VariantProps } from 'class-variance-authority';
export const modalVariants = cva('relative focus:outline-none', {
variants: {
/** モーダル本体のスタイルを適用しないかどうか */
unstyled: {
true: [],
false: ['p-6 bg-surface border border-border rounded-lg shadow-lg'],
},
},
defaultVariants: {
unstyled: false,
},
});
export type ModalVariants = VariantProps<typeof modalVariants>;
export interface ModalProps extends ModalVariants {
/** Modalが開いているかどうか */
open: boolean;
/** 背景クリックおよびEscキーで閉じる動作を有効にするかどうか */
dismissible?: boolean;
/** 閉じるボタンを非表示にするかどうか */
hideCloseButton?: boolean;
/** モーダル本体を非表示にするかどうか */
unstyled?: boolean;
/** クラス */
class?: ClassValue;
children: Snippet<[]>;
}
</script>
<script lang="ts">
import Button from '$lib/components/ui/atoms/Button.svelte';
import { X } from '@lucide/svelte';
import { fade, scale } from 'svelte/transition';
let { class: className, open = $bindable(false), dismissible = true, unstyled = false, hideCloseButton = false, children }: ModalProps = $props();
let modalElement = $state<HTMLElement>();
let modalVariantsClass = $derived(modalVariants({ class: className, unstyled }));
$effect(() => {
let trigger_element: HTMLElement | null = null;
if (open) {
// モーダルを閉じた後にフォーカスを元の要素に戻すため、開く前のフォーカス先を保存
trigger_element = document.activeElement instanceof HTMLElement ? document.activeElement : null;
document.body.classList.add('overflow-hidden');
document.addEventListener('click', handleClickOutside);
document.addEventListener('keydown', handleKeyDown);
focusInitialElement();
}
return () => {
document.body.classList.remove('overflow-hidden');
document.removeEventListener('click', handleClickOutside);
document.removeEventListener('keydown', handleKeyDown);
// モーダルを開く前にフォーカスしていた要素にフォーカスを戻す
trigger_element?.focus();
};
});
/** data-auto-focus 属性を持つ要素にフォーカスする、なければ閉じるボタン、それもなければモーダル枠自体にフォーカスする */
function focusInitialElement() {
const target = modalElement?.querySelector<HTMLElement>('[data-auto-focus]')
?? modalElement?.querySelector<HTMLElement>('[data-close-button]')
?? modalElement;
target?.focus();
}
/** modal外をクリックしたときにmodalを閉じる */
function handleClickOutside(event: MouseEvent) {
if (!modalElement) return;
if (!(event.target instanceof HTMLElement)) return;
if (!modalElement.contains(event.target) && dismissible) {
open = false;
}
}
/** モーダル内のフォーカス可能な要素を取得する */
function getFocusableElements(): HTMLElement[] {
if (!modalElement) return [];
return Array.from(modalElement.querySelectorAll<HTMLElement>('a[href], button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])'));
}
/** フォーカストラップ・escキーでmodalを閉じる */
function handleKeyDown(event: KeyboardEvent) {
// このモーダル内にフォーカスがなければ何もしない(複数モーダル対応)
if (!modalElement?.contains(document.activeElement)) return;
if (event.key === 'Escape' && dismissible) {
open = false;
return;
}
// フォーカストラップ: Tab/Shift+Tabでモーダル内のフォーカス可能要素を循環させる
if (event.key === 'Tab') {
const focusable = getFocusableElements();
if (focusable.length === 0) {
event.preventDefault();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (document.activeElement === modalElement) {
// モーダル枠自体にフォーカスがある場合 → 先頭/末尾に移動
event.preventDefault();
(event.shiftKey ? last : first).focus();
}
else if (event.shiftKey && document.activeElement === first) {
// 先頭でShift+Tab → 末尾に移動
event.preventDefault();
last.focus();
}
else if (!event.shiftKey && document.activeElement === last) {
// 末尾でTab → 先頭に移動
event.preventDefault();
first.focus();
}
}
}
</script>
<div class="contents" data-rabee-ui="modal">
{#if open}
<div class="fixed inset-0 z-40 bg-overlay-dark/50" transition:fade={{ duration: 150 }}></div>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class={modalVariantsClass} role="dialog" aria-modal="true" bind:this={modalElement} tabindex="-1" transition:scale={{ start: 0.9, duration: 150 }}>
{@render children()}
{#if !hideCloseButton}
<Button class="absolute top-2 right-2 p-2" tone="ghost" variant="secondary" size="small" isSquare onclick={() => (open = false)} data-close-button>
<X size="1rem" />
</Button>
{/if}
</div>
</div>
{/if}
</div>
依存コンポーネント
Modalを使うときは、以下のコンポーネントもダウンロードが必要です。
使い方
<script lang="ts">
import Button from '$lib/components/ui/atoms/Button.svelte';
import Label from '$lib/components/ui/atoms/Label.svelte';
import Modal from '$lib/components/ui/modals/Modal.svelte';
let isOpen = $state(false);
/** モーダルを開く */
function openModal(event) {
event.preventDefault();
event.stopPropagation();
isOpen = !isOpen;
}
</script>
<Button onclick={openModal}>Open Modal</Button>
<Modal class="w-full max-w-lg" bind:open={isOpen}>
<div class="flex flex-col gap-2">
<Label class="!text-lg !font-semibold max-md:mx-auto">ラベル</Label>
<p class="text-muted-foreground text-sm max-md:text-center">ここに補足文が入ります。</p>
</div>
</Modal>
サンプル
Default
Defaultでの表示です。
<script lang="ts">
import Button from '$lib/components/ui/atoms/Button.svelte';
import Label from '$lib/components/ui/atoms/Label.svelte';
import Modal from '$lib/components/ui/modals/Modal.svelte';
let isOpen = $state(false);
/** モーダルを開く */
function openModal(event) {
event.preventDefault();
event.stopPropagation();
isOpen = !isOpen;
}
</script>
<Button onclick={openModal}>Open Modal</Button>
<Modal class="w-full max-w-lg" bind:open={isOpen}>
<div class="flex flex-col gap-2">
<Label class="!text-lg !font-semibold max-md:mx-auto">ラベル</Label>
<p class="text-muted-foreground text-sm max-md:text-center">ここに補足文が入ります。</p>
</div>
</Modal>
HideCloseButton
閉じるボタンが非表示の状態です。
<script lang="ts">
import Button from '$lib/components/ui/atoms/Button.svelte';
import Label from '$lib/components/ui/atoms/Label.svelte';
import Modal from '$lib/components/ui/modals/Modal.svelte';
let isOpen = $state(false);
/** モーダルを開く */
function openModal(event) {
event.preventDefault();
event.stopPropagation();
isOpen = !isOpen;
}
</script>
<Button onclick={openModal}>Open Modal</Button>
<Modal class="w-full max-w-lg" hideCloseButton bind:open={isOpen}>
<div class="flex flex-col gap-2">
<Label class="!text-lg !font-semibold max-md:m-auto">ラベル</Label>
<p class="text-muted-foreground text-sm max-md:text-center">ここに補足文が入ります。</p>
</div>
</Modal>
With Input,Select
InputやSelectなどの要素と組み合わせることもできます。
<script lang="ts">
import Button from '$lib/components/ui/atoms/Button.svelte';
import Input from '$lib/components/ui/atoms/Input.svelte';
import Label from '$lib/components/ui/atoms/Label.svelte';
import Select from '$lib/components/ui/atoms/Select.svelte';
import Modal from '$lib/components/ui/modals/Modal.svelte';
let selectValue = $state('');
let inputValue = $state('');
let isOpen = $state(false);
let options = [
{ label: '選択肢1', value: 'item1' },
{ label: '選択肢2', value: 'item2' },
{ label: '選択肢3', value: 'item3' },
];
/** モーダルを開く */
function openModal(event) {
event.preventDefault();
event.stopPropagation();
isOpen = !isOpen;
}
/** モーダルのボタンクリック(決定・キャンセル)の処理 */
function handleClick(result) {
if (result) {
isOpen = !isOpen;
}
else {
isOpen = !isOpen;
}
}
</script>
<Button onclick={openModal}>Open Modal</Button>
<Modal class="max-w-lg w-full" bind:open={isOpen}>
<div class="space-y-6">
<div class="flex flex-col gap-2">
<Label class="!font-semibold !text-lg max-md:mx-auto">ラベル</Label>
<p class="text-muted-foreground text-sm max-md:text-center">ここに補足文が入ります。</p>
</div>
<div>
<Label class="mb-2">ラベル</Label>
<Input class="w-full" type="text" placeholder="プレースホルダー" bind:value={inputValue} data-auto-focus />
</div>
<div>
<Label class="mb-2">ラベル</Label>
<Select options={options} placeholder="選択してください" bind:value={selectValue} />
</div>
<div class="flex justify-end gap-2 max-md:flex-col-reverse">
<Button class="max-md:w-full" variant="secondary" onclick={() => handleClick(false)}>キャンセル</Button>
<Button class="max-md:w-full" variant="primary" onclick={() => handleClick(true)}>決定</Button>
</div>
</div>
</Modal>
Alert Dialog
ユーザーへの情報の通知のみを目的するシーンは、モーダルを閉じるアクションのみを提供するAlert Dialogとして使用できます。
<script lang="ts">
import Button from '$lib/components/ui/atoms/Button.svelte';
import Label from '$lib/components/ui/atoms/Label.svelte';
import Modal from '$lib/components/ui/modals/Modal.svelte';
let isOpen = $state(false);
/** モーダルを開く */
function openModal(event) {
event.preventDefault();
event.stopPropagation();
isOpen = true;
}
</script>
<Button onclick={openModal}>Open Modal</Button>
<Modal class="space-y-6 w-full max-w-lg" dismissible={false} hideCloseButton bind:open={isOpen}>
<div class="flex flex-col gap-2 text-start">
<Label class="!text-lg !font-semibold max-md:mx-auto">データの読み込みに失敗しました</Label>
<p class="text-muted-foreground text-sm max-md:text-center">情報を取得できませんでした。<br />画面を更新して再度お試しください。</p>
</div>
<div class="flex justify-end">
<Button class="max-md:w-full" variant="primary" onclick={() => (isOpen = false)} data-auto-focus>閉じる</Button>
</div>
</Modal>
Confirm Dialog
データの削除確認など、注意喚起が必要なシーンでは、モーダルを閉じる機能を無くすことでConfirm Dialogとして使用できます。
<script lang="ts">
import Button from '$lib/components/ui/atoms/Button.svelte';
import Label from '$lib/components/ui/atoms/Label.svelte';
import Modal from '$lib/components/ui/modals/Modal.svelte';
let isOpen = $state(false);
/** モーダルを開く */
function openModal(event) {
event.preventDefault();
event.stopPropagation();
isOpen = true;
}
</script>
<Button onclick={openModal}>Open Modal</Button>
<Modal class="space-y-6 w-full max-w-lg" dismissible={false} hideCloseButton bind:open={isOpen}>
<div class="flex flex-col gap-2">
<Label class="!text-lg !font-semibold max-md:mx-auto">プロジェクトを削除しますか?</Label>
<p class="text-muted-foreground text-sm max-md:text-center">この操作は取り消せません。<br />削除すると、すべてのデータが完全に消去されます。</p>
</div>
<div class="flex justify-end gap-2 max-md:flex-col-reverse">
<Button class="max-md:w-full" variant="secondary" onclick={() => (isOpen = false)} data-auto-focus>キャンセル</Button>
<Button class="max-md:w-full" variant="danger" onclick={() => (isOpen = false)}>削除</Button>
</div>
</Modal>
Focus Trap
inert属性を使用して、モーダルが開いている間は背景の要素にフォーカスできないようにしています。Tabキーやマウスクリックで背景の入力欄やボタンにフォーカスが移ることを防ぎます。
<script lang="ts">
import Button from '$lib/components/ui/atoms/Button.svelte';
import Input from '$lib/components/ui/atoms/Input.svelte';
import Label from '$lib/components/ui/atoms/Label.svelte';
import Modal from '$lib/components/ui/modals/Modal.svelte';
let isOpen = $state(false);
let backgroundInput = $state('');
let modalInput = $state('');
/** モーダルを開く */
function openModal(event) {
event.preventDefault();
event.stopPropagation();
isOpen = !isOpen;
}
</script>
<div class="flex flex-col gap-4" inert={isOpen}>
<div class="flex flex-col gap-2">
<Label>背景のフォーム</Label>
<Input class="max-w-sm w-full" type="text" placeholder="背景の入力欄" bind:value={backgroundInput} />
<Button variant="secondary" onclick={() => alert('背景のボタンがクリックされました')}>背景のボタン</Button>
</div>
<Button onclick={openModal}>Open Modal</Button>
</div>
<Modal class="max-w-lg w-full" bind:open={isOpen} dismissible={false}>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<Label class="!font-semibold !text-lg">フォーカストラップ</Label>
<p class="text-muted-foreground text-sm">inert属性により、モーダルが開いている間は背景の要素にフォーカスできません。</p>
</div>
<Input class="w-full" type="text" placeholder="モーダル内のフィールド" bind:value={modalInput} data-auto-focus />
<div class="flex justify-end">
<Button variant="primary" onclick={() => (isOpen = false)}>閉じる</Button>
</div>
</div>
</Modal>
Loading
非同期処理の実行中など、画面遷移や再操作を防ぎたいシーンでは、Spinnerと組み合わせて使うことでLoadingとして使用できます。
<script lang="ts">
import Button from '$lib/components/ui/atoms/Button.svelte';
import Spinner from '$lib/components/ui/atoms/Spinner.svelte';
import Modal from '$lib/components/ui/modals/Modal.svelte';
let isOpen = $state(false);
$effect(() => {
if (isOpen) {
const timer = setTimeout(() => {
isOpen = false;
}, 1500);
return () => clearTimeout(timer);
}
});
/** モーダルを開く */
function openModal(event) {
event.preventDefault();
event.stopPropagation();
isOpen = true;
}
</script>
<Button variant="primary" onclick={openModal}>Open Modal</Button>
<Modal dismissible={false} unstyled hideCloseButton bind:open={isOpen}>
<Spinner />
</Modal>