构建无障碍对话框
使用 Headless UI 的 Dialog 组件创建模态框,利用内置的焦点限制和 Esc 键处理功能,并完全使用 Tailwind 设置样式。
构建无障碍对话框 是 CoddyKit 上的免费 Tailwind CSS Academy 课时。 这是第 3 节课,共 4 节。 你可以在下方免费阅读本课时的完整内容 — 然后在浏览器中使用内置代码编辑器和全天候 AI 导师进行实践。 这是 Tailwind CSS Academy 学习路径的一部分,你的进度在网页和 CoddyKit 应用中同步。 Tailwind CSS Academy 课程共包含 4 节课。
本课时的部分内容尚未翻译,以英文显示。
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.
常见问题解答
「构建无障碍对话框」课时是免费的吗?
是的 — 「构建无障碍对话框」的完整文本可在网页上免费阅读。要进行交互式练习(内置代码编辑器和全天候 AI 导师)并解锁 Tailwind CSS Academy 课程的其余内容,请升级到 CoddyKit PRO。 Tailwind CSS Academy 课程共包含 4 节课。
「构建无障碍对话框」这节课中我会学到什么?
使用 Headless UI 的 Dialog 组件创建模态框,利用内置的焦点限制和 Esc 键处理功能,并完全使用 Tailwind 设置样式。 你通过在浏览器中直接运行的动手代码来练习 Tailwind CSS Academy,全天候 AI 导师会在你学习这节课的过程中回答你的问题。
学习 Tailwind CSS Academy 需要有经验吗?
无需任何先前经验。CoddyKit 上的 Tailwind CSS Academy 课程适合初学者到高级学习者,你可以从这里开始或从头开始,按照自己的节奏学习。 这是第 3 节课,共 4 节。
「构建无障碍对话框」课时需要多长时间?
大多数 CoddyKit 课程大约需要 5–10 分钟。每节课都很精短且互动,所以你能稳步进步,并在网页和应用中从离开的地方继续。
我能在这节 Tailwind CSS Academy 课中编写并运行代码吗?
能。每节 Tailwind CSS Academy 课都包含内置代码编辑器,你可以在浏览器中直接编写并运行真实代码,并获得即时 AI 反馈 — 无需本地设置。