Building Accessible Dialogs
Use Headless UI's Dialog component for modals with built-in focus trapping and escape key handling, styled entirely with Tailwind.
Building Accessible Dialogs is a free Tailwind CSS Academy lesson on CoddyKit — lesson 3 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the Tailwind CSS Academy learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.
What Makes a Dialog Accessible?
An accessible dialog (modal) must satisfy several requirements: it must receive keyboard focus when opened, trap focus within itself so users cannot Tab outside, be dismissable with the Escape key, apply correct ARIA attributes (role='dialog', aria-modal='true'), and return focus to the trigger element when closed. These requirements are complex to implement correctly. Headless UI's Dialog component handles all of them automatically.
Basic Dialog Structure
The Headless UI Dialog composes three key elements: Dialog (root, handles ARIA and focus), Dialog.Panel (the visible modal box), and optionally Dialog.Title and Dialog.Description (for semantic labeling). The open prop controls visibility, and onClose fires when the user presses Escape or clicks outside the panel — you decide what action to take (typically setting open state to false).
import { Dialog } from '@headlessui/react';
import { useState } from 'react';
function AlertDialog() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>Open Dialog</button>
<Dialog open={open} onClose={() => setOpen(false)}>
<Dialog.Panel>
<Dialog.Title>Alert</Dialog.Title>
<Dialog.Description>This is an important message.</Dialog.Description>
<button onClick={() => setOpen(false)}>Close</button>
</Dialog.Panel>
</Dialog>
</>
);
}Adding the Backdrop Overlay
A modal should dim the page content behind it to draw the user's attention to the dialog. Add a full-screen backdrop using fixed inset-0 with a semi-transparent background. Place it as the first child of Dialog, before the panel container. Use aria-hidden='true' on the backdrop since it is purely decorative — screen readers should not announce it.
<Dialog open={open} onClose={() => setOpen(false)} className='relative z-50'>
{/* Backdrop */}
<div
className='fixed inset-0 bg-black/50 backdrop-blur-sm'
aria-hidden='true'
/>
{/* Panel container — centers the dialog */}
<div className='fixed inset-0 flex items-center justify-center p-4'>
<Dialog.Panel className='bg-white rounded-2xl shadow-2xl max-w-md w-full'>
{/* Dialog content */}
</Dialog.Panel>
</div>
</Dialog>Styling the Dialog Panel
The Dialog.Panel is the visible modal container. Apply Tailwind classes for background, border radius, shadow, padding, and max-width to create a polished card. The panel should have a max-w-* constraint so it does not stretch full-width on large screens, while remaining responsive on small screens with w-full. Add a close button in the top-right corner for mouse users who prefer clicking over pressing Escape.
<Dialog.Panel className='relative bg-white rounded-2xl shadow-2xl max-w-lg w-full p-6'>
{/* Close button */}
<button
onClick={() => setOpen(false)}
className='absolute top-4 right-4 rounded-full p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600'
>
<XMarkIcon className='h-5 w-5' />
<span className='sr-only'>Close</span>
</button>
{/* Header */}
<Dialog.Title className='text-lg font-semibold text-gray-900 pr-8'>
Delete Project
</Dialog.Title>
<Dialog.Description className='mt-2 text-sm text-gray-600'>
This action cannot be undone. All project data will be permanently removed.
</Dialog.Description>
{/* Actions */}
<div className='mt-6 flex gap-3 justify-end'>
<button onClick={() => setOpen(false)}
className='px-4 py-2 text-sm font-medium rounded-lg border border-gray-300 hover:bg-gray-50'>
Cancel
</button>
<button
className='px-4 py-2 text-sm font-medium rounded-lg bg-red-600 text-white hover:bg-red-700'>
Delete
</button>
</div>
</Dialog.Panel>Scrollable Dialog for Long Content
Dialogs with long content — like terms of service, form wizards, or detailed previews — need to be scrollable without the backdrop scrolling. Apply overflow-y-auto to the panel and a max-h-* constraint so the dialog does not grow beyond the viewport. The outer centering container should be items-start with top padding to keep the dialog near the top of the screen for very long content.
// Scrollable dialog for long content
<div className='fixed inset-0 overflow-y-auto'>
<div className='flex min-h-full items-start justify-center p-4 pt-16'>
<Dialog.Panel
className='
bg-white rounded-2xl shadow-xl
max-w-2xl w-full
max-h-[80vh] overflow-y-auto
'
>
<div className='sticky top-0 bg-white border-b border-gray-100 px-6 py-4 z-10'>
<Dialog.Title className='text-lg font-semibold'>Terms of Service</Dialog.Title>
</div>
<div className='px-6 py-4 prose prose-sm'>
{/* Long content */}
</div>
<div className='sticky bottom-0 bg-white border-t border-gray-100 px-6 py-4'>
<button className='w-full bg-blue-600 text-white rounded-lg py-2'>Accept</button>
</div>
</Dialog.Panel>
</div>
</div>Dialog Size Variants
Build reusable dialog size variants using Tailwind's max-w-* utilities. Small dialogs for confirmations, medium for forms, and large for previews or multi-step wizards. Create a DialogModal component that accepts a size prop and applies the corresponding max-width class — this is a natural use case for the CVA (class-variance-authority) pattern.
const panelSizes = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-2xl',
full: 'max-w-5xl'
};
function DialogModal({ open, onClose, size = 'md', title, description, children }) {
return (
<Dialog open={open} onClose={onClose} className='relative z-50'>
<div className='fixed inset-0 bg-black/50' aria-hidden='true' />
<div className='fixed inset-0 flex items-center justify-center p-4'>
<Dialog.Panel
className={cn(
'bg-white rounded-2xl shadow-xl w-full p-6',
panelSizes[size]
)}
>
{title && <Dialog.Title className='text-lg font-semibold'>{title}</Dialog.Title>}
{description && <Dialog.Description className='mt-1 text-sm text-gray-600'>{description}</Dialog.Description>}
<div className='mt-4'>{children}</div>
</Dialog.Panel>
</div>
</Dialog>
);
}Focus Management in Practice
Headless UI automatically moves focus into the dialog when it opens. By default, focus goes to the first focusable element inside the panel. To direct focus to a specific element — like a primary CTA or a text input — use the initialFocus prop with a React ref pointing to that element. This improves the user experience for keyboard and screen reader users who immediately need to interact with a specific control.
import { Dialog } from '@headlessui/react';
import { useRef } from 'react';
function DeleteConfirm({ open, onClose, onDelete }) {
const cancelButtonRef = useRef(null);
return (
<Dialog
open={open}
onClose={onClose}
initialFocus={cancelButtonRef} // focus Cancel by default (safer)
>
{/* ...backdrop... */}
<div className='fixed inset-0 flex items-center justify-center p-4'>
<Dialog.Panel className='bg-white rounded-2xl p-6 max-w-sm w-full shadow-xl'>
<Dialog.Title className='font-semibold text-gray-900'>Delete?</Dialog.Title>
<div className='mt-4 flex gap-3 justify-end'>
{/* initialFocus lands here */}
<button ref={cancelButtonRef} onClick={onClose}
className='px-4 py-2 text-sm border rounded-lg'>
Cancel
</button>
<button onClick={onDelete}
className='px-4 py-2 text-sm bg-red-600 text-white rounded-lg'>
Delete
</button>
</div>
</Dialog.Panel>
</div>
</Dialog>
);
}Nested Dialog Stacking
Sometimes a dialog triggers another dialog — like a confirmation within a settings modal. Use incrementally higher z-index values for nested dialogs so they layer correctly. Each dialog manages its own focus trap independently; Headless UI supports multiple open dialogs simultaneously. Use state variables for each dialog level and close them in reverse order.
function SettingsModal({ open, onClose }) {
const [confirmOpen, setConfirmOpen] = useState(false);
return (
<>
{/* Primary dialog — z-40 */}
<Dialog open={open} onClose={onClose} className='relative z-40'>
<div className='fixed inset-0 bg-black/40' aria-hidden='true' />
<div className='fixed inset-0 flex items-center justify-center p-4'>
<Dialog.Panel className='bg-white rounded-2xl p-6 max-w-lg w-full shadow-xl'>
<h2 className='font-semibold text-lg'>Settings</h2>
<button onClick={() => setConfirmOpen(true)}
className='mt-4 text-red-600 text-sm'>
Reset all settings
</button>
</Dialog.Panel>
</div>
</Dialog>
{/* Nested confirmation — z-50 (higher) */}
<Dialog open={confirmOpen} onClose={() => setConfirmOpen(false)} className='relative z-50'>
{/* ... */}
</Dialog>
</>
);
}Preventing Background Scroll
When a dialog is open, the page content behind it should not scroll. Headless UI does not handle this automatically. Add a side effect that adds overflow-hidden to the body when the dialog opens and removes it when it closes. In a Next.js or React app, use a useEffect inside the dialog component or a custom hook that cleans up correctly on unmount.
import { useEffect } from 'react';
function useBodyScrollLock(isLocked) {
useEffect(() => {
if (isLocked) {
document.body.classList.add('overflow-hidden');
} else {
document.body.classList.remove('overflow-hidden');
}
// Cleanup on unmount
return () => document.body.classList.remove('overflow-hidden');
}, [isLocked]);
}
// Usage in dialog component
function MyDialog({ open, onClose }) {
useBodyScrollLock(open);
return (
<Dialog open={open} onClose={onClose}>
{/* ... */}
</Dialog>
);
}Dialog Accessibility Checklist
Before shipping a dialog component, verify it meets accessibility requirements: focus moves into the dialog on open, focus is trapped inside while open, Escape dismisses the dialog, clicking the backdrop dismisses the dialog, focus returns to the trigger element on close, screen readers announce the dialog title, and all interactive elements inside are keyboard reachable. Headless UI handles most of these — verify focus return and backdrop click behavior in your implementation.
/*
Dialog Accessibility Checklist:
[✓] Focus enters dialog on open (Headless UI automatic)
[✓] Focus trapped inside while open (Headless UI automatic)
[✓] Escape key closes dialog (Headless UI automatic)
[✓] role='dialog' + aria-modal='true' (Headless UI automatic)
[✓] Dialog.Title used for aria-labelledby (Headless UI automatic)
[✓] Dialog.Description for aria-describedby (Headless UI automatic)
[ ] Focus returns to trigger on close → store triggerRef
[ ] Backdrop click closes dialog → pass handler to onClose
[ ] Body scroll locked while open → useBodyScrollLock hook
[ ] Close button has visible label or aria-label
*/Dialog Variants: Alert vs Confirm vs Form
Dialogs serve different purposes and should be designed accordingly. An alert dialog delivers urgent information with a single acknowledgement button — use role='alertdialog' for these. A confirmation dialog asks a yes/no question before a destructive action, with Cancel as the default focus. A form dialog contains a complete form with validation. Each type has different size, focus, and button order conventions that help users quickly understand what is expected of them.
<!-- Alert dialog: urgent info, single action -->
<Dialog.Panel class='bg-white rounded-2xl p-6 max-w-sm shadow-xl'>
<div class='flex items-start gap-4'>
<div class='flex-shrink-0 w-10 h-10 rounded-full bg-red-100 flex items-center justify-center'>
<ExclamationTriangleIcon class='h-5 w-5 text-red-600' />
</div>
<div>
<Dialog.Title class='text-base font-semibold text-gray-900'>Session Expired</Dialog.Title>
<Dialog.Description class='mt-1 text-sm text-gray-600'>
Your session has expired. Please log in again.
</Dialog.Description>
<button class='mt-4 w-full bg-blue-600 text-white rounded-lg py-2 text-sm font-medium'>
Log In
</button>
</div>
</div>
</Dialog.Panel>Quick Check
Test your understanding of Tailwind CSS Mastery concepts from this lesson.
Lesson Recap
In this lesson you learned: Headless UI Dialog handles focus trapping, Escape dismissal, and ARIA roles automatically, the backdrop is a full-screen fixed overlay positioned before the panel, and initialFocus directs keyboard focus to a specific element on open. Next up we animate dialog open and close transitions using Headless UI's Transition component.
Frequently asked questions
Is the “Building Accessible Dialogs” lesson free?
Yes — the full text of “Building Accessible Dialogs” is free to read here on the web, and the Tailwind CSS Academy course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the Tailwind CSS Academy course, upgrade to CoddyKit PRO.
What will I learn in “Building Accessible Dialogs”?
Use Headless UI's Dialog component for modals with built-in focus trapping and escape key handling, styled entirely with Tailwind. You practise Tailwind CSS Academy with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.
Do I need any experience to start Tailwind CSS Academy?
No prior experience is required. Tailwind CSS Academy on CoddyKit is structured for beginners through advanced learners; this is — lesson 3 of 4, so you can start here or from the beginning and move at your own pace.
How long does the “Building Accessible Dialogs” lesson take?
Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.
Can I write and run code in this Tailwind CSS Academy lesson?
Yes. Every Tailwind CSS Academy lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.
All lessons in this course
- Introduction to Headless UI
- Styling Headless Menu and Dropdown
- Building Accessible Dialogs
- Transitions With Headless UI