daisyUI is a popular Tailwind CSS component library that provides ready-to-use component classes for rapid UI development. When combined with Svelte, it enables developers to create dynamic and accessible applications. This article covers building an advanced modal system with state management through Svelte stores, nested modal support, animations, and full accessibility. This is part 17 of a series on using daisyUI with Svelte.
This guide walks through creating a production-ready modal system with centralized state management, modal stack support, automatic focus trapping, and keyboard handling.
Prerequisites
Before starting, ensure you have:
- A Svelte project (SvelteKit or standalone Svelte 4+)
- Node.js 18+ and npm/pnpm/yarn
- Tailwind CSS configured in your project
- Understanding of Svelte stores and reactivity
- Basic familiarity with accessibility concepts (ARIA, focus management)
Installation
Install daisyUI as a Tailwind CSS plugin:
npm install -D daisyui
# or
pnpm add -D daisyui
# or
yarn add -D daisyui
daisyUI works on top of Tailwind CSS, so make sure Tailwind CSS is already installed in your project.
Project Setup
Configure daisyUI in your Tailwind CSS configuration:
// tailwind.config.js
/** @type {import('tailwindcss').Config} */
export default {
content: [
'./src/**/*.{html,js,svelte,ts}',
],
theme: {
extend: {},
},
plugins: [require('daisyui')],
daisyui: {
themes: ['light', 'dark', 'cupcake'],
darkTheme: 'dark',
base: true,
styled: true,
utils: true,
},
}
Add Tailwind directives to your main CSS file:
/* src/app.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
Import the CSS in your layout:
<!-- src/routes/+layout.svelte -->
<script>
import '../app.css';
</script>
<slot />
First Example / Basic Usage
Let's start with a simple modal controlled through a Svelte store:
<!-- src/lib/Modal.svelte -->
<script>
import { createEventDispatcher } from 'svelte';
export let open = false;
export let title = 'Modal Title';
export let size = 'md'; // sm, md, lg, xl, 2xl, 3xl, 4xl, 5xl, 6xl, 7xl, full
const dispatch = createEventDispatcher();
let dialogElement;
$: if (dialogElement) {
if (open) {
dialogElement.showModal();
} else {
dialogElement.close();
}
}
function handleClose() {
open = false;
dispatch('close');
}
function handleBackdropClick(event) {
if (event.target === dialogElement) {
handleClose();
}
}
</script>
<dialog
bind:this={dialogElement}
class="modal"
on:click={handleBackdropClick}
on:close={handleClose}
>
<div class="modal-box" class:modal-{size}>
<form method="dialog">
<button
class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
aria-label="Close modal"
>
✕
</button>
</form>
<h3 class="font-bold text-lg mb-4">{title}</h3>
<slot />
<div class="modal-action">
<slot name="actions" />
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
Using the component:
<!-- src/routes/+page.svelte -->
<script>
import Modal from '$lib/Modal.svelte';
let isOpen = false;
</script>
<button class="btn btn-primary" on:click={() => isOpen = true}>
Open Modal
</button>
<Modal bind:open={isOpen} title="Welcome">
<p>This is a simple modal dialog.</p>
<svelte:fragment slot="actions">
<button class="btn" on:click={() => isOpen = false}>Close</button>
</svelte:fragment>
</Modal>
This example demonstrates a basic modal using the HTML5 <dialog> element. The component automatically opens and closes when the open prop changes, supports closing on backdrop click and ESC key.
Understanding the Basics
daisyUI provides two approaches to creating modals:
- Checkbox-based modals — use a hidden checkbox to control state
-
Dialog-based modals — use the native HTML5
<dialog>element
For an advanced system with programmatic control, we use the <dialog> element because it provides:
- Native API (
showModal(),close()) - Automatic focus trapping
- ESC key support
- Better accessibility out of the box
Key daisyUI classes for modals:
-
modal— main container -
modal-box— modal content -
modal-backdrop— darkened background -
modal-action— area for action buttons -
modal-{size}— modal sizes
Practical Example / Building Something Real
Let's create a complete modal management system using a Svelte store:
// src/stores/modalStore.ts
import { writable } from 'svelte/store';
export interface ModalConfig {
id: string;
component: any;
props?: Record<string, any>;
size?: string;
closable?: boolean;
}
interface ModalState {
modals: ModalConfig[];
}
function createModalStore() {
const { subscribe, update, set } = writable<ModalState>({ modals: [] });
return {
subscribe,
open: (config: ModalConfig) => {
update((state) => ({
modals: [...state.modals, config],
}));
},
close: (id: string) => {
update((state) => ({
modals: state.modals.filter((modal) => modal.id !== id),
}));
},
closeAll: () => {
set({ modals: [] });
},
closeLast: () => {
update((state) => {
const newModals = [...state.modals];
newModals.pop();
return { modals: newModals };
});
},
};
}
export const modalStore = createModalStore();
Create a universal modal container component:
<!-- src/lib/ModalContainer.svelte -->
<script>
import { modalStore } from '$stores/modalStore';
import { onMount } from 'svelte';
let dialogElements: Map<string, HTMLDialogElement> = new Map();
let previousActiveElement: HTMLElement | null = null;
$: modals = $modalStore.modals;
function handleClose(id: string) {
modalStore.close(id);
// Restore focus to previous element
if (previousActiveElement) {
previousActiveElement.focus();
previousActiveElement = null;
}
}
function handleBackdropClick(event: MouseEvent, id: string) {
const dialog = dialogElements.get(id);
if (dialog && event.target === dialog) {
handleClose(id);
}
}
function handleKeyDown(event: KeyboardEvent, id: string) {
if (event.key === 'Escape') {
const lastModal = modals[modals.length - 1];
if (lastModal?.id === id) {
handleClose(id);
}
}
}
$: {
// Manage modal opening/closing
modals.forEach((modal) => {
const dialog = dialogElements.get(modal.id);
if (dialog) {
if (!dialog.open) {
// Save active element before opening
previousActiveElement = document.activeElement as HTMLElement;
dialog.showModal();
}
}
});
// Close modals that are no longer in the list
dialogElements.forEach((dialog, id) => {
if (!modals.find((m) => m.id === id)) {
dialog.close();
}
});
}
</script>
{#each modals as modal, index (modal.id)}
{@const Component = modal.component}
{@const isLast = index === modals.length - 1}
<dialog
bind:this={dialogElements.get(modal.id) || undefined}
class="modal"
class:modal-open={isLast}
on:click={(e) => handleBackdropClick(e, modal.id)}
on:keydown={(e) => handleKeyDown(e, modal.id)}
data-modal-id={modal.id}
aria-labelledby="modal-title-{modal.id}"
aria-modal="true"
role="dialog"
>
<div class="modal-box" class:modal-{modal.size || 'md'}>
{#if modal.closable !== false}
<form method="dialog">
<button
class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
aria-label="Close modal"
on:click={() => handleClose(modal.id)}
>
✕
</button>
</form>
{/if}
<div id="modal-title-{modal.id}">
<Component {...modal.props} />
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
{/each}
Create example modal components:
<!-- src/lib/modals/ConfirmModal.svelte -->
<script>
export let title = 'Confirm Action';
export let message = 'Are you sure you want to proceed?';
export let confirmText = 'Confirm';
export let cancelText = 'Cancel';
export let variant = 'primary'; // primary, error, warning, success
export let onconfirm: () => void;
export let oncancel: () => void;
function handleConfirm() {
onconfirm?.();
}
function handleCancel() {
oncancel?.();
}
</script>
<div>
<h3 class="font-bold text-lg mb-4">{title}</h3>
<p class="py-4">{message}</p>
<div class="modal-action">
<button class="btn" on:click={handleCancel}>{cancelText}</button>
<button class="btn btn-{variant}" on:click={handleConfirm}>
{confirmText}
</button>
</div>
</div>
<!-- src/lib/modals/FormModal.svelte -->
<script>
export let title = 'Form';
export let submitText = 'Submit';
export let onsubmit: (data: any) => void;
let formData = {
name: '',
email: '',
};
function handleSubmit() {
onsubmit?.(formData);
}
</script>
<div>
<h3 class="font-bold text-lg mb-4">{title}</h3>
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
<div class="form-control">
<label class="label">
<span class="label-text">Name</span>
</label>
<input
type="text"
placeholder="Enter name"
class="input input-bordered"
bind:value={formData.name}
required
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Email</span>
</label>
<input
type="email"
placeholder="Enter email"
class="input input-bordered"
bind:value={formData.email}
required
/>
</div>
<div class="modal-action">
<button type="submit" class="btn btn-primary">{submitText}</button>
</div>
</form>
</div>
Now create helper functions for convenient usage:
// src/lib/modalHelpers.ts
import { modalStore } from '$stores/modalStore';
import ConfirmModal from '$lib/modals/ConfirmModal.svelte';
import FormModal from '$lib/modals/FormModal.svelte';
export function openModal(
component: any,
props: Record<string, any> = {},
options: { size?: string; closable?: boolean } = {}
) {
const id = `modal-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
modalStore.open({
id,
component,
props,
size: options.size || 'md',
closable: options.closable !== false,
});
return id;
}
export function closeModal(id: string) {
modalStore.close(id);
}
export function confirm(options: {
title?: string;
message?: string;
confirmText?: string;
cancelText?: string;
variant?: string;
}): Promise<boolean> {
return new Promise((resolve) => {
const id = openModal(
ConfirmModal,
{
...options,
onconfirm: () => {
closeModal(id);
resolve(true);
},
oncancel: () => {
closeModal(id);
resolve(false);
},
},
{ closable: false }
);
});
}
export function showForm(options: {
title?: string;
submitText?: string;
}): Promise<any> {
return new Promise((resolve) => {
const id = openModal(
FormModal,
{
...options,
onsubmit: (data: any) => {
closeModal(id);
resolve(data);
},
},
{ closable: true }
);
});
}
Add ModalContainer to the root layout:
<!-- src/routes/+layout.svelte -->
<script>
import '../app.css';
import ModalContainer from '$lib/ModalContainer.svelte';
</script>
<slot />
<ModalContainer />
Example usage in a component:
<!-- src/routes/+page.svelte -->
<script>
import { confirm, showForm, openModal } from '$lib/modalHelpers';
import CustomModal from '$lib/modals/CustomModal.svelte';
async function handleDelete() {
const result = await confirm({
title: 'Delete Item',
message: 'This action cannot be undone.',
confirmText: 'Delete',
cancelText: 'Cancel',
variant: 'error',
});
if (result) {
console.log('Item deleted');
// Perform deletion
}
}
async function handleAddUser() {
const data = await showForm({
title: 'Add New User',
submitText: 'Create User',
});
if (data) {
console.log('User data:', data);
// Send data to server
}
}
function handleCustomModal() {
openModal(CustomModal, {
title: 'Custom Modal',
content: 'This is a custom modal component',
});
}
</script>
<div class="p-8 space-y-4">
<h1 class="text-3xl font-bold mb-6">Modal System Demo</h1>
<div class="flex gap-4">
<button class="btn btn-error" on:click={handleDelete}>
Delete Item
</button>
<button class="btn btn-primary" on:click={handleAddUser}>
Add User
</button>
<button class="btn btn-secondary" on:click={handleCustomModal}>
Custom Modal
</button>
</div>
</div>
This example demonstrates a complete modal management system with:
- Centralized state management through Svelte stores
- Modal stack support (nested modals)
- Automatic focus management
- Promise-based API for confirm and form modals
- Full accessibility (ARIA attributes, keyboard navigation)
- Easy extension through custom components
Common Issues / Troubleshooting
-
Modal doesn't open
- Ensure Tailwind CSS is properly configured and daisyUI plugin is connected
- Check that
modalandmodal-boxclasses are applied correctly - Make sure you're using the
showModal()method for the<dialog>element
-
Focus is not trapped inside modal
- Ensure you're using the
<dialog>element, not just a div with classes - Check that the modal has the
aria-modal="true"attribute - Make sure there are no other elements with
tabindexoutside the modal
- Ensure you're using the
-
Nested modals don't work correctly
- Check that each modal has a unique ID
- Ensure only the last modal in the stack has the
modal-openclass - Check the close logic — only the last modal should close on ESC
-
Styles are not applied
- Ensure daisyUI is properly configured in
tailwind.config.js - Check that the CSS file with Tailwind directives is imported
- Make sure the paths in the
contentarray include all your components
- Ensure daisyUI is properly configured in
Next Steps
- Explore other daisyUI components: Explore dropdown, drawer, toast, and other components to extend functionality
- Add animations: Use Svelte transitions for smooth open/close animations
- Integrate with routing: Link modals to URL parameters for browser history support
- Add testing: Write tests for the modal system using Testing Library
- Optimize performance: Use lazy loading for modal components
Summary
In this article, we created an advanced modal management system using daisyUI and Svelte. The system includes centralized state management through Svelte stores, nested modal support, automatic focus management, and full accessibility. You can now use this system to create complex interfaces with modals in your Svelte applications.
Top comments (0)