Overlay

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

Migration
<!-- 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.

SlotDescription
overlayBackdrop overlay
contentModal content container
headerHeader section
wrapperTitle/description wrapper
titleTitle text
descriptionDescription text
actionsActions area (between title and close)
bodyMain content area
footerFooter section
closeClose button area

Snippets

SnippetDescription
childrenTrigger element. Receives a { props } argument you must spread onto your own focusable element.
contentReplace entire default layout
headerCustom header (replaces default)
titleSlotCustom title (overrides title prop)
descriptionSlotCustom description
actionsActions between title and close button
bodyBody content
footerFooter content
closeSlotCustom close button

Props

PropTypeDefault
openbooleanfalse
titlestring-
descriptionstring-
overlaybooleantrue
scrollablebooleanfalse
size'sm' | 'md' | 'lg' | 'xl' | 'full''md'
fullscreenbooleanfalse
closeboolean | ClosePropstrue
dismissiblebooleantrue
transition'none' | 'fade' | 'slide' | 'scale' | boolean'scale'
portalbooleantrue
onOpenChange(open) => void-
refHTMLElement | nullnull
classstring-
uiRecord<Slot, Class>-