Modals, Toasts & Overlays
Overlays are where most AI-generated UIs fail under real-world usage. A modal that doesn’t trap focus will let screen reader users wander behind it. A toast that fires twice from a double-click is annoying. A drawer that doesn’t close on ESC frustrates keyboard users. This lesson shows you the prompts that prevent all of that.
Modal dialog
The minimum viable prompt for an accessible modal has five requirements: focus trap, ESC close, backdrop click close, aria-modal="true", and aria-labelledby pointing to the heading.
Build a modal dialog triggered by a button.
- Backdrop: rgba(0,0,0,0.4), clicking it closes the modal
- Content card: white, border-radius 12px, max-width 440px, centered
- Buttons: Cancel (ghost) and Confirm (primary)
- Accessibility: aria-modal="true", aria-labelledby pointing to the h2
- Close on ESC key
- Focus trap: Tab/Shift+Tab cycles only within the modal while open
- Restore focus to the trigger button on close
- Animate in: scale(0.96) → scale(1) + opacity 0 → 1, 220ms ease
The focus trap pattern is the trickiest part. The standard approach is to query all focusable elements inside the modal, then intercept Tab keydown to redirect from the last element back to the first (and Shift+Tab from first to last).
Toast notification system
Toasts need a stacking container, four visual types, auto-dismiss, and manual dismiss. The most important prompt detail is the slide-in direction and the stacking behavior.
Build a toast notification system:
- Fixed container: top-right, z-index 300, column flex with gap
- 4 types: success (green), error (red), warning (amber), info (blue)
- Each toast: white bg, 1px border, left 3px solid matching color, icon + title + message
- Slide in from right (translateX(100%) → translateX(0)), opacity 0 → 1
- Auto-dismiss after 3 seconds, manual dismiss button (×)
- Toasts stack — newer ones appear above older ones
- aria-live="polite" on the container for screen reader announcements
Key pattern — busy flag: If you have a button that triggers a toast on click, make sure rapid clicks don’t create a flood. A simple let isSending = false flag with a setTimeout reset is enough. In the demo the buttons create a new toast each click, which is intentional for testing — but in production you’d gate this.
Drawer / side panel
A drawer is essentially a modal that lives at the edge of the screen. Same accessibility requirements — focus trap, ESC, aria-modal — but the animation is horizontal instead of scale.
Build a slide-in drawer from the right side.
- Width: 320px (full width on mobile)
- Overlay backdrop: rgba(0,0,0,0.4), closes drawer on click
- Drawer: white bg, box-shadow on the left edge, transform translateX(100%) → translateX(0)
- Header: title on left, × close button on right
- Body: a settings form (text input, email input, two toggle switches)
- Close on: × button, ESC key, backdrop click
- Focus trap while open, restore focus on close
- Transition: 300ms ease
What the demo shows
The live demo has three sections. Click “Open Modal” to see the dialog with focus trap — try tabbing through it and pressing ESC. Click the four toast buttons to create stacking notifications and watch them slide in and auto-dismiss. Click “Open Drawer” to see the settings panel slide in from the right with its form and toggle switches. All three patterns are fully keyboard accessible.
모달, 토스트, 오버레이는 올바르게 구현하기 복잡합니다 — 포커스 트랩, ESC 닫기, 배경 스크롤 잠금, 접근성. Claude Code에 모든 것을 명세하면 한 번에 올바르게 만들 수 있습니다.
완전한 모달 프롬프트
접근 가능한 모달 다이얼로그를 만들어줘:
- 반투명 배경 오버레이 (rgba(0,0,0,0.5))
- 흰 카드, max-width 480px, border-radius 12px
- 닫기 버튼 (×), ESC로 닫기, 배경 클릭으로 닫기
- 열릴 때 포커스 트랩 (Tab이 모달 내에서만 순환)
- 열릴 때 body 스크롤 잠금
- aria-modal="true", role="dialog", aria-labelledby
토스트 알림
토스트 알림 시스템을 만들어줘:
- 화면 오른쪽 하단에 쌓임
- success/error/info 세 가지 타입
- 3초 후 자동 dismissal + 수동 닫기
- 슬라이드 인/아웃 애니메이션
- 여러 토스트를 쌓아서 표시
데모는 완전한 포커스 트랩을 갖춘 모달과 큐 관리가 있는 토스트 시스템을 보여줍니다.
/* Modals, Toasts & Overlays — script.js
Implements modal dialog (focus trap + ESC), toast stack, and drawer.
*/
/* ── Utilities ── */
/** Get all focusable elements within a container */
function getFocusable(container) {
return Array.from(
container.querySelectorAll(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
)
);
}
/* ── Modal ── */
const modalBackdrop = document.getElementById('modal-backdrop');
const modal = document.getElementById('modal');
const openModalBtn = document.getElementById('open-modal-btn');
const modalCancel = document.getElementById('modal-cancel');
const modalConfirm = document.getElementById('modal-confirm');
let modalPrevFocus = null;
let modalOpen = false;
function openModal() {
if (modalOpen) return;
modalOpen = true;
modalPrevFocus = document.activeElement;
modalBackdrop.classList.add('is-open');
modalBackdrop.setAttribute('aria-hidden', 'false');
// Focus the dialog after transition starts
requestAnimationFrame(() => {
modal.focus();
});
document.addEventListener('keydown', handleModalKeydown);
}
function closeModal() {
if (!modalOpen) return;
modalOpen = false;
modalBackdrop.classList.remove('is-open');
modalBackdrop.setAttribute('aria-hidden', 'true');
document.removeEventListener('keydown', handleModalKeydown);
modalPrevFocus?.focus();
}
function handleModalKeydown(e) {
if (e.key === 'Escape') {
closeModal();
return;
}
// Focus trap
if (e.key === 'Tab') {
const focusable = getFocusable(modal);
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
}
openModalBtn.addEventListener('click', openModal);
modalCancel.addEventListener('click', closeModal);
modalConfirm.addEventListener('click', () => {
closeModal();
showToast('success', 'Action confirmed', 'Your changes have been published.');
});
// Close on backdrop click (not modal itself)
modalBackdrop.addEventListener('click', e => {
if (e.target === modalBackdrop) closeModal();
});
/* ── Toasts ── */
const toastContainer = document.getElementById('toast-container');
const TOAST_DURATION = 3000;
const TOAST_CONFIG = {
success: { icon: '✓', title: 'Success', msg: 'Operation completed successfully.' },
error: { icon: '✕', title: 'Error', msg: 'Something went wrong. Please try again.' },
warning: { icon: '⚠', title: 'Warning', msg: 'Proceed with caution.' },
info: { icon: 'ℹ', title: 'Info', msg: 'Here is some useful information.' },
};
function showToast(type, title, msg) {
const cfg = TOAST_CONFIG[type];
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.setAttribute('role', 'status');
toast.innerHTML = `
<span class="toast-icon">${cfg.icon}</span>
<div class="toast-content">
<div class="toast-title">${title || cfg.title}</div>
<div class="toast-msg">${msg || cfg.msg}</div>
</div>
<button class="toast-dismiss" aria-label="Dismiss notification">×</button>
`;
toastContainer.appendChild(toast);
// Animate in
requestAnimationFrame(() => {
requestAnimationFrame(() => toast.classList.add('is-visible'));
});
// Auto-dismiss
let dismissTimer = setTimeout(() => removeToast(toast), TOAST_DURATION);
toast.querySelector('.toast-dismiss').addEventListener('click', () => {
clearTimeout(dismissTimer);
removeToast(toast);
});
}
function removeToast(toast) {
toast.classList.remove('is-visible');
toast.classList.add('is-leaving');
toast.addEventListener('transitionend', () => toast.remove(), { once: true });
}
document.querySelectorAll('.toast-btn').forEach(btn => {
btn.addEventListener('click', () => showToast(btn.dataset.type));
});
/* ── Drawer ── */
const drawerOverlay = document.getElementById('drawer-overlay');
const drawer = document.getElementById('drawer');
const openDrawerBtn = document.getElementById('open-drawer-btn');
const drawerCloseBtn = document.getElementById('drawer-close');
let drawerPrevFocus = null;
let drawerOpen = false;
function openDrawer() {
if (drawerOpen) return;
drawerOpen = true;
drawerPrevFocus = document.activeElement;
drawerOverlay.classList.add('is-open');
drawerOverlay.setAttribute('aria-hidden', 'false');
drawer.classList.add('is-open');
requestAnimationFrame(() => {
drawer.focus();
});
document.addEventListener('keydown', handleDrawerKeydown);
}
function closeDrawer() {
if (!drawerOpen) return;
drawerOpen = false;
drawerOverlay.classList.remove('is-open');
drawerOverlay.setAttribute('aria-hidden', 'true');
drawer.classList.remove('is-open');
document.removeEventListener('keydown', handleDrawerKeydown);
drawerPrevFocus?.focus();
}
function handleDrawerKeydown(e) {
if (e.key === 'Escape') {
closeDrawer();
return;
}
// Focus trap
if (e.key === 'Tab') {
const focusable = getFocusable(drawer);
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
}
openDrawerBtn.addEventListener('click', openDrawer);
drawerCloseBtn.addEventListener('click', closeDrawer);
drawerOverlay.addEventListener('click', closeDrawer);