Morphing Modal Dialogs — Button-to-Dialog Animation
Three accessible modal dialogs where the button itself morphs into the dialog and back: a type-to-confirm delete dialog, a newsletter form with validation, loading and success states, and an image lightbox with captions and keyboard arrows. Built on the native <dialog> element, so focus trapping and Esc work out of the box.
- Published 15 Oct 2026
- 13 views
Overview
Morphing Modal Dialogs — Button-to-Dialog Animation
Modals that grow out of the button you clicked and shrink back into it. Native <dialog>, smooth FLIP animation, zero libraries.
What's Included
- Button morphs into the dialog (and back) with a FLIP animation
- Native <dialog> — focus trap, Esc to close and backdrop click built in
- Type-to-confirm destructive dialog
- Newsletter form with validation, loading spinner and animated success tick
- Image lightbox with captions, counter and ← → keyboard navigation
- Blurred backdrop that fades in and out
- Falls back to an instant open for users who prefer reduced motion
Dependencies
- Google Fonts — Manrope https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap
Already linked inside the component's files — no extra setup needed.
Desktop Preview
HTML
html1<!doctype html> 2<html lang="en"> 3 <head> 4 <meta charset="UTF-8" /> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 <title>Morphing Modal Dialogs | MetaSkillGroup</title> 7 <link rel="stylesheet" href="style.css" /> 8 </head> 9 <body> 10 <!-- created by metaskillgroup-team --> 11 <main class="stage"> 12 <header class="intro"> 13 <span class="eyebrow">Modal & Dialog</span> 14 <h1>Buttons that <span>become</span> the dialog</h1> 15 <p>Click a button — it morphs into its modal. Press <kbd>Esc</kbd> or click outside to send it back.</p> 16 </header> 17 18 <div class="cards"> 19 <article class="card"> 20 <span class="card-icon danger" aria-hidden="true"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M10 11v6M14 11v6M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12M9 7V4h6v3"/></svg></span> 21 <h2>Confirm action</h2> 22 <p>A destructive action that asks you to type the name before it runs.</p> 23 <button type="button" class="trigger danger" data-modal="modal-confirm">Delete project</button> 24 </article> 25 26 <article class="card"> 27 <span class="card-icon brand" aria-hidden="true"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 7l9 6 9-6"/></svg></span> 28 <h2>Form dialog</h2> 29 <p>A newsletter signup with validation, loading state and success screen.</p> 30 <button type="button" class="trigger brand" data-modal="modal-form">Subscribe</button> 31 </article> 32 33 <article class="card"> 34 <span class="card-icon teal" aria-hidden="true"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="9" cy="10" r="2"/><path d="M21 16l-5-5-9 9"/></svg></span> 35 <h2>Lightbox</h2> 36 <p>An image viewer with captions and keyboard arrows to browse.</p> 37 <button type="button" class="trigger teal" data-modal="modal-photo">View gallery</button> 38 </article> 39 </div> 40 </main> 41 42 <!-- Confirm dialog --> 43 <dialog class="modal" id="modal-confirm" aria-labelledby="confirm-title"> 44 <div class="modal-body"> 45 <button type="button" class="modal-close" aria-label="Close">✕</button> 46 <span class="modal-icon danger" aria-hidden="true"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3l9.5 17h-19z"/><path d="M12 10v4M12 17.5v.01"/></svg></span> 47 <h2 id="confirm-title">Delete “portfolio-site”?</h2> 48 <p>This permanently removes the project, its 24 deployments and all environment variables. This can't be undone.</p> 49 <label class="field"> 50 <span>Type <b>portfolio-site</b> to confirm</span> 51 <input type="text" id="confirm-input" autocomplete="off" spellcheck="false" /> 52 </label> 53 <div class="modal-actions"> 54 <button type="button" class="btn ghost" data-close>Cancel</button> 55 <button type="button" class="btn danger" id="confirm-delete" disabled>Delete project</button> 56 </div> 57 </div> 58 </dialog> 59 60 <!-- Form dialog --> 61 <dialog class="modal" id="modal-form" aria-labelledby="form-title"> 62 <div class="modal-body"> 63 <button type="button" class="modal-close" aria-label="Close">✕</button> 64 <form id="subscribe-form" novalidate> 65 <span class="modal-icon brand" aria-hidden="true"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 13l3-8h12l3 8v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1z"/><path d="M3 13h5l1 3h6l1-3h5"/></svg></span> 66 <h2 id="form-title">Get the weekly digest</h2> 67 <p>One email every Friday with new components, tips and freebies. No spam.</p> 68 <label class="field"> 69 <span>Email address</span> 70 <input type="email" id="email" placeholder="you@example.com" required /> 71 <small class="error" id="email-error" aria-live="polite"></small> 72 </label> 73 <fieldset class="chips"> 74 <legend>I'm interested in</legend> 75 <label><input type="checkbox" checked /> <span>UI components</span></label> 76 <label><input type="checkbox" /> <span>Templates</span></label> 77 <label><input type="checkbox" /> <span>Tutorials</span></label> 78 </fieldset> 79 <button type="submit" class="btn brand full" id="subscribe-btn"> 80 <span class="label">Subscribe</span> 81 <span class="spinner" aria-hidden="true"></span> 82 </button> 83 </form> 84 <div class="success" hidden> 85 <svg viewBox="0 0 52 52" aria-hidden="true"> 86 <circle cx="26" cy="26" r="24" /> 87 <path d="M15 27l7 7 15-16" /> 88 </svg> 89 <h2>You're in! 🎉</h2> 90 <p>Check your inbox to confirm your email.</p> 91 </div> 92 </div> 93 </dialog> 94 95 <!-- Lightbox dialog --> 96 <dialog class="modal modal-photo" id="modal-photo" aria-labelledby="photo-title"> 97 <div class="modal-body"> 98 <button type="button" class="modal-close light" aria-label="Close">✕</button> 99 <figure class="photo"> 100 <div class="photo-art" id="photo-art"></div> 101 <figcaption> 102 <div> 103 <h2 id="photo-title"></h2> 104 <p id="photo-caption"></p> 105 </div> 106 <span class="photo-count" id="photo-count"></span> 107 </figcaption> 108 </figure> 109 <button type="button" class="nav prev" aria-label="Previous photo">‹</button> 110 <button type="button" class="nav next" aria-label="Next photo">›</button> 111 </div> 112 </dialog> 113 114 <div class="toast" id="toast" role="status" aria-live="polite"></div> 115 116 <script src="script.js"></script> 117 </body> 118</html>
CSS
css1/* Importing Google Fonts - Manrope */ 2@import url("https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap"); 3 4* { 5 margin: 0; 6 padding: 0; 7 box-sizing: border-box; 8 font-family: "Manrope", sans-serif; 9} 10 11:root { 12 --bg: #f3f1fb; 13 --card: #ffffff; 14 --text: #17142b; 15 --muted: #6c6887; 16 --border: #e4e0f3; 17 --brand: #6d4aff; 18 --danger: #e5484d; 19 --teal: #0fb5a3; 20 --radius: 24px; 21} 22 23body { 24 min-height: 100vh; 25 background: 26 radial-gradient(circle at 15% 10%, rgba(109, 74, 255, 0.12), transparent 40%), 27 radial-gradient(circle at 85% 90%, rgba(15, 181, 163, 0.12), transparent 40%), 28 var(--bg); 29 color: var(--text); 30} 31 32.stage { 33 display: grid; 34 align-content: center; 35 min-height: 100vh; 36 max-width: 1080px; 37 margin: 0 auto; 38 padding: 90px 20px; 39} 40 41/* ---------- Intro ---------- */ 42.intro { 43 max-width: 640px; 44 margin: 0 auto 56px; 45 text-align: center; 46} 47 48.eyebrow { 49 font-size: 0.8rem; 50 font-weight: 800; 51 letter-spacing: 0.14em; 52 text-transform: uppercase; 53 color: var(--brand); 54} 55 56.intro h1 { 57 margin: 14px 0; 58 font-size: clamp(2rem, 5vw, 3.2rem); 59 font-weight: 800; 60 line-height: 1.1; 61 letter-spacing: -0.03em; 62 text-wrap: balance; 63} 64 65.intro h1 span { 66 position: relative; 67 color: var(--brand); 68} 69 70.intro h1 span::after { 71 content: ""; 72 position: absolute; 73 left: 0; 74 right: 0; 75 bottom: 2px; 76 height: 10px; 77 border-radius: 6px; 78 background: rgba(109, 74, 255, 0.18); 79 z-index: -1; 80} 81 82.intro p { 83 color: var(--muted); 84 font-size: 1.05rem; 85 line-height: 1.6; 86} 87 88kbd { 89 padding: 2px 7px; 90 border: 1px solid var(--border); 91 border-bottom-width: 3px; 92 border-radius: 6px; 93 background: var(--card); 94 font-family: inherit; 95 font-size: 0.85em; 96 font-weight: 700; 97} 98 99/* ---------- Cards ---------- */ 100.cards { 101 display: grid; 102 grid-template-columns: repeat(3, 1fr); 103 gap: 22px; 104} 105 106.card { 107 display: flex; 108 flex-direction: column; 109 align-items: flex-start; 110 padding: 28px; 111 border: 1px solid var(--border); 112 border-radius: var(--radius); 113 background: var(--card); 114 box-shadow: 0 20px 50px -30px rgba(23, 20, 43, 0.25); 115} 116 117.card-icon { 118 display: grid; 119 place-items: center; 120 width: 48px; 121 height: 48px; 122 border-radius: 14px; 123 font-size: 1.35rem; 124} 125 126.card-icon svg, 127.modal-icon svg { 128 width: 24px; 129 height: 24px; 130 fill: none; 131 stroke: currentColor; 132 stroke-width: 2; 133 stroke-linecap: round; 134 stroke-linejoin: round; 135} 136 137.card-icon.danger, 138.modal-icon.danger { 139 background: rgba(229, 72, 77, 0.12); 140 color: var(--danger); 141} 142 143.card-icon.brand, 144.modal-icon.brand { 145 background: rgba(109, 74, 255, 0.12); 146 color: var(--brand); 147} 148 149.card-icon.teal { 150 background: rgba(15, 181, 163, 0.12); 151 color: var(--teal); 152} 153 154.card h2 { 155 margin: 18px 0 8px; 156 font-size: 1.2rem; 157} 158 159.card p { 160 flex: 1; 161 margin-bottom: 24px; 162 color: var(--muted); 163 font-size: 0.95rem; 164 line-height: 1.55; 165} 166 167/* ---------- Buttons ---------- */ 168.trigger, 169.btn { 170 position: relative; 171 display: inline-flex; 172 align-items: center; 173 justify-content: center; 174 gap: 8px; 175 padding: 13px 22px; 176 border: 0; 177 border-radius: 14px; 178 color: #fff; 179 font-size: 0.95rem; 180 font-weight: 700; 181 cursor: pointer; 182 transition: transform 0.2s, box-shadow 0.2s, opacity 0.2s, background 0.2s; 183} 184 185.trigger:hover, 186.btn:hover:not(:disabled) { 187 transform: translateY(-2px); 188} 189 190.trigger:focus-visible, 191.btn:focus-visible, 192.modal-close:focus-visible, 193.nav:focus-visible { 194 outline: 3px solid rgba(109, 74, 255, 0.45); 195 outline-offset: 2px; 196} 197 198.danger { 199 background: var(--danger); 200 box-shadow: 0 10px 24px -10px rgba(229, 72, 77, 0.8); 201} 202 203.brand { 204 background: var(--brand); 205 box-shadow: 0 10px 24px -10px rgba(109, 74, 255, 0.8); 206} 207 208.teal { 209 background: var(--teal); 210 box-shadow: 0 10px 24px -10px rgba(15, 181, 163, 0.8); 211} 212 213.card-icon.danger, 214.card-icon.brand, 215.card-icon.teal, 216.modal-icon { 217 box-shadow: none; 218} 219 220/* The trigger stays in place but invisible while its modal is open */ 221.trigger.is-hidden { 222 visibility: hidden; 223} 224 225.btn.ghost { 226 background: #f1eff8; 227 color: var(--text); 228 box-shadow: none; 229} 230 231.btn:disabled { 232 opacity: 0.45; 233 cursor: not-allowed; 234} 235 236.btn.full { 237 width: 100%; 238 margin-top: 6px; 239} 240 241/* ---------- Modal ---------- */ 242.modal { 243 width: min(460px, calc(100% - 32px)); 244 margin: auto; 245 padding: 0; 246 border: 0; 247 border-radius: var(--radius); 248 background: var(--card); 249 color: var(--text); 250 box-shadow: 0 40px 100px -30px rgba(23, 20, 43, 0.5); 251 overflow: hidden; 252} 253 254.modal::backdrop { 255 background: rgba(23, 20, 43, 0.45); 256 backdrop-filter: blur(6px); 257 animation: fadeIn 0.3s ease; 258} 259 260.modal.is-closing::backdrop { 261 animation: fadeOut 0.35s ease forwards; 262} 263 264@keyframes fadeIn { 265 from { 266 opacity: 0; 267 } 268} 269 270@keyframes fadeOut { 271 to { 272 opacity: 0; 273 } 274} 275 276.modal-body { 277 position: relative; 278 padding: 32px; 279} 280 281.modal-close { 282 position: absolute; 283 top: 16px; 284 right: 16px; 285 z-index: 2; 286 display: grid; 287 place-items: center; 288 width: 34px; 289 height: 34px; 290 border: 0; 291 border-radius: 50%; 292 background: #f1eff8; 293 color: var(--muted); 294 font-size: 0.9rem; 295 cursor: pointer; 296} 297 298.modal-close.light { 299 background: rgba(255, 255, 255, 0.85); 300 color: var(--text); 301} 302 303.modal-icon { 304 display: grid; 305 place-items: center; 306 width: 54px; 307 height: 54px; 308 border-radius: 16px; 309 font-size: 1.5rem; 310} 311 312.modal h2 { 313 margin: 18px 0 8px; 314 font-size: 1.35rem; 315 letter-spacing: -0.01em; 316} 317 318.modal p { 319 color: var(--muted); 320 font-size: 0.95rem; 321 line-height: 1.6; 322} 323 324.field { 325 display: grid; 326 gap: 8px; 327 margin-top: 22px; 328 font-size: 0.88rem; 329 font-weight: 600; 330} 331 332.field input { 333 width: 100%; 334 padding: 13px 14px; 335 border: 1.5px solid var(--border); 336 border-radius: 12px; 337 background: #faf9fe; 338 color: var(--text); 339 font-size: 1rem; 340 outline: none; 341 transition: border-color 0.2s, box-shadow 0.2s; 342} 343 344.field input:focus { 345 border-color: var(--brand); 346 box-shadow: 0 0 0 4px rgba(109, 74, 255, 0.15); 347} 348 349.field input.invalid { 350 border-color: var(--danger); 351 box-shadow: 0 0 0 4px rgba(229, 72, 77, 0.12); 352} 353 354.error { 355 min-height: 16px; 356 color: var(--danger); 357 font-size: 0.8rem; 358 font-weight: 600; 359} 360 361.modal-actions { 362 display: flex; 363 justify-content: flex-end; 364 gap: 10px; 365 margin-top: 26px; 366} 367 368/* Interest chips */ 369.chips { 370 display: flex; 371 flex-wrap: wrap; 372 gap: 8px; 373 margin: 10px 0 18px; 374 border: 0; 375} 376 377.chips legend { 378 margin-bottom: 10px; 379 font-size: 0.88rem; 380 font-weight: 600; 381} 382 383.chips label { 384 cursor: pointer; 385} 386 387.chips input { 388 position: absolute; 389 opacity: 0; 390} 391 392.chips span { 393 display: inline-block; 394 padding: 8px 14px; 395 border: 1.5px solid var(--border); 396 border-radius: 999px; 397 font-size: 0.85rem; 398 font-weight: 600; 399 transition: all 0.2s; 400} 401 402.chips input:checked + span { 403 border-color: var(--brand); 404 background: rgba(109, 74, 255, 0.1); 405 color: var(--brand); 406} 407 408.chips input:focus-visible + span { 409 outline: 3px solid rgba(109, 74, 255, 0.35); 410} 411 412/* Loading state */ 413.spinner { 414 display: none; 415 width: 18px; 416 height: 18px; 417 border: 2.5px solid rgba(255, 255, 255, 0.4); 418 border-top-color: #fff; 419 border-radius: 50%; 420 animation: spin 0.7s linear infinite; 421} 422 423.is-loading .spinner { 424 display: inline-block; 425} 426 427.is-loading .label { 428 opacity: 0.8; 429} 430 431@keyframes spin { 432 to { 433 transform: rotate(360deg); 434 } 435} 436 437/* Success screen */ 438.success { 439 padding: 20px 0 6px; 440 text-align: center; 441} 442 443.success svg { 444 width: 84px; 445 height: 84px; 446 fill: none; 447 stroke: var(--teal); 448 stroke-width: 3; 449 stroke-linecap: round; 450 stroke-linejoin: round; 451} 452 453.success circle { 454 stroke-dasharray: 151; 455 stroke-dashoffset: 151; 456 animation: draw 0.6s ease forwards; 457} 458 459.success path { 460 stroke-dasharray: 40; 461 stroke-dashoffset: 40; 462 animation: draw 0.4s 0.5s ease forwards; 463} 464 465@keyframes draw { 466 to { 467 stroke-dashoffset: 0; 468 } 469} 470 471/* ---------- Lightbox ---------- */ 472.modal-photo { 473 width: min(760px, calc(100% - 32px)); 474} 475 476.modal-photo .modal-body { 477 padding: 0; 478} 479 480.photo-art { 481 aspect-ratio: 16 / 10; 482 transition: opacity 0.25s; 483} 484 485.photo-art svg { 486 display: block; 487 width: 100%; 488 height: 100%; 489} 490 491.photo figcaption { 492 display: flex; 493 justify-content: space-between; 494 align-items: flex-end; 495 gap: 16px; 496 padding: 20px 26px 24px; 497} 498 499.photo h2 { 500 margin: 0 0 4px; 501} 502 503.photo-count { 504 flex-shrink: 0; 505 color: var(--muted); 506 font-size: 0.85rem; 507 font-weight: 700; 508} 509 510.nav { 511 position: absolute; 512 top: calc((100% - 92px) / 2); 513 transform: translateY(-50%); 514 display: grid; 515 place-items: center; 516 width: 44px; 517 height: 44px; 518 border: 0; 519 border-radius: 50%; 520 background: rgba(255, 255, 255, 0.85); 521 color: var(--text); 522 font-size: 1.6rem; 523 line-height: 1; 524 cursor: pointer; 525 backdrop-filter: blur(6px); 526} 527 528.nav.prev { 529 left: 16px; 530} 531 532.nav.next { 533 right: 16px; 534} 535 536/* ---------- Toast ---------- */ 537.toast { 538 position: fixed; 539 left: 50%; 540 bottom: 28px; 541 padding: 12px 20px; 542 border-radius: 12px; 543 background: var(--text); 544 color: #fff; 545 font-size: 0.92rem; 546 font-weight: 600; 547 opacity: 0; 548 transform: translate(-50%, 20px); 549 transition: opacity 0.3s, transform 0.3s; 550 pointer-events: none; 551} 552 553.toast.show { 554 opacity: 1; 555 transform: translate(-50%, 0); 556} 557 558/* ---------- Responsive ---------- */ 559@media (max-width: 900px) { 560 .cards { 561 grid-template-columns: 1fr; 562 max-width: 460px; 563 margin: 0 auto; 564 } 565} 566 567@media (max-width: 480px) { 568 .modal-body { 569 padding: 26px 22px; 570 } 571 572 .modal-actions { 573 flex-direction: column-reverse; 574 } 575 576 .modal-actions .btn { 577 width: 100%; 578 } 579} 580 581@media (prefers-reduced-motion: reduce) { 582 *, 583 *::before, 584 *::after, 585 .modal::backdrop { 586 animation-duration: 0.01ms !important; 587 transition: none !important; 588 } 589}
JavaScript
javascript1// Morph timing — tweak to taste 2const DURATION = 520; 3const EASE = "cubic-bezier(0.65, 0, 0.2, 1)"; 4const TRIGGER_RADIUS = 14; 5const MODAL_RADIUS = 24; 6 7// Lightbox photos (drawn as SVG so no image files are needed — swap for <img> if you like) 8const photos = [ 9 { title: "Dunes at sunset", caption: "Golden hour over the Thar desert.", sky: ["#ff9a5a", "#ffd27a"], sun: "#fff4d6", land: ["#e07a3f", "#b9552b"] }, 10 { title: "Northern lights", caption: "A green aurora dancing over the fjord.", sky: ["#0b1d3a", "#1f6f6a"], sun: "#c9fff1", land: ["#0e2a3a", "#07131f"] }, 11 { title: "Lake at dawn", caption: "Soft pink mist rising off still water.", sky: ["#f7b4c8", "#c3d4ff"], sun: "#ffffff", land: ["#7f8fd6", "#5a6bb8"] }, 12]; 13 14const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; 15const openedBy = new Map(); // dialog → the trigger that opened it 16 17// Keyframes that make the dialog start exactly on top of the trigger. 18// Border radius is divided by the scale so corners stay round while scaled. 19const morphFrames = (from, to, fromColor) => { 20 const sx = from.width / to.width; 21 const sy = from.height / to.height; 22 const dx = from.left + from.width / 2 - (to.left + to.width / 2); 23 const dy = from.top + from.height / 2 - (to.top + to.height / 2); 24 return [ 25 { 26 transform: `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`, 27 borderRadius: `${TRIGGER_RADIUS / sx}px / ${TRIGGER_RADIUS / sy}px`, 28 backgroundColor: fromColor, 29 }, 30 { 31 transform: "none", 32 borderRadius: `${MODAL_RADIUS}px`, 33 backgroundColor: getComputedStyle(document.documentElement).getPropertyValue("--card").trim(), 34 }, 35 ]; 36}; 37 38const openModal = (trigger) => { 39 const dialog = document.getElementById(trigger.dataset.modal); 40 const body = dialog.querySelector(".modal-body"); 41 const from = trigger.getBoundingClientRect(); 42 const fromColor = getComputedStyle(trigger).backgroundColor; 43 44 openedBy.set(dialog, trigger); 45 dialog.dispatchEvent(new Event("beforeopen")); 46 dialog.showModal(); 47 if (reduceMotion) return; 48 49 const to = dialog.getBoundingClientRect(); 50 trigger.classList.add("is-hidden"); 51 52 dialog.animate(morphFrames(from, to, fromColor), { duration: DURATION, easing: EASE }); 53 // Content fades in during the last part of the morph 54 body.animate( 55 [ 56 { opacity: 0, transform: "translateY(10px)" }, 57 { opacity: 1, transform: "none" }, 58 ], 59 { duration: 280, delay: DURATION * 0.6, easing: "ease-out", fill: "backwards" }, 60 ); 61}; 62 63const closeModal = async (dialog) => { 64 if (!dialog.open || dialog.dataset.closing) return; 65 dialog.dataset.closing = "true"; 66 const trigger = openedBy.get(dialog); 67 const body = dialog.querySelector(".modal-body"); 68 69 if (!reduceMotion && trigger) { 70 dialog.classList.add("is-closing"); 71 await body.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 140, fill: "forwards" }).finished; 72 const from = trigger.getBoundingClientRect(); 73 const to = dialog.getBoundingClientRect(); 74 const frames = morphFrames(from, to, getComputedStyle(trigger).backgroundColor).reverse(); 75 await dialog.animate(frames, { duration: DURATION * 0.85, easing: EASE, fill: "forwards" }).finished; 76 } 77 78 dialog.close(); 79 [...dialog.getAnimations(), ...body.getAnimations()].forEach((a) => a.cancel()); 80 dialog.classList.remove("is-closing"); 81 delete dialog.dataset.closing; 82 trigger?.classList.remove("is-hidden"); 83 trigger?.focus(); 84}; 85 86document.querySelectorAll(".trigger").forEach((trigger) => { 87 trigger.addEventListener("click", () => openModal(trigger)); 88}); 89 90document.querySelectorAll(".modal").forEach((dialog) => { 91 // Esc key → animated close instead of the instant native one 92 dialog.addEventListener("cancel", (e) => { 93 e.preventDefault(); 94 closeModal(dialog); 95 }); 96 // A click that lands on the dialog itself (not its content) is the backdrop 97 dialog.addEventListener("click", (e) => { 98 if (e.target === dialog) closeModal(dialog); 99 }); 100 dialog.querySelectorAll(".modal-close, [data-close]").forEach((btn) => btn.addEventListener("click", () => closeModal(dialog))); 101}); 102 103// ---------- Toast ---------- 104const toast = document.getElementById("toast"); 105let toastTimer; 106const showToast = (message) => { 107 toast.textContent = message; 108 toast.classList.add("show"); 109 clearTimeout(toastTimer); 110 toastTimer = setTimeout(() => toast.classList.remove("show"), 2600); 111}; 112 113// ---------- Confirm dialog ---------- 114const confirmDialog = document.getElementById("modal-confirm"); 115const confirmInput = document.getElementById("confirm-input"); 116const confirmBtn = document.getElementById("confirm-delete"); 117 118confirmDialog.addEventListener("beforeopen", () => { 119 confirmInput.value = ""; 120 confirmBtn.disabled = true; 121}); 122confirmInput.addEventListener("input", () => { 123 confirmBtn.disabled = confirmInput.value.trim() !== "portfolio-site"; 124}); 125confirmBtn.addEventListener("click", async () => { 126 await closeModal(confirmDialog); 127 showToast("🗑️ “portfolio-site” was deleted"); 128}); 129 130// ---------- Form dialog ---------- 131const formDialog = document.getElementById("modal-form"); 132const form = document.getElementById("subscribe-form"); 133const email = document.getElementById("email"); 134const emailError = document.getElementById("email-error"); 135const submitBtn = document.getElementById("subscribe-btn"); 136const success = formDialog.querySelector(".success"); 137 138formDialog.addEventListener("beforeopen", () => { 139 form.hidden = false; 140 success.hidden = true; 141 form.reset(); 142 email.classList.remove("invalid"); 143 emailError.textContent = ""; 144 submitBtn.classList.remove("is-loading"); 145 submitBtn.disabled = false; 146}); 147 148form.addEventListener("submit", (e) => { 149 e.preventDefault(); 150 const valid = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(email.value.trim()); 151 email.classList.toggle("invalid", !valid); 152 emailError.textContent = valid ? "" : "Please enter a valid email address."; 153 if (!valid) { 154 email.focus(); 155 return; 156 } 157 158 // Fake request — replace with your API call 159 submitBtn.classList.add("is-loading"); 160 submitBtn.disabled = true; 161 setTimeout(() => { 162 form.hidden = true; 163 success.hidden = false; 164 setTimeout(() => closeModal(formDialog), 1800); 165 }, 1200); 166}); 167 168// ---------- Lightbox ---------- 169const photoDialog = document.getElementById("modal-photo"); 170const art = document.getElementById("photo-art"); 171let current = 0; 172 173const photoSVG = ({ sky, sun, land }, i) => ` 174 <svg viewBox="0 0 800 500" preserveAspectRatio="xMidYMid slice" role="img" aria-label="${photos[i].title}"> 175 <defs> 176 <linearGradient id="sky${i}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${sky[0]}"/><stop offset="1" stop-color="${sky[1]}"/></linearGradient> 177 <linearGradient id="land${i}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${land[0]}"/><stop offset="1" stop-color="${land[1]}"/></linearGradient> 178 </defs> 179 <rect width="800" height="500" fill="url(#sky${i})"/> 180 <circle cx="${560 - i * 120}" cy="${200 + i * 20}" r="70" fill="${sun}" opacity=".9"/> 181 <path d="M0 330 Q160 250 320 320 T640 300 T800 320 V500 H0Z" fill="url(#land${i})" opacity=".75"/> 182 <path d="M0 390 Q200 330 400 385 T800 370 V500 H0Z" fill="url(#land${i})"/> 183 </svg>`; 184 185const showPhoto = (index) => { 186 current = (index + photos.length) % photos.length; 187 art.style.opacity = 0; 188 setTimeout(() => { 189 art.innerHTML = photoSVG(photos[current], current); 190 document.getElementById("photo-title").textContent = photos[current].title; 191 document.getElementById("photo-caption").textContent = photos[current].caption; 192 document.getElementById("photo-count").textContent = `${current + 1} / ${photos.length}`; 193 art.style.opacity = 1; 194 }, art.innerHTML ? 180 : 0); 195}; 196 197photoDialog.querySelector(".prev").addEventListener("click", () => showPhoto(current - 1)); 198photoDialog.querySelector(".next").addEventListener("click", () => showPhoto(current + 1)); 199photoDialog.addEventListener("keydown", (e) => { 200 if (e.key === "ArrowLeft") showPhoto(current - 1); 201 if (e.key === "ArrowRight") showPhoto(current + 1); 202}); 203 204showPhoto(0);
Built by MetaSkillGroup — copy it, use it, make it yours.
Features
Button morphs into the dialog (and back) with a FLIP animation
Native <dialog> — focus trap, Esc to close and backdrop click built in
Type-to-confirm destructive dialog
Newsletter form with validation, loading spinner and animated success tick
Image lightbox with captions, counter and ← → keyboard navigation
Blurred backdrop that fades in and out
Falls back to an instant open for users who prefer reduced motion
Built With
HTML5
CSS3
JavaScript
Compatibility
❓ FAQs
Yes. It uses the native <dialog> element with showModal(), so focus stays inside the dialog, Esc closes it and screen readers announce it correctly. Focus returns to the button on close.
Add a <dialog class="modal" id="my-modal"> with a .modal-body inside, and a button with class "trigger" and data-modal="my-modal". The morph works automatically.
Change DURATION and EASE at the top of script.js. TRIGGER_RADIUS and MODAL_RADIUS keep the corners matching your button and dialog.

Written by
MetaSkillGroup
Need it built for you?
Built to the same standard as our client work.
If nothing here fits exactly, tell us what you need — our core developers build it. Requirements call first, a written SRS before any code, and a price agreed up front.