ui-004

Animated Modal Dialog

£10 or the whole Vault, £99

An accessible animated modal dialog: the button you press morphs into the dialog and back, with side and centred placements, stacking and focus trapping.

Flip intermediate
7 more details
micro-interactiondialog-animationflip-layoutshared-elementform-validationkeyboard-navigationstate-transition

This element reads better at your own screen size than in the frame below:

Updated

About this component

A dialog that is launched from the thing you pressed. GSAP Flip records the trigger's box, the dialog is dropped into it, and the difference is animated: width, height, position, corner radius and colour, never scale, so the type inside stays upright the whole way. Closing runs the same morph in reverse, measured against the trigger at that moment, so it lands back on the right button however far the page has been scrolled.

Read the full effect overview

Around the morph sits everything a dialog actually owes: focus moved in and trapped, focus returned to whatever opened it, Escape, and the page behind held still. One component covers three placements, centred, side drawer and bottom sheet, from a single data attribute, and any number of triggers on a page each morph to and from their own dialog.

It also handles the awkward states: a dialog opening on top of another with the one behind receding, Escape unwinding only the top of the stack, a short form with inline validation and a shake on an invalid submit, and a confirm step before an unsaved form is thrown away. Light and dark themes, a small public API and CustomEvents for wiring the rest of your UI.

Animated Modal Dialog - UI component preview

What's included

10 items
  • GSAP Flip morph from any trigger's box into the dialog, tweening width, height, radius and colour rather than scale, so text never squashes
  • The close morph is measured at close time, so it returns to the right trigger after the page has scrolled
  • Three placements from one data attribute: centred, side drawer and bottom sheet, all from the same trigger
  • Several triggers per page, each morphing to and from its own dialog
  • Stacking: a second dialog opens over the first, the one behind recedes and Escape closes only the top
  • The full overlay contract: focus trapped, focus returned to the trigger, Escape, and the page behind locked
  • A real dialog's contents: a form with inline validation, a shake on invalid submit and a success state
  • Confirm before close when the form is dirty, with discard and keep-editing paths
  • window.fluidModal open() / close() / isOpen(), plus modal:open, modal:close and modal:cancel events on the root
  • Light and dark themes from one attribute, every colour, radius and shadow a custom property the script never reads

Perfect for

4 use cases
  • Invite, share and settings dialogs launched from a row's own button
  • Checkout, confirmation and destructive-action flows that need a confirm on top
  • Side drawers for detail panels that become bottom sheets on a phone
  • Any product surface where several buttons each open their own dialog

How it works

3 sections

Shared-element morph, both ways

The dialog is set to the trigger's exact box, Flip.getState records that, the geometry is cleared so the dialog snaps to its CSS placement, and Flip.from animates the difference. Because the recorded box is cleared rather than kept, the same code produces a centred dialog, a side drawer or a bottom sheet: the placement is CSS and the morph is whatever the difference happens to be. Closing captures the open state and applies the trigger's box instead, measured at that instant, which is why it lands correctly after a scroll.

Why never scale: true

Flip can match two boxes by scaling, which is cheaper, but a container full of text scaled from a 40px button is illegible for the whole tween. This morph lets Flip tween width and height and fades the dialog's contents in behind them, so the type is upright from the first frame. The morphing box carries no min-height for the same reason: it could not shrink back to the trigger.

A stack, not a flag

Open dialogs are held in an array, so Escape, the backdrop and the focus trap always address the last entry only. Opening one over another scales, dims and blurs the one underneath and takes it out of the tab order and out of pointer reach; closing brings it forward again. That is also how the dirty-form confirm works: it is an ordinary dialog opened on top, morphing out of the close button it interrupted.

Plugins Flip
Difficulty Intermediate
Includes HTML + JS + CSS source, documentation, AI setup prompt, lifetime updates

Lighthouse, as measured

Google Lighthouse on this UI element's demo, from the latest scan. A measurement of the demo as shipped, not a promise for your page.

Accessibility
100
Best practices
100

No performance score, on purpose. That figure depends on how you deploy: your server's compression and caching, your CDN, the connection and the device doing the test, none of which the code controls. The same page can score very differently on two consecutive runs, so measure it where it will live.

Paid UI element

Purchase to unlock the code.

Buying Animated Modal Dialog opens the HTML, CSS and JavaScript source, the full documentation, an AI setup prompt for your editor, and every update we ship to it. Standard license: unlimited personal and commercial projects. Every paid product is tested for desktop and mobile layouts, reduced-motion handling, animation cleanup and keyboard controls wherever there is something to operate.

£10 Standard license, unlimited projects

Browse UI elements

Or the whole library: the Vault, £99, one payment.

Documentation

What's Included

  • index.html: the demo page and the markup to copy

  • assets/style.css: component styles, every colour a CSS custom property

  • assets/script.js: readable, commented source with an onReady guard

  • assets/script.min.js: minified production script

  • Flip morph from any trigger's box into the dialog, tweening width, height, corner radius and background colour rather than scale, so the type inside stays upright for the whole tween

  • The close morph measures the trigger at close time, so it lands back on the right button however far the page has been scrolled

  • Three placements from one attribute (data-modal-mode): center, drawer, sheet — same markup, same script, and a drawer becomes a bottom sheet under 560px

  • Several triggers on a page, each morphing to and from its own dialog, and a second open that re-measures rather than replaying the first (the demo shows one trigger and one dialog)

  • A stack: a dialog can open over another, the one behind recedes and leaves the tab order, and Escape closes only the top

  • A form with inline validation, a shake on invalid submit, a success state, and a confirm step before an unsaved form is discarded

  • Roles: role="dialog" / role="alertdialog" with aria-modal, aria-labelledby, aria-haspopup="dialog" and aria-expanded on triggers, role="alert" on each field error

Quick Start

1. Add to your <head>:

Code snippet omitted: it ships with the download.

2. Add before the closing </body> tag:

Code snippet omitted: it ships with the download.

3. Copy the component markup from index.html into your page.

It is the block marked <!-- Component starts here -->: one [data-modal-root] wrapper holding [data-modal-backdrop] and one [data-modal] element per dialog. Nothing is found by id except the dialog a trigger names, so you can rename everything.

A trigger is any button, anywhere on the page:

Code snippet omitted: it ships with the download.

  • data-modal-open is the id of the dialog it opens.
  • data-modal-mode is optional; without it the dialog uses its own data-mode, defaulting to center.
  • data-modal-dismiss on any button inside a dialog closes it (the close button and Cancel both use it).
  • data-modal-inner wraps the dialog's contents so they can fade in behind the morph. Keep it.
  • data-modal-autofocus names the control that should take focus on open; without it, the first focusable one does.

The form, the confirm dialog and the success panel are optional. A dialog with no [data-modal-form] in it never asks before closing.

The demo toolbar strip (the theme toggle and the placement control) and the short document the dialog opens over — the .stage-doc block in index.html and its rules at the bottom of assets/style.css — are demo furniture, there so the morph has something to launch from and the scroll lock is visible. They are not part of the component; leave them behind. thumbnail.html is a build-only capture frame and is not part of the component either.

Themes

One design at two sets of values, light (the default) and dark. Pick one by setting the attribute:

Code snippet omitted: it ships with the download.

Set it on <body> in the demo, or on the component's owning root in your page. Nothing else changes: same markup, same script, and the JavaScript never reads the theme name — where it needs a theme-dependent colour it reads the trigger's computed style at the moment of the morph. The demo's toggle and the ?variant=dark URL parameter only set that attribute.

Both blocks sit at the top of assets/style.css and define every value the component uses:

Token What it paints
--ground, --ground-2 the page behind, and the demo toolbar strip
--raised, --hover the dialog's surface, and the hover step above it
--ink, --ink-2, --ink-3 titles, body copy, labels and meta
--line, --line-strong hairlines, and input and button borders
--accent, --accent-hover, --accent-ink, --accent-wash the primary button, the trigger, the switch when on, the focus ring, the success mark
--danger invalid field borders and their messages
--field-bg, --knob input grounds, and the switch's knob
--backdrop, --shadow, --shadow-card the scrim and the dialog's elevation
--radius-card, --radius-sm, --border-w, --font the edge, and the face

To put it in your own brand, re-value that short list in both blocks — the accent is the only colour that carries any opinion, and it has one job: the primary action, the selected state and the focus ring. No selector override is ever needed.

How It Works

The morph, both ways. On open, the dialog is set to the trigger's exact box (top, left, width, height, plus the trigger's border radius and background), Flip.getState records that, the geometry is cleared so the dialog snaps to whatever its CSS placement is, and Flip.from animates the difference. Because the recorded box is cleared rather than kept, the same eight lines produce a centred dialog, a right-hand drawer or a bottom sheet — the placement is CSS and the morph is simply the difference. Closing captures the open state and applies the trigger's box instead, measured at that instant, which is why it returns to the right button after the page has scrolled.

Never scale: true. Flip can match two boxes by scaling, which is cheaper, but a container full of text scaled up from a 40px button is illegible for the whole tween. This one lets Flip tween width and height and fades the contents in behind them. For the same reason the dialog carries no min-height: it could not shrink back down to the trigger.

A stack, not a flag. Open dialogs live in an array, so Escape, the backdrop and the focus trap always address the last entry only. Opening one over another scales, dims and blurs the one underneath, sets aria-hidden and takes it out of pointer reach; closing brings it forward. The dirty-form confirm is nothing special — it is an ordinary dialog opened on top, morphing out of the close button it interrupted.

Closing hides for real. The dialog's hidden attribute is set in the morph's onComplete, and every property GSAP wrote is cleared there too. A faded-out overlay still covers the page and eats every click behind it, and a dialog left holding an inline width opens at the wrong size the second time.

Public API. The first instance is exposed for wiring:

Code snippet omitted: it ships with the download.

open() takes the trigger, not the dialog, because the trigger is what the morph flies out of.

Events. The root dispatches, all bubbling, with a detail object:

Event When detail
modal:open a dialog is opening id, mode, trigger, depth
modal:opened its morph has finished id, mode, trigger
modal:cancel dismissed by Escape, the backdrop or a close button id, reason
modal:close any close, after modal:cancel id, reason, depth
modal:submit the form validated and was accepted id
modal:invalid a submit was rejected id, fields

Code snippet omitted: it ships with the download.

Accessibility

Key What it does
Enter / Space on a trigger Opens its dialog and moves focus into it
Escape Closes the top dialog only; focus returns to whatever opened it
Tab / Shift+Tab Cycles within the top dialog and never leaves it
Enter on the form Validates; invalid fields are marked, announced and the first one is focused
  • Roles and state: each dialog is role="dialog" with aria-modal="true" and an aria-labelledby pointing at its title; the discard prompt is role="alertdialog". Triggers get aria-haspopup="dialog" and an aria-expanded that tracks the dialog. Field errors are role="alert" and referenced by aria-describedby, and each invalid field carries aria-invalid="true".
  • Focus: moved to [data-modal-autofocus] (or the first focusable control) on open, trapped while open, and returned to the trigger on close. The trigger is hidden during the morph with opacity, never visibility, precisely so it is still focusable when focus comes back to it, and the dialog's contents fade in with opacity for the same reason: autoAlpha would set visibility: hidden on the frame focus moves in, and nothing inside a hidden box can take it.
  • The page behind: while anything is open, overflow: hidden on <html> and <body> (on <body> alone it does nothing once <html> has any overflow of its own), plus position: fixed on <body> held at the offset the reader was at — overflow: hidden by itself clamps a scrolled page to the top, so the document would jump the moment the dialog opened. The scrollbar's width is added as padding so nothing shifts sideways, and the offset is scrolled back on unlock. It is released only when the last dialog closes.
  • A dialog underneath is aria-hidden, pointer-events: none and outside the focus trap, so nothing behind the top dialog can be reached by pointer or keyboard.
  • prefers-reduced-motion: reduce: the same states, the same code path, durations effectively zero. The dialog appears and disappears rather than morphing; nothing is disabled.
  • Touch: the close button grows to 44px on coarse pointers, and a side drawer becomes a bottom sheet under 560px so it is reachable with a thumb.
  • Without JavaScript the dialogs stay hidden and the page behind is untouched — nothing is pre-hidden by CSS that only JS can undo.

Customisation

  • --dialog-w on a dialog sets its width in center and drawer mode (.fm-dialog--confirm uses it to make the discard prompt narrower).
  • --backdrop is the scrim; --radius-card, --shadow-card and --border-w are the dialog's edge.
  • Colours live in assets/style.css as custom properties per theme (the table above).
  • Easing and durations are the Flip.from and gsap.to calls in assets/script.js: 0.6s power3.inOut in, 0.42s out, and one T multiplier that reduced motion sets to zero.
  • Do not put a CSS transform on .fm-dialog or on a trigger — GSAP owns those, and a CSS transform underneath one is read as a pixel offset it never returns from. Do not give the dialog a min-height either, or the closing morph cannot shrink it back to the trigger.

Requirements

  • GSAP 3.12+ plus the Flip plugin (the demo uses 3.15.0). Without Flip it degrades to a scale-and-fade and everything else still works.
  • No build step, no framework

Worked examples, the events and programmatic API, and the class reference ship with the download, alongside the full source.