DEV Community

Ethan Walker
Ethan Walker

Posted on

Building Accessible Dialog Components with Melt UI in Svelte

Melt UI is a headless, accessible component library for Svelte that provides unstyled UI primitives following WAI-ARIA guidelines. It enables developers to build custom-styled components with robust interactive behavior, keyboard navigation, and accessibility features built-in. This article covers building production-ready dialog components with custom actions, focus management, and animations using Melt UI's Dialog builder. This is part 9 of a series on using Melt UI with Svelte.

This guide walks through creating accessible modal dialogs with complete keyboard navigation, focus trapping, escape key handling, and custom styling using Melt UI's createDialog builder in Svelte.

Prerequisites

Before starting, ensure you have:

  • A Svelte project (SvelteKit or standalone Svelte 4+)
  • Node.js 18+ and npm/pnpm/yarn installed
  • Basic understanding of Svelte reactivity, stores, and component lifecycle
  • Familiarity with HTML accessibility concepts (ARIA attributes, focus management)

Key concepts to understand:

  • Builder pattern: Melt UI uses builders that return stores and element configurations you spread onto your HTML elements
  • Melt action: The melt action attaches the builder's behavior (event handlers, ARIA attributes) to your elements
  • Focus trapping: Automatically keeps keyboard focus within the dialog when it's open
  • Portal rendering: Dialogs are typically rendered outside the normal DOM flow to avoid z-index issues

Installation

Install Melt UI for Svelte using your preferred package manager:

npm install @melt-ui/svelte
# or
pnpm add @melt-ui/svelte
# or
yarn add @melt-ui/svelte
Enter fullscreen mode Exit fullscreen mode

The package includes all builders including the Dialog builder. No additional dependencies are required.

Project Setup

After installation, you can start using Melt UI builders immediately. No global configuration is needed. The library works with any styling solution (CSS, Tailwind, CSS-in-JS, etc.).

For TypeScript projects, types are included automatically. If you're using JavaScript, the library still provides excellent IntelliSense support.

First Example / Basic Usage

Let's start with the simplest possible working dialog:

<!-- src/lib/SimpleDialog.svelte -->
<script>
  import { createDialog, melt } from '@melt-ui/svelte';

  const {
    elements: { overlay, content, title, description, close },
    states: { open }
  } = createDialog();
</script>

<button on:click={() => $open = true}>
  Open Dialog
</button>

{#if $open}
  <div use:melt={$overlay} class="overlay">
    <div use:melt={$content} class="dialog">
      <h2 use:melt={$title}>Dialog Title</h2>
      <p use:melt={$description}>This is the dialog content.</p>
      <button use:melt={$close}>Close</button>
    </div>
  </div>
{/if}

<style>
  .overlay {
    position: fixed;
    inset: 0;
    background: rgba(0, 0, 0, 0.5);
    z-index: 50;
    display: flex;
    align-items: center;
    justify-content: center;
  }

  .dialog {
    background: white;
    padding: 1.5rem;
    border-radius: 0.5rem;
    box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
    max-width: 90vw;
    max-height: 90vh;
  }
</style>
Enter fullscreen mode Exit fullscreen mode

What's happening here:

  • createDialog() returns element stores and state stores
  • $overlay and $content are element stores that contain ARIA attributes and event handlers
  • The melt action spreads these attributes and handlers onto your elements
  • $open is a reactive store that tracks the dialog's open/closed state
  • The dialog automatically handles Escape key, focus trapping, and click-outside-to-close

Understanding the Basics

Melt UI's Dialog builder provides several key elements:

  • overlay: The backdrop that covers the screen
  • content: The dialog container
  • title: The dialog title (required for accessibility)
  • description: Optional descriptive text
  • close: A button element that closes the dialog

The builder automatically:

  • Manages focus (traps focus inside, returns focus on close)
  • Handles keyboard events (Escape to close)
  • Applies proper ARIA attributes (role="dialog", aria-modal, etc.)
  • Prevents body scrolling when open
  • Handles click-outside-to-close behavior

Here's a more complete example with all elements:

<!-- src/lib/CompleteDialog.svelte -->
<script>
  import { createDialog, melt } from '@melt-ui/svelte';

  const {
    elements: { overlay, content, title, description, close },
    states: { open }
  } = createDialog();
</script>

<button on:click={() => $open = true}>
  Show Complete Dialog
</button>

{#if $open}
  <div use:melt={$overlay} class="overlay">
    <div use:melt={$content} class="dialog">
      <h2 use:melt={$title} class="title">
        Confirm Action
      </h2>
      <p use:melt={$description} class="description">
        Are you sure you want to proceed? This action cannot be undone.
      </p>
      <div class="actions">
        <button use:melt={$close} class="button secondary">
          Cancel
        </button>
        <button 
          class="button primary"
          on:click={() => {
            console.log('Action confirmed');
            $open = false;
          }}
        >
          Confirm
        </button>
      </div>
    </div>
  </div>
{/if}

<style>
  .overlay {
    position: fixed;
    inset: 0;
    background: rgba(0, 0, 0, 0.5);
    z-index: 50;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 1rem;
  }

  .dialog {
    background: white;
    padding: 1.5rem;
    border-radius: 0.5rem;
    box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
    max-width: 500px;
    width: 100%;
  }

  .title {
    margin: 0 0 0.5rem 0;
    font-size: 1.5rem;
    font-weight: 600;
  }

  .description {
    margin: 0 0 1.5rem 0;
    color: #666;
  }

  .actions {
    display: flex;
    gap: 0.75rem;
    justify-content: flex-end;
  }

  .button {
    padding: 0.5rem 1rem;
    border-radius: 0.375rem;
    border: none;
    cursor: pointer;
    font-weight: 500;
    transition: background-color 0.2s;
  }

  .button.primary {
    background: #3b82f6;
    color: white;
  }

  .button.primary:hover {
    background: #2563eb;
  }

  .button.secondary {
    background: #e5e7eb;
    color: #374151;
  }

  .button.secondary:hover {
    background: #d1d5db;
  }
</style>
Enter fullscreen mode Exit fullscreen mode

Practical Example / Building Something Real

Let's build a production-ready user creation dialog with form validation, loading states, and animations:

<!-- src/lib/UserCreationDialog.svelte -->
<script>
  import { createDialog, melt } from '@melt-ui/svelte';
  import { fade, fly } from 'svelte/transition';

  const {
    elements: { overlay, content, title, description, close },
    states: { open }
  } = createDialog({
    closeOnOutsideClick: false, // Prevent accidental closes during form filling
    closeOnEscape: true
  });

  let formData = {
    name: '',
    email: '',
    role: 'user'
  };

  let errors = {};
  let isSubmitting = false;

  function validateForm() {
    errors = {};

    if (!formData.name.trim()) {
      errors.name = 'Name is required';
    }

    if (!formData.email.trim()) {
      errors.email = 'Email is required';
    } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
      errors.email = 'Please enter a valid email address';
    }

    return Object.keys(errors).length === 0;
  }

  async function handleSubmit() {
    if (!validateForm()) {
      return;
    }

    isSubmitting = true;

    try {
      // Simulate API call
      await new Promise(resolve => setTimeout(resolve, 1500));
      console.log('User created:', formData);

      // Reset form and close dialog
      formData = { name: '', email: '', role: 'user' };
      errors = {};
      $open = false;
    } catch (error) {
      console.error('Error creating user:', error);
    } finally {
      isSubmitting = false;
    }
  }

  function handleClose() {
    if (!isSubmitting) {
      formData = { name: '', email: '', role: 'user' };
      errors = {};
      $open = false;
    }
  }
</script>

<button 
  on:click={() => $open = true}
  class="trigger-button"
>
  Add New User
</button>

{#if $open}
  <div 
    use:melt={$overlay}
    transition:fade={{ duration: 200 }}
    class="overlay"
    on:click={handleClose}
  >
    <div 
      use:melt={$content}
      transition:fly={{ y: -20, duration: 200 }}
      class="dialog"
      on:click|stopPropagation
    >
      <h2 use:melt={$title} class="title">
        Create New User
      </h2>
      <p use:melt={$description} class="description">
        Fill in the details to create a new user account.
      </p>

      <form on:submit|preventDefault={handleSubmit} class="form">
        <div class="form-group">
          <label for="name" class="label">
            Full Name <span class="required">*</span>
          </label>
          <input
            id="name"
            type="text"
            bind:value={formData.name}
            class="input"
            class:error={errors.name}
            placeholder="John Doe"
            disabled={isSubmitting}
          />
          {#if errors.name}
            <span class="error-message">{errors.name}</span>
          {/if}
        </div>

        <div class="form-group">
          <label for="email" class="label">
            Email Address <span class="required">*</span>
          </label>
          <input
            id="email"
            type="email"
            bind:value={formData.email}
            class="input"
            class:error={errors.email}
            placeholder="john@example.com"
            disabled={isSubmitting}
          />
          {#if errors.email}
            <span class="error-message">{errors.email}</span>
          {/if}
        </div>

        <div class="form-group">
          <label for="role" class="label">Role</label>
          <select
            id="role"
            bind:value={formData.role}
            class="input"
            disabled={isSubmitting}
          >
            <option value="user">User</option>
            <option value="admin">Administrator</option>
            <option value="moderator">Moderator</option>
          </select>
        </div>

        <div class="form-actions">
          <button
            type="button"
            use:melt={$close}
            on:click={handleClose}
            class="button secondary"
            disabled={isSubmitting}
          >
            Cancel
          </button>
          <button
            type="submit"
            class="button primary"
            disabled={isSubmitting}
          >
            {#if isSubmitting}
              Creating...
            {:else}
              Create User
            {/if}
          </button>
        </div>
      </form>
    </div>
  </div>
{/if}

<style>
  .trigger-button {
    padding: 0.5rem 1rem;
    background: #3b82f6;
    color: white;
    border: none;
    border-radius: 0.375rem;
    cursor: pointer;
    font-weight: 500;
  }

  .trigger-button:hover {
    background: #2563eb;
  }

  .overlay {
    position: fixed;
    inset: 0;
    background: rgba(0, 0, 0, 0.6);
    z-index: 50;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 1rem;
  }

  .dialog {
    background: white;
    border-radius: 0.75rem;
    box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
    max-width: 500px;
    width: 100%;
    max-height: 90vh;
    overflow-y: auto;
  }

  .title {
    margin: 0;
    padding: 1.5rem 1.5rem 0.5rem 1.5rem;
    font-size: 1.5rem;
    font-weight: 600;
    color: #111827;
  }

  .description {
    margin: 0 0 1.5rem 0;
    padding: 0 1.5rem;
    color: #6b7280;
    font-size: 0.875rem;
  }

  .form {
    padding: 0 1.5rem 1.5rem 1.5rem;
  }

  .form-group {
    margin-bottom: 1.25rem;
  }

  .label {
    display: block;
    margin-bottom: 0.5rem;
    font-size: 0.875rem;
    font-weight: 500;
    color: #374151;
  }

  .required {
    color: #ef4444;
  }

  .input {
    width: 100%;
    padding: 0.5rem 0.75rem;
    border: 1px solid #d1d5db;
    border-radius: 0.375rem;
    font-size: 1rem;
    transition: border-color 0.2s, box-shadow 0.2s;
  }

  .input:focus {
    outline: none;
    border-color: #3b82f6;
    box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
  }

  .input.error {
    border-color: #ef4444;
  }

  .input:disabled {
    background: #f3f4f6;
    cursor: not-allowed;
  }

  .error-message {
    display: block;
    margin-top: 0.25rem;
    font-size: 0.875rem;
    color: #ef4444;
  }

  .form-actions {
    display: flex;
    gap: 0.75rem;
    justify-content: flex-end;
    margin-top: 1.5rem;
  }

  .button {
    padding: 0.5rem 1rem;
    border-radius: 0.375rem;
    border: none;
    cursor: pointer;
    font-weight: 500;
    font-size: 0.875rem;
    transition: background-color 0.2s;
  }

  .button:disabled {
    opacity: 0.6;
    cursor: not-allowed;
  }

  .button.primary {
    background: #3b82f6;
    color: white;
  }

  .button.primary:hover:not(:disabled) {
    background: #2563eb;
  }

  .button.secondary {
    background: #f3f4f6;
    color: #374151;
  }

  .button.secondary:hover:not(:disabled) {
    background: #e5e7eb;
  }
</style>
Enter fullscreen mode Exit fullscreen mode

Key features of this implementation:

  • Form validation with error messages
  • Loading state during submission
  • Prevents closing during submission
  • Smooth animations using Svelte transitions
  • Proper focus management (automatically handled by Melt UI)
  • Accessible form labels and error announcements
  • Responsive design with max-width constraints

Advanced Configuration Options

Melt UI's createDialog accepts several configuration options:

<script>
  import { createDialog, melt } from '@melt-ui/svelte';
  import { writable } from 'svelte/store';

  // External state control
  const externalOpen = writable(false);

  const {
    elements: { overlay, content, title, close },
    states: { open }
  } = createDialog({
    open: externalOpen,              // Control dialog from external store
    closeOnOutsideClick: true,      // Close when clicking overlay
    closeOnEscape: true,            // Close on Escape key
    preventScroll: true,            // Prevent body scrolling when open
    closeFocus: null,               // Element to focus when closing (null = trigger)
    openFocus: undefined,           // Element to focus when opening (undefined = first focusable)
    onOpenChange: (open) => {       // Callback when open state changes
      console.log('Dialog is now:', open ? 'open' : 'closed');
    }
  });
</script>
Enter fullscreen mode Exit fullscreen mode

Common Issues / Troubleshooting

Dialog doesn't close on Escape key

  • Ensure the melt action is applied to both overlay and content elements
  • Check that closeOnEscape: true is set (it's true by default)
  • Verify the dialog is conditionally rendered with {#if $open}

Focus not trapped inside dialog

  • Make sure melt action is applied to the content element
  • The builder automatically handles focus trapping - don't manually manage focus
  • Ensure no other focus management code interferes (remove manual focus() calls)

Overlay not covering the entire screen

  • Add CSS: position: fixed; inset: 0; z-index: 50; to the overlay element
  • Check for parent elements with overflow: hidden or position: relative that might clip the overlay

Dialog content not centered

  • Use flexbox on overlay: display: flex; align-items: center; justify-content: center;
  • Or use CSS Grid: display: grid; place-items: center;

Body scroll not prevented

  • Ensure preventScroll: true is set (it's true by default)
  • Check that the overlay is rendered at the root level, not inside a scrollable container

Next Steps

Now that you understand how to build accessible dialogs with Melt UI, consider exploring:

  • Nested dialogs: Creating dialogs that open other dialogs
  • Alert dialogs: Using the AlertDialog builder for confirmation dialogs
  • Custom animations: Implementing more complex transitions and animations
  • Form integration: Building reusable dialog components with form libraries
  • State management: Integrating dialogs with global state management solutions
  • Testing: Writing tests for dialog components with Svelte Testing Library

For more advanced patterns, check out other articles in this series covering:

  • Building data tables with Melt UI
  • Creating accessible form components
  • Implementing navigation menus and dropdowns

Additional Resources:

Summary

This guide walked through building accessible dialog components with Melt UI in Svelte. You learned how to use the createDialog builder to create modal dialogs with automatic focus management, keyboard navigation, and ARIA compliance. You should now be able to create production-ready dialogs with custom styling, form integration, and advanced configurations.

Top comments (0)