dialog (headless)
dialog (from firstly/svelte) is a headless, async dialog layer for Svelte 5. It owns the
logic only - the queue, the await-able resolution, stacking, Escape, scroll-lock - and ships
no markup of its own beyond opt-in defaults. You mount one <FF_DialogManager> and either lean
on its theme-adaptive defaults or hand it your own snippets, so the dialog looks native to your
design system.
<script lang="ts"> import { dialog } from 'firstly/svelte'
async function remove() { const { ok } = await dialog.confirm('Delete this item?', { danger: true }) if (ok) await repo(Task).delete(id) }</script>Setup - mount the manager once
Section titled “Setup - mount the manager once”Put <FF_DialogManager> once, high in the tree (e.g. your root layout). Every dialog.* call
renders through it.
<script lang="ts"> import { FF_DialogManager } from 'firstly/svelte'</script>
<FF_DialogManager />{@render children()}That’s the zero-config setup: the built-in defaults render with semantic Tailwind tokens (see
Theming). To restyle, pass shell / confirm / prompt snippets (also below).
One result for all three
Section titled “One result for all three”show / confirm / prompt all resolve the same DialogResult shape:
type DialogResult<T> = { ok: true; data: T } | { ok: false }{ ok: true } means it went through (confirmed / submitted); { ok: false } means cancelled or
dismissed. No per-method boolean / null special-casing - if (r.ok) everywhere.
| Call | Resolves |
|---|---|
dialog.show<T>(body) | { ok: true, data: T } | { ok: false } |
dialog.open(Comp) | { ok: true, data: T } | { ok: false } (T from close prop) |
dialog.confirm(msg) | { ok: true } | { ok: false } (no data) |
dialog.prompt(opts) | { ok: true, data: string } | { ok: false } |
confirm
Section titled “confirm”Yes/no. { ok } is the answer.
const { ok } = await dialog.confirm('Save the changes?', { title: 'Confirm', confirmLabel: 'Save', cancelLabel: 'Cancel', danger: false, // true -> destructive styling})prompt
Section titled “prompt”A single text field. data is the trimmed value.
const r = await dialog.prompt({ title: 'New option', label: 'Label', placeholder: 'My option', hint: (v) => `key: ${slug(v) || '-'}`, // optional live hint under the field})if (r.ok) create(r.data)show - any body
Section titled “show - any body”show renders a snippet you pass, which receives a close(result?) callback. Call
close({ ok: true, data }) to resolve with data, close() to dismiss.
<script lang="ts"> import { dialog } from 'firstly/svelte'
let name = $state('')
async function open() { name = '' const r = await dialog.show<{ name: string }>(body, { width: 'md' }) if (r.ok) console.log(r.data.name) }</script>
{#snippet body(close)} <input bind:value={name} /> <button onclick={() => close({ name })}>OK</button> <button onclick={() => close()}>Cancel</button>{/snippet}
<button onclick={open}>Open</button>close(value) is normalised: a bare value becomes { ok: true, data: value }, undefined becomes
{ ok: false }, and an explicit { ok, data } is passed through.
open - a component
Section titled “open - a component”open renders a component + props - the natural door for reusable dialogs. close is injected
as a prop: declare it close: DialogClose<T> and open infers the resolved data from it, so there
is no call-site generic and no cast.
<script lang="ts"> import type { DialogClose } from 'firstly/svelte'
let { minQueryLength, close }: { minQueryLength: number; close: DialogClose<User> } = $props()</script>
<!-- ...body, with whatever heading/icon you want... --><button onclick={() => close({ ok: true, data: user })}>Choisir</button><script lang="ts"> import { dialog } from 'firstly/svelte'
import UserPicker from './UserPicker.svelte'
async function pick() { const r = await dialog.open(UserPicker, { props: { minQueryLength: 2 }, width: 'lg' }) if (r.ok) console.log(r.data) // r.data: User }</script>props is optional and is a snapshot at open time (it omits close, which the manager injects).
The component sits inside the same shell as show, so backdrop / Escape / width / dismissible
all apply unchanged.
show vs open: which one?
Section titled “show vs open: which one?”open(Component)- a reusable, standalone, typed dialog you’d want to test on its own. The result type comes from the component.show(snippet)- a quick one-off body, or an example that reads well inline (these docs use it for exactly that). No separate file.
There is no title option for either: you render the body, so the heading (text, icon, styling)
lives in your snippet/component. (confirm/prompt take a title because the manager renders
their markup.)
Options
Section titled “Options”dialog.show(body, { dismissible: true, // Escape / backdrop / close button (default true) width: 'sm' | 'md' | 'lg', // passed to your shell snippet allowClose: () => boolean | Promise<boolean>, // veto a dismiss (e.g. dirty-form guard)})Built in: Escape dismisses the topmost item, the body scroll-locks while anything is open, and dialogs stack (multiple open at once, newest on top).
Theming
Section titled “Theming”The manager renders built-in defaults with zero config. There are two ways to make them match your app.
Option 1 - define the semantic tokens
Section titled “Option 1 - define the semantic tokens”The defaults use shadcn-style semantic Tailwind color utilities. Define the matching CSS variables once and every dialog looks right, no per-call work:
| Token (utility) | Used for |
|---|---|
background / foreground | panel bg / text |
card | input bg (prompt) |
border | panel + control borders |
primary / primary-foreground | confirm / submit button |
destructive / destructive-foreground | danger confirm button |
muted / muted-foreground | hover / secondary text |
ring | focus ring |
/* app.css (Tailwind v4) */@theme { --color-background: oklch(1 0 0); --color-foreground: oklch(0.15 0 0); --color-primary: oklch(0.6 0.2 250); --color-primary-foreground: oklch(1 0 0); --color-border: oklch(0.9 0 0); /* ...muted, destructive, card, ring */}Option 2 - pass your own snippets
Section titled “Option 2 - pass your own snippets”For a different system (e.g. daisyUI), hand <FF_DialogManager> shell, confirm, and prompt
snippets. Each receives typed args and the actions to call.
<script lang="ts"> import { FF_DialogManager, ffAutofocus } from 'firstly/svelte' import type { DialogConfirmArgs, DialogShellArgs } from 'firstly/svelte'</script>
{#snippet shell({ body, close, dismiss, dismissible, width }: DialogShellArgs)} <div class="modal modal-open"> <div class="modal-box" use:ffAutofocus> {@render body(close)} {#if dismissible}<button onclick={dismiss}>✕</button>{/if} </div> <button class="modal-backdrop" onclick={dismiss} aria-label="Close"></button> </div>{/snippet}
{#snippet confirm({ message, title, confirmLabel, cancelLabel, danger, confirm, cancel,}: DialogConfirmArgs)} <div class="modal modal-open"> <div class="modal-box"> {#if title}<h3>{title}</h3>{/if} <p>{message}</p> <button onclick={cancel}>{cancelLabel}</button> <button class={danger ? 'btn-error' : 'btn-primary'} onclick={confirm}>{confirmLabel}</button> </div> </div>{/snippet}
<FF_DialogManager {shell} {confirm} />You can override any subset - omit prompt to keep the built-in prompt, etc.
Labels accept a LocalizedMessage
Section titled “Labels accept a LocalizedMessage”Every label (title, confirmLabel, cancelLabel, message, …) takes a LocalizedMessage: a
plain string, or a zero-arg function (a paraglide / i18next message) resolved at render time.
import * as m from '$lib/paraglide/messages'
await dialog.confirm(m.delete_item(), { confirmLabel: m.delete() })Imperative helpers
Section titled “Imperative helpers”dialog.closeAll() resolves everything as { ok: false } (e.g. on full-page navigation);
dismissTop() / dismissTopConfirm() / dismissTopPrompt() dismiss the topmost of a kind.