DEV Community

Ethan Walker
Ethan Walker

Posted on

Building Advanced Modal Systems with State Management in daisyUI and Svelte

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
Enter fullscreen mode Exit fullscreen mode

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,
  },
}
Enter fullscreen mode Exit fullscreen mode

Add Tailwind directives to your main CSS file:

/* src/app.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
Enter fullscreen mode Exit fullscreen mode

Import the CSS in your layout:

<!-- src/routes/+layout.svelte -->
<script>
  import '../app.css';
</script>

<slot />
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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:

  1. Checkbox-based modals — use a hidden checkbox to control state
  2. 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();
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
<!-- 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>
Enter fullscreen mode Exit fullscreen mode

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 }
    );
  });
}
Enter fullscreen mode Exit fullscreen mode

Add ModalContainer to the root layout:

<!-- src/routes/+layout.svelte -->
<script>
  import '../app.css';
  import ModalContainer from '$lib/ModalContainer.svelte';
</script>

<slot />
<ModalContainer />
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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

  1. Modal doesn't open

    • Ensure Tailwind CSS is properly configured and daisyUI plugin is connected
    • Check that modal and modal-box classes are applied correctly
    • Make sure you're using the showModal() method for the <dialog> element
  2. 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 tabindex outside the modal
  3. 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-open class
    • Check the close logic — only the last modal should close on ESC
  4. 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 content array include all your components

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)