Modal
Opens over the page, traps focus, and closes on Escape or an outside click. Modals can nest, and Escape closes only the top one.
Installation
Copy the command below and run it in your terminal.
bunx @sivir-ui/svelte add modalpnpm dlx @sivir-ui/svelte add modalnpx @sivir-ui/svelte add modalyarn dlx @sivir-ui/svelte add modalUsage
Bind open to control the modal from code. Modal.Close and Modal.Confirm both close it, and a Shortcut inside either one clicks that button when its key is pressed.
import * as Modal from '@sivir-ui/svelte/components/modal';
import Shortcut from '@sivir-ui/svelte/components/shortcut';
let open = $state(false);
<Modal.Root bind:open>
<Modal.Trigger>Rename project</Modal.Trigger>
<Modal.Content>
<Modal.Header>
<Modal.Title>Rename project</Modal.Title>
<Modal.Description>The new name appears in URLs and the sidebar.</Modal.Description>
</Modal.Header>
<Modal.Footer>
<Modal.Close>Cancel <Shortcut shortcut="esc" /></Modal.Close>
<Modal.Confirm>Save <Shortcut shortcut="enter" /></Modal.Confirm>
</Modal.Footer>
</Modal.Content>
</Modal.Root>Examples
Basic
<script lang="ts">
import * as Modal from '@sivir-ui/svelte/components/modal';
import Shortcut from '@sivir-ui/svelte/components/shortcut';
let open = $state(false);
</script>
<Modal.Root bind:open>
<Modal.Trigger>Archive project</Modal.Trigger>
<Modal.Content>
<Modal.Header>
<Modal.Title>Archive project?</Modal.Title>
<Modal.Description
>Archived projects are read-only until you restore them.</Modal.Description
>
</Modal.Header>
<Modal.Footer>
<Modal.Close>
Cancel
<Shortcut shortcut="esc" />
</Modal.Close>
<Modal.Confirm>
Archive
<Shortcut shortcut="enter" />
</Modal.Confirm>
</Modal.Footer>
</Modal.Content>
</Modal.Root>
Nested
<script lang="ts">
import * as Modal from '@sivir-ui/svelte/components/modal';
import Shortcut from '@sivir-ui/svelte/components/shortcut';
let open = $state(false);
let nestedOpen = $state(false);
</script>
<Modal.Root bind:open orientation="vertical">
<Modal.Trigger>Edit project</Modal.Trigger>
<Modal.Content>
<Modal.Header>
<Modal.Title>Edit project</Modal.Title>
<Modal.Description>
Change the name, or transfer this project to someone else.
</Modal.Description>
</Modal.Header>
<Modal.Body>
<Modal.Root bind:open={nestedOpen} orientation="vertical">
<Modal.Trigger variant="outline">Transfer ownership</Modal.Trigger>
<Modal.Content>
<Modal.Header>
<Modal.Title>Transfer ownership</Modal.Title>
<Modal.Description>
The new owner gets full control. You will remain a member.
</Modal.Description>
</Modal.Header>
<Modal.Footer>
<Modal.Close>
Cancel
<Shortcut shortcut="esc" />
</Modal.Close>
<Modal.Confirm>
Transfer
<Shortcut shortcut="enter" />
</Modal.Confirm>
</Modal.Footer>
</Modal.Content>
</Modal.Root>
</Modal.Body>
<Modal.Footer>
<Modal.Close>
Cancel
<Shortcut shortcut="esc" />
</Modal.Close>
<Modal.Confirm>
Save
<Shortcut shortcut="enter" />
</Modal.Confirm>
</Modal.Footer>
</Modal.Content>
</Modal.Root>
Deeply nested
<script lang="ts">
import * as Modal from '@sivir-ui/svelte/components/modal';
import Shortcut from '@sivir-ui/svelte/components/shortcut';
const MAX_LEVEL = 5;
const open = $state<boolean[]>(Array.from({ length: MAX_LEVEL }, () => false));
</script>
{#snippet level(index: number)}
<Modal.Root bind:open={open[index]} orientation="vertical">
<Modal.Trigger variant={index === 0 ? 'primary' : 'outline'}>
{index === 0 ? 'Open level 1' : `Open level ${index + 1}`}
</Modal.Trigger>
<Modal.Content>
<Modal.Header>
<Modal.Title>Level {index + 1} of {MAX_LEVEL}</Modal.Title>
<Modal.Description>
{#if index === 0}
Escape closes this level and returns to the page.
{:else}
Escape closes this level and returns to level {index}.
{/if}
</Modal.Description>
</Modal.Header>
{#if index + 1 < MAX_LEVEL}
<Modal.Body> {@render level(index + 1)} </Modal.Body>
{/if}
<Modal.Footer>
<Modal.Close>
Back
<Shortcut shortcut="esc" />
</Modal.Close>
</Modal.Footer>
</Modal.Content>
</Modal.Root>
{/snippet}
{@render level(0)}
Modals stack to any depth. Each earlier panel recedes behind the next, and Escape closes one level at a time.
With select
<script lang="ts">
import * as Modal from '@sivir-ui/svelte/components/modal';
import * as Select from '@sivir-ui/svelte/components/select';
import Shortcut from '@sivir-ui/svelte/components/shortcut';
let open = $state(false);
let role = $state('editor');
const roles = [
{ value: 'viewer', label: 'Viewer' },
{ value: 'editor', label: 'Editor' },
{ value: 'admin', label: 'Admin' }
];
const selected = $derived(roles.find((option) => option.value === role));
</script>
<Modal.Root bind:open>
<Modal.Trigger>Invite member</Modal.Trigger>
<Modal.Content>
<Modal.Header>
<Modal.Title>Invite member</Modal.Title>
<Modal.Description>Choose a role for the new member.</Modal.Description>
</Modal.Header>
<Modal.Body>
<div class="flex flex-col gap-2">
<span class="text-sm [font-weight:var(--font-weight-label,500)] text-foreground">
Role
</span>
<Select.Root value={role}>
<Select.Trigger class="w-full" variant="outline" size="md">
{selected?.label ?? 'Select role'}
</Select.Trigger>
<Select.Content>
{#each roles as option (option.value)}
<Select.Item value={option.value} onclick={() => (role = option.value)}>
{option.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
</Modal.Body>
<Modal.Footer>
<Modal.Close>
Cancel
<Shortcut shortcut="esc" />
</Modal.Close>
<Modal.Confirm>
Invite
<Shortcut shortcut="enter" />
</Modal.Confirm>
</Modal.Footer>
</Modal.Content>
</Modal.Root>
Escape closes an open Select first. Press it again to close the modal.
Compact
<script lang="ts">
import * as Modal from '@sivir-ui/svelte/components/modal';
import Shortcut from '@sivir-ui/svelte/components/shortcut';
let open = $state(false);
</script>
<Modal.Root bind:open orientation="vertical">
<Modal.Trigger variant="outline">Open compact modal</Modal.Trigger>
<Modal.Content size="sm">
<Modal.Header>
<Modal.Title>Leave without saving?</Modal.Title>
<Modal.Description>Your edits to this draft will be lost.</Modal.Description>
</Modal.Header>
<Modal.Footer>
<Modal.Close>
Cancel
<Shortcut shortcut="esc" />
</Modal.Close>
<Modal.Confirm>
Leave
<Shortcut shortcut="enter" />
</Modal.Confirm>
</Modal.Footer>
</Modal.Content>
</Modal.Root>
Wide
<script lang="ts">
import * as Modal from '@sivir-ui/svelte/components/modal';
import Shortcut from '@sivir-ui/svelte/components/shortcut';
let open = $state(false);
</script>
<Modal.Root bind:open orientation="horizontal">
<Modal.Trigger variant="outline">Open wide modal</Modal.Trigger>
<Modal.Content size="xl">
<Modal.Header>
<Modal.Title>Import project data</Modal.Title>
<Modal.Description>
Pick the repositories to copy and the environment to deploy them to.
</Modal.Description>
</Modal.Header>
<Modal.Body>
<div class="grid gap-3 sm:grid-cols-2">
<div class="rounded-[var(--radius-md)] border border-border bg-secondary p-4">
<p class="font-medium text-foreground">Repositories</p>
<p class="mt-1 text-sm text-foreground-muted">
Choose which repositories to import.
</p>
</div>
<div class="rounded-[var(--radius-md)] border border-border bg-secondary p-4">
<p class="font-medium text-foreground">Environment</p>
<p class="mt-1 text-sm text-foreground-muted">
Set the target deployment environment.
</p>
</div>
</div>
</Modal.Body>
<Modal.Footer>
<Modal.Close>
Cancel
<Shortcut shortcut="esc" />
</Modal.Close>
<Modal.Confirm>
Import
<Shortcut shortcut="enter" />
</Modal.Confirm>
</Modal.Footer>
</Modal.Content>
</Modal.Root>
Anatomy
Modal.Root - Controls modal state.
Modal.Trigger - Opens the modal.
Modal.Content - Renders the modal surface.
Modal.Header - Groups modal heading content.
Modal.Title - Renders the modal title.
Modal.Description - Renders the modal description.
Modal.Body - Renders the modal body.
Modal.Footer - Groups modal actions.
Modal.Close - Closes the modal.
Modal.Confirm - Confirms and closes the modal.
<script lang="ts"> import Globe from '@lucide/svelte/icons/globe'; import { Input } from '@sivir-ui/svelte/components/input'; import * as Modal from '@sivir-ui/svelte/components/modal'; import Shortcut from '@sivir-ui/svelte/components/shortcut'; let open = $state(false); let domain = $state(''); </script> <Modal.Root bind:open orientation="vertical"> <Modal.Trigger>Add domain</Modal.Trigger> <Modal.Content> <Modal.Header> <div class="flex items-center gap-2.5"> <Globe size={18} class="text-foreground-muted" /> <Modal.Title>Add a domain</Modal.Title> </div> <Modal.Description>Add an existing domain to your sivir-ui project.</Modal.Description> </Modal.Header> <Modal.Body class="gap-4"> <Input bind:value={domain} label="Domain" placeholder="example.com" description="We'll guide you through DNS configuration next." /> </Modal.Body> <Modal.Footer> <Modal.Close> Cancel <Shortcut shortcut="esc" /> </Modal.Close> <Modal.Confirm> Add <Shortcut shortcut="enter" /> </Modal.Confirm> </Modal.Footer> </Modal.Content> </Modal.Root>