Modal
A dialog modal with header, body, and footer sections. Supports scrollable content, fullscreen mode, custom headers, and non-dismissible state. Also composed by the Header mobile menu (default mode).
<!-- Before (v2.0.0) - trigger auto-wrapped in a <button> -->
<Modal bind:open title="Basic Modal">
<Button label="Open Modal" onclick={() => (open = true)} />
{#snippet body()}...{/snippet}
</Modal>
<!-- After (v2.1.0) - children is a snippet; spread props onto the trigger -->
<Modal bind:open title="Basic Modal">
{#snippet children({ props })}
<Button {...props} label="Open Modal" />
{/snippet}
{#snippet body()}...{/snippet}
</Modal>Basic Usage
Use bind:open to control visibility.
<script lang="ts">
import { Modal, Button } from 'sv5ui';
let open = $state(false);
</script>
<Button label="Open Modal" onclick={() => (open = true)} />
<Modal
bind:open
title="Basic Modal"
description="This is a simple modal with a title and description."
>
{#snippet body()}
<p>This is the body content of the modal.</p>
{/snippet}
</Modal>Trigger Snippet
Instead of an external button, render the trigger inside the children snippet and spread the props argument onto your own focusable element.
<script lang="ts">
import { Modal, Button } from 'sv5ui';
let open = $state(false);
</script>
<Modal
bind:open
title="Trigger Snippet"
description="The trigger lives inside the children snippet."
>
{#snippet children({ props })}
<Button {...props} label="Open via Trigger" />
{/snippet}
{#snippet body()}
<p>Opened by a trigger that spreads `props` - no external button needed.</p>
{/snippet}
</Modal>With Footer
Add action buttons in the footer.
<script lang="ts">
import { Modal, Button } from 'sv5ui';
let open = $state(false);
</script>
<Button label="Open Modal" onclick={() => (open = true)} />
<Modal
bind:open
title="Confirm Action"
description="Are you sure you want to proceed?"
>
{#snippet body()}
<p>This action cannot be undone. Please confirm to continue.</p>
{/snippet}
{#snippet footer()}
<Button variant="outline" color="surface" label="Cancel" onclick={() => (open = false)} />
<Button label="Confirm" onclick={() => (open = false)} />
{/snippet}
</Modal>Scrollable
Two scroll modes for long content. The default keeps header/footer fixed and scrolls inside the body. Set scrollable to let the whole modal scroll within the overlay (use for very tall modals). Both modes prevent the underlying page from scrolling.
<script lang="ts">
import { Modal, Button } from 'sv5ui';
let bodyOpen = $state(false); // default mode - body scrolls inside modal
let wholeOpen = $state(false); // scrollable mode - entire overlay scrolls
const sections = Array.from({ length: 15 }, (_, i) => i + 1);
</script>
<div class="flex flex-wrap gap-2">
<Button label="Body-Only Scroll (default)" onclick={() => (bodyOpen = true)} />
<Button variant="outline" label="Whole Modal Scrolls" onclick={() => (wholeOpen = true)} />
</div>
<!-- Default: header & footer stay fixed, only the body scrolls -->
<Modal bind:open={bodyOpen} title="Terms of Service">
{#snippet body()}
<div class="space-y-4">
{#each sections as n (n)}
<p>Section {n}. Long content here...</p>
{/each}
</div>
{/snippet}
{#snippet footer()}
<Button variant="outline" label="Decline" onclick={() => (bodyOpen = false)} />
<Button label="Accept" onclick={() => (bodyOpen = false)} />
{/snippet}
</Modal>
<!-- scrollable={true}: the whole modal (overlay) scrolls - use for very tall modals -->
<Modal bind:open={wholeOpen} title="Long Form" scrollable>
{#snippet body()}
<div class="space-y-4">
{#each sections as n (n)}
<p>Section {n}. Long content here...</p>
{/each}
</div>
{/snippet}
</Modal>Sizes
Use size to control modal width - values 'sm', 'md' (default), 'lg', 'xl', or 'full' to fill the entire viewport. The legacy fullscreen prop is now a deprecated alias of size="full".
<script lang="ts">
import { Modal, Button } from 'sv5ui';
import type { ModalProps } from 'sv5ui';
let open = $state(false);
let size = $state<NonNullable<ModalProps['size']>>('md');
</script>
<div class="flex flex-wrap gap-2">
{#each (['sm', 'md', 'lg', 'xl', 'full'] as const) as s (s)}
<Button
label={s === 'full' ? 'Full' : s.toUpperCase()}
variant={size === s ? 'solid' : 'outline'}
onclick={() => { size = s; open = true; }}
/>
{/each}
</div>
<Modal bind:open title="Size: {size}" {size}>
{#snippet body()}
<p>This modal is rendered with size="{size}".</p>
{/snippet}
</Modal>
<!--
The legacy `fullscreen` prop still works as an alias of size="full":
<Modal fullscreen title="..." /> <!-- deprecated -->
<Modal size="full" title="..." /> <!-- preferred -->
-->Transitions
Control the entrance/exit animation with transition: 'scale' (default), 'fade', 'slide', or 'none'. The legacy boolean form still works (true → previous default, false → 'none').
<script lang="ts">
import { Modal, Button } from 'sv5ui';
let open = $state(false);
let transition = $state<'none' | 'fade' | 'slide' | 'scale'>('scale');
</script>
<div class="flex flex-wrap gap-2">
{#each (['scale', 'fade', 'slide', 'none'] as const) as t (t)}
<Button
label={t.charAt(0).toUpperCase() + t.slice(1)}
variant={transition === t ? 'solid' : 'outline'}
onclick={() => { transition = t; open = true; }}
/>
{/each}
</div>
<Modal bind:open title="Transition: {transition}" {transition}>
{#snippet body()}
<p>Animation: {transition}</p>
{/snippet}
</Modal>
<!--
Boolean still supported for back-compat:
- transition={true} → previous default
- transition={false} → 'none'
-->Custom Header
Replace the default header with a custom layout.
<script lang="ts">
import { Modal, Button } from 'sv5ui';
let open = $state(false);
</script>
<Button label="Open Custom Header" onclick={() => (open = true)} />
<Modal bind:open>
{#snippet header()}
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
<span class="text-primary text-lg">!</span>
</div>
<div>
<h3 class="text-lg font-semibold">Custom Header</h3>
<p class="text-sm text-on-surface/60">With a custom icon layout</p>
</div>
</div>
{/snippet}
{#snippet body()}
<p>The header snippet lets you fully customize the header area.</p>
{/snippet}
</Modal>Nested Modals
Stack modals on top of each other.
<script lang="ts">
import { Modal, Button } from 'sv5ui';
let outerOpen = $state(false);
let innerOpen = $state(false);
</script>
<Button label="Open Outer Modal" onclick={() => (outerOpen = true)} />
<Modal bind:open={outerOpen} title="Outer Modal">
{#snippet body()}
<p>This is the outer modal. Click below to open a nested modal.</p>
<Button label="Open Inner Modal" variant="outline" onclick={() => (innerOpen = true)} class="mt-4" />
{/snippet}
{#snippet footer()}
<Button variant="outline" label="Close" onclick={() => (outerOpen = false)} />
{/snippet}
</Modal>
<Modal bind:open={innerOpen} title="Inner Modal" description="This is a nested modal on top of the outer one.">
{#snippet body()}
<p>You can stack modals on top of each other.</p>
{/snippet}
{#snippet footer()}
<Button label="Close Inner" onclick={() => (innerOpen = false)} />
{/snippet}
</Modal>With Form & SelectMenu
A practical example combining Modal with form controls including a searchable SelectMenu.
<script lang="ts">
import { Modal, Button, Input, Textarea, Select, SelectMenu, FormField } from 'sv5ui';
let open = $state(false);
</script>
<Button label="Create Task" onclick={() => (open = true)} />
<Modal bind:open title="Create New Task" description="Fill in the details below.">
{#snippet body()}
<form class="space-y-4">
<FormField label="Title" required>
<Input placeholder="Task title..." />
</FormField>
<FormField label="Assignee">
<SelectMenu
placeholder="Search team members..."
items={[
{ value: 'alice', label: 'Alice Martin', avatar: { src: 'https://i.pravatar.cc/40?u=alice', alt: 'Alice' } },
{ value: 'bob', label: 'Bob Wilson', avatar: { src: 'https://i.pravatar.cc/40?u=bob', alt: 'Bob' } },
{ value: 'carol', label: 'Carol Lee', avatar: { src: 'https://i.pravatar.cc/40?u=carol', alt: 'Carol' } },
{ value: 'dave', label: 'Dave Kim', avatar: { src: 'https://i.pravatar.cc/40?u=dave', alt: 'Dave' } }
]}
/>
</FormField>
<FormField label="Priority">
<Select
placeholder="Select priority"
items={[
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
{ value: 'urgent', label: 'Urgent' }
]}
/>
</FormField>
<FormField label="Description" hint="Optional">
<Textarea placeholder="Describe the task..." autoresize />
</FormField>
</form>
{/snippet}
{#snippet footer()}
<Button variant="outline" color="surface" label="Cancel" onclick={() => (open = false)} />
<Button label="Create Task" onclick={() => (open = false)} />
{/snippet}
</Modal>Non-Dismissible
Prevent closing via overlay click or Escape.
<script lang="ts">
import { Modal, Button } from 'sv5ui';
let open = $state(false);
</script>
<Button label="Open Non-dismissible" onclick={() => (open = true)} />
<Modal
bind:open
title="Important Notice"
dismissible={false}
>
{#snippet body()}
<p>This modal cannot be closed by clicking outside or pressing Escape. You must use the button below.</p>
{/snippet}
{#snippet footer()}
<Button label="I Understand" onclick={() => (open = false)} />
{/snippet}
</Modal>UI Slots
Use the ui prop to override classes on internal elements.
| Slot | Description |
|---|---|
overlay | Backdrop overlay |
content | Modal content container |
header | Header section |
wrapper | Title/description wrapper |
title | Title text |
description | Description text |
actions | Actions area (between title and close) |
body | Main content area |
footer | Footer section |
close | Close button area |
Snippets
| Snippet | Description |
|---|---|
children | Trigger element. Receives a { props } argument you must spread onto your own focusable element. |
content | Replace entire default layout |
header | Custom header (replaces default) |
titleSlot | Custom title (overrides title prop) |
descriptionSlot | Custom description |
actions | Actions between title and close button |
body | Body content |
footer | Footer content |
closeSlot | Custom close button |
Props
| Prop | Type | Default |
|---|---|---|
open | boolean | false |
title | string | - |
description | string | - |
overlay | boolean | true |
scrollable | boolean | false |
size | 'sm' | 'md' | 'lg' | 'xl' | 'full' | 'md' |
fullscreen | boolean | false |
close | boolean | CloseProps | true |
dismissible | boolean | true |
transition | 'none' | 'fade' | 'slide' | 'scale' | boolean | 'scale' |
portal | boolean | true |
onOpenChange | (open) => void | - |
ref | HTMLElement | null | null |
class | string | - |
ui | Record<Slot, Class> | - |