Skip to content
UI ComponentModal & DialogOverlayNew

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
Morphing Modal Dialogs — Button-to-Dialog Animation — preview

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

Already linked inside the component's files — no extra setup needed.


Desktop Preview


HTML

html
1<!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 &amp; 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

css
1/* 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

javascript
1// 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

Browsers
ChromeEdgeFirefoxSafariOperaBraveArcVivaldi
Devices
DesktopTabletMobile
Frameworks
Vanilla JSHTML/CSS

❓ 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.

MetaSkillGroup

Written by

MetaSkillGroup

October 15, 2026

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.

Morphing Modal Dialog Animation (HTML CSS JS) | MetaSkillGroup