Focus Project — Build an Accessible Dialog With Full Focus Management
In this tutorial, you will learn about Focus Project. We cover key concepts, practical examples, and best practices to help you master this topic.
Build a production-ready accessible dialog with focus trapping, focus restoration, inert background, aria-modal, Escape handling, and full keyboard support.
In this project, you'll implement complete Focus Management.
What You'll Learn
By the end of this project, you'll have built a fully accessible dialog component that handles all focus management requirements.
Why It Matters
Modal dialogs are the most common focus management challenge. Mastering this pattern prepares you for all overlay components.
Real-World Use
Doda Browser's settings dialog uses this exact pattern, as does Durga Antivirus Pro's threat confirmation dialog.
Project Architecture
flowchart TD A[Dialog] --> B[Open: store trigger] A --> C[Open: show dialog] A --> D[Open: inert background] A --> E[Open: trap focus] A --> F[Open: focus first element] A --> G[Close: restore focus] A --> H[Close: remove inert] A --> I[Close: hide dialog]
HTML Structure
<button id="settings-trigger" aria-haspopup="dialog">
Open Settings
</button>
<div
id="settings-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
hidden
class="dialog"
>
<div class="dialog-overlay"></div>
<div class="dialog-content">
<h2 id="dialog-title">Settings</h2>
<label>
Username
<input type="text" id="username" />
</label>
<label>
Email
<input type="email" id="email" />
</label>
<div class="dialog-actions">
<button id="save-btn" class="primary">Save</button>
<button id="close-btn">Cancel</button>
</div>
</div>
</div>
Complete Implementation
class AccessibleDialog {
constructor(dialogElement) {
this.dialog = dialogElement;
this.trigger = document.querySelector('[aria-haspopup="dialog"]');
this.lastFocused = null;
this.dialog.addEventListener('keydown', (e) => this.handleKeydown(e));
this.dialog.querySelector('.dialog-overlay').addEventListener('click', () => this.close());
this.dialog.querySelector('#close-btn').addEventListener('click', () => this.close());
this.dialog.querySelector('#save-btn').addEventListener('click', () => this.save());
this.trigger.addEventListener('click', () => this.open());
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !this.dialog.hidden) this.close();
});
}
open() {
this.lastFocused = document.activeElement;
this.dialog.hidden = false;
document.getElementById('page-content').inert = true;
const firstFocusable = this.dialog.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (firstFocusable) {
firstFocusable.focus();
}
}
close() {
this.dialog.hidden = true;
document.getElementById('page-content').inert = false;
if (this.lastFocused && document.contains(this.lastFocused)) {
this.lastFocused.focus();
}
}
save() {
const username = this.dialog.querySelector('#username').value;
const email = this.dialog.querySelector('#email').value;
console.log('Saved:', { username, email });
this.close();
}
handleKeydown(event) {
if (event.key !== 'Tab') return;
const focusable = this.dialog.querySelectorAll(
'button:not([disabled]), [href], input:not([disabled]), ' +
'select:not([disabled]), textarea:not([disabled]), ' +
'[tabindex]:not([tabindex="-1"]):not([disabled])'
);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
}
new AccessibleDialog(document.getElementById('settings-dialog'));
CSS Styling
.dialog {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.dialog-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
}
.dialog-content {
position: relative;
background: #fff;
padding: 24px;
border-radius: 8px;
max-width: 480px;
width: 90%;
}
.dialog[hidden] {
display: none;
}
Testing Checklist
- Trigger focuses on Tab
- Enter/Space opens the dialog
- Focus moves to first input on open
- Tab cycles through focusable elements
- Shift+Tab cycles backward
- Background is non-interactive (inert)
- Escape closes the dialog
- Overlay click closes the dialog
- Focus returns to trigger on close
- Screen reader announces dialog title
Common Mistakes
- Not using inert on background: Users can Tab into background elements.
- Forgetting Shift+Tab cycling: Keyboard users get stuck at the first element.
- Not restoring focus on close: Users are stranded at the page top.
- Not handling overlay click: Users cannot close by clicking outside.
- Not setting aria-modal: Screen readers do not know the dialog is modal.
FAQ
Mini Project
You just built the mini project. Add confirmation dialog support: when the user clicks Save, show a confirmation dialog. Manage focus across both dialogs.
What's Next
This completes the Focus Management Deep Dive series. Continue to Accessible Modals and Dialogs for more advanced dialog patterns.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro