HTML + API · 04 of 05
Make room for a moment Native modality, focus, and dismissal. This embedded preview is modal within its own frame.
Experience kept A modal flow with application-specific save logic
Machinery removed Focus trapping, Escape handling, scroll lock, and z-index work
Decision Keep app logic; return modality to the platform Live demoOpen it. Tab through. Press Escape.
THE REDUCTION
Same outcome. Less to coordinate. A custom overlay has to rebuild the top layer, focus containment, Escape, and focus return, then unwind all of it.
CURRENT PROJECT · JSCopy code
overlay.hidden = false ;
document.body .style .overflow = "hidden" ;
document.addEventListener ("keydown" , closeOnEscape);
trapFocus (overlay);
WITH FEWER PARTS · JSCopy code
dialog.showModal ();
dialog.addEventListener ("close" , () => {
save (dialog.returnValue );
}); Only application logic stays in JavaScript. The skill still checks the accessible name, how the dialog is dismissed, and where focus lands afterwards.
ABOUT THE PATTERN
Why this works The top layer, focus trapping, Escape handling, and backdrop semantics come from the platform instead of a custom overlay stack.
Decisions that matter Name the dialog, choose initial focus deliberately, preserve Escape and return focus. This minimal example disables its preview in unsupported browsers; production essential workflows need an alternative. In the gallery iframe, modality affects only that frame. Read from the skill's own reference files at build time.
01 Fallback The demo uses the broadly supported dialog API, while the surrounding page makes the iframe boundary explicit.
02 What to test Open the dialog, move through its focusable controls, press Escape, and verify focus returns to the trigger.
Reference MDN showModal() ↗ COPY, ADAPT, SHIP
The dependency-free implementation HTML, CSS, and the JavaScript the interaction actually needs
STANDALONE HTMLCopy code
<!doctype html>
<html lang ="en" ><meta charset ="utf-8" ><meta name ="viewport" content ="width=device-width,initial-scale=1" >
<title >Native modal dialog</title >
<style id ="recipe" >
dialog { max-inline-size : min (28rem , calc (100 % - 2rem )); }
dialog::backdrop { background : rgb (18 30 48 / 45 %); }
</style >
<style >
* {box-sizing :border-box}body {margin :0 ;padding :28px ;font :14px /1.5 system-ui;color :#182334 ;background :#f2f5f9;text-align :center}.tile {padding :30px 0 }.symbol {font-size :32px ;color :#3866e8}h2 {font-size :18px ;margin :8px 0 }p {color :#647186 ;margin :8px 0 20px }button {font :inherit;font-weight :600 ;background :#202c42;color :white;border :0 ;border-radius :8px ;padding :11px 20px ;cursor :pointer}button:focus-visible {outline :3px solid #3866e8;outline-offset :4px }dialog {border :1px solid #ccd5e2;border-radius :16px ;padding :24px ;text-align :start;box-shadow :0 20px 70px #18233430 }dialog p {color :#43536b}dialog form {text-align :end}
</style >
<div class ="tile" ><span class ="symbol" aria-hidden ="true" >▣</span ><h2 >A moment of focus</h2 ><p >A native dialog, ready for the keyboard.</p ><button id ="open" type ="button" >Open dialog</button ></div >
<dialog aria-labelledby ="title" ><h2 id ="title" >You're in the dialog.</h2 ><p >Press Escape or close to return to the page.</p ><form method ="dialog" ><button autofocus>Close dialog</button ></form ></dialog >
<script >
const trigger = document.querySelector ('#open' );
const dialog = document.querySelector ('dialog' );
if (typeof dialog.showModal === 'function' ) {
trigger.addEventListener ('click' , () => dialog.showModal ());
dialog.addEventListener ('close' , () => trigger.focus ());
} else {
trigger.disabled = true ;
trigger.textContent = 'Dialog preview unavailable' ;
}
</script ></html >
Using Tailwind CSS v4 already? View the adapted example+ Optional integration · adapt utilities to your project tokens
TAILWIND HTMLCopy code
<section class ="py-8 text-center" >
<span aria-hidden ="true" class ="text-3xl text-blue-600" >▣</span >
<h2 class ="mt-2 text-lg font-semibold text-slate-900" >A moment of focus</h2 >
<p class ="mt-2 text-slate-600" >A native dialog, ready for the keyboard.</p >
<button id ="open" type ="button" class ="mt-5 rounded-lg bg-slate-800 px-5 py-3 font-semibold text-white hover:bg-slate-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" >
Open dialog
</button >
</section >
<dialog aria-labelledby ="dialog-title" class ="m-auto w-[calc(100%-2rem)] max-w-md rounded-2xl border border-slate-300 bg-white p-6 text-left text-slate-900 shadow-2xl backdrop:bg-slate-900/45" >
<h2 id ="dialog-title" class ="text-lg font-semibold" >You're in the dialog.</h2 >
<p class ="mt-2 text-slate-600" >Press Escape or close to return to the page.</p >
<form method ="dialog" class ="mt-6 text-right" >
<button autofocus class ="rounded-lg bg-slate-800 px-5 py-3 font-semibold text-white hover:bg-slate-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" >
Close dialog
</button >
</form >
</dialog >
<script >
const trigger = document.querySelector ('#open' );
const dialog = document.querySelector ('dialog' );
if (typeof dialog.showModal === 'function' ) {
trigger.addEventListener ('click' , () => dialog.showModal ());
dialog.addEventListener ('close' , () => trigger.focus ());
} else {
trigger.disabled = true ;
trigger.textContent = 'Dialog unavailable' ;
}
</script >
← PREVIOUS The parent gets the message :has()NEXT → Let the platform parse it URL.canParse()