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
meltaction 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
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>
What's happening here:
-
createDialog()returns element stores and state stores -
$overlayand$contentare element stores that contain ARIA attributes and event handlers - The
meltaction spreads these attributes and handlers onto your elements -
$openis 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>
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>
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>
Common Issues / Troubleshooting
Dialog doesn't close on Escape key
- Ensure the
meltaction is applied to bothoverlayandcontentelements - Check that
closeOnEscape: trueis set (it's true by default) - Verify the dialog is conditionally rendered with
{#if $open}
Focus not trapped inside dialog
- Make sure
meltaction is applied to thecontentelement - 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: hiddenorposition: relativethat 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: trueis 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:
- Melt UI Documentation
- Melt UI GitHub Repository
- WAI-ARIA Dialog Pattern
- Svelte Transitions Documentation
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)