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.

<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>

Installation

Copy the command below and run it in your terminal.

bunx @sivir-ui/svelte add modal

Usage

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.