Building Production-Ready Modal Systems with DaisyUI and Svelte
Modals are one of those UI patterns that look trivially simple until you actually try to build them properly.
A button opens a box, a button closes it — what could go wrong? Quite a lot, as it turns out.
Accessibility falls apart the moment a screen reader user arrives. State logic sprawls across five components.
Someone tries to open a confirmation dialog from inside another dialog, and the whole stack collapses like a badly packed suitcase.
This guide cuts through that mess with a systematic approach to
daisyUI modal dialogs in Svelte —
covering centralized state management, promise-based patterns, nested dialogs, and ARIA compliance you won’t be ashamed of.
The combination of daisyUI and
Svelte is genuinely underrated for building
component-heavy interfaces. DaisyUI gives you semantic, Tailwind-powered classes without writing
utility soup in every template. Svelte’s reactivity model — stores, lifecycle hooks, and its
minimal runtime — makes stateful UI patterns cleaner than most alternatives. Together they give you
a surface area that’s small enough to understand fully and powerful enough to ship real products.
What follows isn’t a “copy this snippet and move on” tutorial. It’s a full architecture walkthrough —
one you can adapt into a reusable modal system that handles edge cases, scales across a SvelteKit app,
and passes accessibility audits without heroic effort. We’ll build up from the basics, add the
hard bits one layer at a time, and end with a production-grade implementation you’d actually want to maintain.
Setting Up DaisyUI with SvelteKit: The Right Foundation
Before any modal logic, the foundation has to be solid.
SvelteKit is now the default way
to build Svelte applications — its file-based routing, SSR capabilities, and build pipeline make it
the sensible starting point. The
daisyUI SvelteKit setup
is straightforward: install Tailwind CSS via @tailwindcss/vite, then add daisyUI as a plugin.
No PostCSS configuration ceremonies, no webpack archaeology.
# Install Tailwind and daisyUI
npm install tailwindcss @tailwindcss/vite daisyui
# vite.config.ts
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()]
});
In your global CSS entry point, add the two Tailwind directives and then import daisyUI.
The order matters — daisyUI’s theme system needs to sit on top of Tailwind’s base styles
so its CSS custom properties resolve correctly. Once that’s wired, you get the full
daisyUI component library
available through class names alone, with theming support baked in.
/* app.css */ @import "tailwindcss"; @plugin "daisyui";
One thing worth establishing early: daisyUI’s modal component historically relied on a hidden
checkbox or an anchor-based toggle to control visibility without JavaScript. That approach still works,
but it’s semantically questionable and makes dynamic state management awkward. For any real application,
you want the <dialog> element approach — using
HTML5’s native dialog element
via dialogRef.showModal() and dialogRef.close().
DaisyUI fully supports this pattern through its modal class family,
and it gives you proper browser-native backdrop handling, the close event,
and the Escape key for free.
Svelte Stores as a Centralized Modal State Machine
The single biggest architectural mistake in modal implementation is scattering open/closed booleans
across component local state. The moment you need to trigger a modal from a deeply nested component,
you’re prop-drilling callbacks or firing custom events up through five layers of the tree.
Svelte writable stores
solve this elegantly — they’re globally subscribable, reactive by default, and require no external
state management library.
// src/lib/stores/modalStore.ts
import { writable } from 'svelte/store';
export type ModalConfig = {
id: string;
component?: ConstructorOfATypedSvelteComponent;
props?: Record<string, unknown>;
title?: string;
onConfirm?: () => void;
onCancel?: () => void;
};
type ModalState = {
stack: ModalConfig[];
};
function createModalStore() {
const { subscribe, update, set } = writable<ModalState>({ stack: [] });
return {
subscribe,
open: (config: ModalConfig) =>
update(s => ({ stack: [...s.stack, config] })),
close: () =>
update(s => ({ stack: s.stack.slice(0, -1) })),
closeAll: () => set({ stack: [] }),
closeById: (id: string) =>
update(s => ({ stack: s.stack.filter(m => m.id !== id) }))
};
}
export const modalStore = createModalStore();
Notice the stack structure rather than a single boolean flag. This is the key that unlocks nested modals —
each open modal pushes a new configuration object onto the stack, and closing one pops it.
The topmost item in the stack is the active modal. This pattern means you never have to think about
“which modal is currently on top” inside your rendering logic; you just render the last element
of the stack. The store also supports component injection through the component field,
which makes it possible to render any Svelte component inside the modal without pre-defining it
in the modal template itself.
The store’s centralized nature also gives you an obvious place to add cross-cutting behavior:
logging, analytics events when modals open or close, or preventing opening when certain app
conditions are active (e.g., during a form submission). These concerns live in the store, not
scattered across every component that happens to trigger a dialog. That’s the kind of architecture
that makes maintenance pleasant six months after you first wrote it.
The Modal Host Component: Rendering the Stack
With the store defined, you need a single “host” component that subscribes to the store
and renders whatever’s on the stack. This component lives once in your root layout —
+layout.svelte in SvelteKit — and handles all modal rendering for the entire application.
No individual page or component needs to know about the DOM structure of the modal; they just
call modalStore.open() and the host takes care of the rest.
<!-- src/lib/components/ModalHost.svelte -->
<script lang="ts">
import { modalStore } from '$lib/stores/modalStore';
import { onDestroy } from 'svelte';
import Modal from './Modal.svelte';
let modals: typeof $modalStore.stack = [];
const unsub = modalStore.subscribe(s => (modals = s.stack));
onDestroy(unsub);
</script>
{#each modals as config, i (config.id)}
<Modal
{config}
zIndex={50 + i * 10}
on:close={() => modalStore.closeById(config.id)}
/>
{/each}
<!-- src/lib/components/Modal.svelte -->
<script lang="ts">
import { createEventDispatcher, onMount, onDestroy } from 'svelte';
import type { ModalConfig } from '$lib/stores/modalStore';
import { trapFocus } from '$lib/actions/trapFocus';
export let config: ModalConfig;
export let zIndex = 50;
const dispatch = createEventDispatcher();
let dialogEl: HTMLDialogElement;
onMount(() => {
dialogEl.showModal();
dialogEl.addEventListener('close', handleClose);
});
onDestroy(() => {
dialogEl?.removeEventListener('close', handleClose);
});
function handleClose() {
dispatch('close');
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === dialogEl) {
config.onCancel?.();
dispatch('close');
}
}
</script>
<dialog
bind:this={dialogEl}
class="modal"
style="z-index: {zIndex}"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title-{config.id}"
use:trapFocus
on:click={handleBackdropClick}
>
<div class="modal-box">
{#if config.title}
<h3
id="modal-title-{config.id}"
class="font-bold text-lg mb-4"
>
{config.title}
</h3>
{/if}
{#if config.component}
<svelte:component
this={config.component}
{...(config.props ?? {})}
on:confirm={() => { config.onConfirm?.(); dispatch('close'); }}
on:cancel={() => { config.onCancel?.(); dispatch('close'); }}
/>
{/if}
</div>
</dialog>
The use:trapFocus action referenced above is doing important work.
Let’s look at that next — it’s where accessibility stops being a checkbox and starts being actual engineering.
Svelte Modal Accessibility: Focus Trapping and ARIA That Works
Accessibility in modal dialogs is one of those areas where “good enough” is meaningfully different
from “actually correct.” The WCAG 2.1 success criteria around dialogs require three things:
focus moves into the dialog when it opens, focus cannot leave the dialog while it’s open
(via keyboard navigation), and focus returns to the element that triggered the dialog when it closes.
Getting all three right simultaneously is where most implementations slip.
// src/lib/actions/trapFocus.ts
export function trapFocus(node: HTMLElement) {
const focusableSelectors = [
'a[href]', 'button:not([disabled])', 'textarea',
'input', 'select', '[tabindex]:not([tabindex="-1"])'
].join(', ');
const previouslyFocused = document.activeElement as HTMLElement;
function getFocusable(): HTMLElement[] {
return Array.from(node.querySelectorAll<HTMLElement>(focusableSelectors));
}
function handleKeydown(e: KeyboardEvent) {
if (e.key !== 'Tab') return;
const focusable = getFocusable();
if (!focusable.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
// Move focus into dialog on mount
const firstFocusable = getFocusable()[0];
firstFocusable?.focus();
node.addEventListener('keydown', handleKeydown);
return {
destroy() {
node.removeEventListener('keydown', handleKeydown);
previouslyFocused?.focus();
}
};
}
This Svelte action (use:trapFocus) is attached directly to the <dialog> element.
When the action initializes, it moves focus to the first focusable element inside the dialog.
The keydown handler intercepts Tab and Shift+Tab to cycle focus
within the modal boundaries. When the action is destroyed — which happens when the modal
component unmounts — it returns focus to whichever element was active before the modal opened.
That last part, previouslyFocused?.focus(), is the step that most open-source
modal libraries either forget entirely or get wrong for elements that have been conditionally
rendered in the meantime.
On the ARIA side, the <dialog> element already carries implicit
role="dialog" semantics, but being explicit about it doesn’t hurt and helps
screen readers that are slightly behind the specification. The aria-modal="true"
attribute tells assistive technologies to treat the dialog as a modal context — hiding the
rest of the document from the virtual buffer. Without it, many screen readers will let
their virtual cursor wander outside the dialog even when keyboard focus is properly trapped.
The aria-labelledby attribute ties the dialog to its heading, giving screen
reader users an immediate announcement of what context they’ve entered.
<dialog> element withshowModal() provides browser-native backdrop rendering, Escapekey handling, and the
close event — all without JavaScript overhead.DaisyUI’s
.modal class works on top of this element seamlessly.
Promise-Based Modals: Awaiting User Decisions
One of the most ergonomic patterns in modal design — and one of the least common in the wild —
is the promise-based confirmation dialog. Instead of managing callback functions and boolean flags,
you write const confirmed = await confirm('Delete this record?') directly in
your event handler. The modal opens, the user makes a decision, the modal closes, and your
code continues with the result. It reads like synchronous code while being entirely non-blocking.
// src/lib/services/modalService.ts
import { modalStore } from '$lib/stores/modalStore';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
export function confirm(message: string, title = 'Confirm'): Promise<boolean> {
return new Promise((resolve) => {
const id = crypto.randomUUID();
modalStore.open({
id,
component: ConfirmDialog,
title,
props: { message },
onConfirm: () => resolve(true),
onCancel: () => resolve(false)
});
});
}
export function prompt(message: string, title = 'Input'): Promise<string | null> {
return new Promise((resolve) => {
const id = crypto.randomUUID();
modalStore.open({
id,
component: PromptDialog,
title,
props: { message },
onConfirm: (value: string) => resolve(value),
onCancel: () => resolve(null)
});
});
}
The service layer wraps the store in promise-returning functions. Each call to confirm()
or prompt() generates a unique ID for that modal instance, opens it on the stack,
and attaches the promise’s resolve function to the onConfirm and onCancel
callbacks. When the user clicks a button inside ConfirmDialog.svelte, it dispatches
the appropriate event, the host component calls the callback, the promise resolves, and the caller
continues. Clean, predictable, and completely decoupled from the UI structure.
<!-- src/lib/components/ConfirmDialog.svelte -->
<script lang="ts">
import { createEventDispatcher } from 'svelte';
export let message: string;
const dispatch = createEventDispatcher();
</script>
<p class="py-2 text-base-content/80">{message}</p>
<div class="modal-action">
<button
class="btn btn-ghost"
on:click={() => dispatch('cancel')}
>
Cancel
</button>
<button
class="btn btn-error"
on:click={() => dispatch('confirm')}
autofocus
>
Confirm
</button>
</div>
In a SvelteKit page or component, consumption is as simple as it looks:
// In any Svelte component
import { confirm } from '$lib/services/modalService';
async function handleDelete(itemId: string) {
const ok = await confirm('Are you sure you want to delete this item?', 'Delete Item');
if (!ok) return;
await deleteItem(itemId);
// Continue with post-deletion logic
}
Nested Modals: When One Dialog Opens Another
Nested modals are controversial in UX circles — some designers treat them as an anti-pattern,
others recognize they’re sometimes the most honest representation of a workflow
(a settings panel that triggers a confirmation before saving, for instance).
Whatever your position on the design debate, if you’ve built the stack-based store
described above, nested modals are already handled structurally. The engineering challenge
is making sure the second modal doesn’t visually compete with the first,
and that keyboard and focus behavior stays coherent.
The zIndex prop passed to each Modal component handles the stacking order —
each new modal on the stack gets a z-index 10 higher than its predecessor.
DaisyUI’s backdrop is rendered by the native <dialog> element’s
::backdrop pseudo-element, which stacks automatically with the dialog’s own
z-index context. This means you get correct backdrop layering without manually managing
backdrop overlay elements. The second modal’s backdrop covers the first modal’s content,
giving the user a clear visual indication that a new context has been entered.
Focus management in nested modals follows the same action-based pattern — each Modal
instance mounts its own trapFocus action, which captures the currently focused element
(the trigger inside the first modal), moves focus into the second modal, and restores it correctly
when the second modal closes. This is why the “capture active element on mount” approach is critical;
if you capture at the wrong moment, you end up restoring focus to the body or to an element
that no longer exists in the DOM.
Advanced DaisyUI Component Patterns: Beyond the Basic Modal
DaisyUI’s modal classes are part of a broader
advanced component system
that plays well with Svelte’s composition model. The .modal-box class constrains
the dialog’s content width and adds the rounded card appearance. .modal-action
provides flexbox alignment for button groups at the bottom. .modal-backdrop
can be used as a manual backdrop element when you’re not using the native dialog approach.
Knowing which class does what prevents the common mistake of fighting the framework’s assumptions.
For size variants, daisyUI provides modifier classes: .modal-top,
.modal-bottom, .modal-middle control vertical positioning.
Combined with Tailwind utility classes on .modal-box
(like max-w-3xl for wider dialogs or h-screen max-h-screen
for full-height side panels), you can cover the full spectrum of dialog patterns without
writing custom CSS. The daisyUI theming system
means your modals automatically respect dark mode, high-contrast themes, and any
custom theme you’ve defined — another thing you’d otherwise implement manually.
One pattern worth calling out explicitly: loading states inside modals.
When a modal triggers an async operation (submitting a form, fetching data),
you need to disable the action buttons and show feedback without closing the modal prematurely.
Using a local loading variable inside the dialog component — toggled around
the async call — combined with daisyUI’s .loading class on the button,
handles this with minimal code. The modal stays open during the operation, buttons are
disabled to prevent double-submission, and the spinner communicates progress to the user
without any third-party library.
Integrating with SvelteKit: Routing, SSR, and Layout Considerations
SvelteKit introduces a few constraints worth acknowledging when integrating a modal system.
The ModalHost component should live in the root layout (src/routes/+layout.svelte)
so it persists across route transitions. Without this, navigating between pages would unmount
and remount the host, potentially closing open modals mid-transition — not ideal if a user
is mid-form inside a dialog when they trigger a navigation.
<!-- src/routes/+layout.svelte --> <script> import ModalHost from '$lib/components/ModalHost.svelte'; </script> <slot /> <ModalHost />
On the server-side rendering side, the modal store initializes with an empty stack,
so there’s no hydration mismatch to worry about — no modal will be open on first render.
The onMount call inside the Modal component ensures
dialogEl.showModal() only runs in the browser, since
HTMLDialogElement.showModal doesn’t exist in Node.js environments.
This is one case where Svelte’s lifecycle hooks and SvelteKit’s SSR model align cleanly
without extra defensive code.
Deep-linking to a specific modal state via URL is a separate concern that the store-based
approach handles gracefully. If your application needs a shareable URL that opens a specific dialog
(a common pattern in admin panels and dashboards), you can add a +page.svelte
onMount hook that reads URL search params and calls modalStore.open()
on initialization. The history API can then reflect modal state changes with
goto(?modal=settings, { replaceState: true }) — no additional infrastructure needed.
Testing and Production Readiness
A modal system is only production-ready when it survives edge cases gracefully.
The most common failure modes to address: the user presses Escape while an async
operation is in flight (guard the close event by checking a loading state);
a modal is programmatically opened before the DOM is fully mounted
(the onMount pattern handles this, but test it under slow network conditions);
and rapid open/close sequences from users who click faster than your animations complete
(debounce the trigger or disable it during open transitions).
For automated testing, Playwright’s accessibility tree queries are the right tool.
Use page.getByRole('dialog') to locate open modals,
assert on their visible content, and interact with them via keyboard events.
This tests the actual browser behavior — including focus management and ARIA semantics —
rather than implementation details of your Svelte components. Vitest with
@testing-library/svelte handles unit-level testing of the store logic:
verify that calling modalStore.open() populates the stack,
modalStore.close() removes the top entry, and closeById
finds the right entry regardless of stack position.
- Use
page.getByRole('dialog')in Playwright for integration tests - Test keyboard navigation with
page.keyboard.press('Tab')sequences - Assert
aria-modalandaria-labelledbypresence in accessibility audits - Run
axe-coreagainst open modal states, not just page-level snapshots
FAQ
How do you manage modal state globally in Svelte?
Use a Svelte writable store that holds a stack of modal configuration objects.
Export functions like open(config), close(), and closeById(id)
from the store module. Any component in the application can call these functions without
prop-drilling or custom event chains. A single ModalHost component in the root
layout subscribes to the store and renders whatever modals are currently on the stack.
This pattern keeps all modal rendering logic centralized, makes testing straightforward,
and scales cleanly as the number of modal types in your app grows.
How do you make daisyUI modals ARIA-accessible?
Add role="dialog", aria-modal="true", and
aria-labelledby pointing to the modal’s heading element on the
<dialog> tag. Implement a focus trap that cycles keyboard focus within
the open dialog — blocking Tab and Shift+Tab from escaping the boundary.
Move focus into the dialog immediately on open (typically to the first interactive element
or the dialog itself), and restore focus to the triggering element when the dialog closes.
Using the native <dialog> element with showModal()
provides built-in Escape key handling and a browser-managed backdrop,
reducing the amount of accessibility scaffolding you need to build manually.
How do you implement promise-based modals in Svelte?
Create a service module that wraps modalStore.open() in a
new Promise() call. Pass the promise’s resolve function
as the onConfirm and onCancel callbacks in the modal config.
When the user makes a choice inside the dialog component, it dispatches an event,
the host triggers the appropriate callback, and the promise resolves with the result.
Callers can then write const ok = await confirm('Sure?') in any
async function — no state variables, no callback props, no event listeners at the call site.