Most popup tutorials give you a popup. This one gives you a production-ready modal: correct ARIA so screen readers understand it, a real focusable button to close it, a single reversible GSAP timeline for open and close, click-outside-to-close, and a proper scroll lock that behaves on mobile. It’s as much an accessibility lesson as an animation one.
The theme throughout is architecture over hacks. Every piece — the roles, the reversible timeline, the attribute-based triggers, the scroll lock — is chosen so the whole thing stays clean and reusable.
How it works
Accessibility comes first. The dialog element carries three ARIA attributes: role="dialog" tells assistive tech this is a dialog window rather than a generic container; aria-modal="true" signals that the background is not interactive while it’s open; and aria-labelledby="modal-title" links the dialog to its heading’s id, so a screen reader announces something like “dialog, Join our newsletter” instead of an unnamed box. Both the open and close controls are real buttons — custom elements with type="button", not Webflow’s native button (which renders as an <a>, i.e. navigation). A button is focusable, works with Enter/Space, and is announced as an action. Since they’re icon-only, each gets an aria-label (“close dialog”, “open join newsletter dialog”).
One timeline, reversed. The wrapper is position: fixed, full-viewport, high z-index, display: none by default. The open interaction is three actions: a set action flipping the wrapper’s display from none to flex at 0s, an animate action fading the wrapper’s opacity in (a from 0%), and an animate action sliding the popup up (a from on move-Y, e.g. 2rem). To close, you don’t build anything new — add a click trigger to the close button and set its control to reverse. GSAP plays the entire timeline backward.
Click-outside-to-close, done right. Attaching the close trigger to the wrapper directly would fire on every click, including inside the popup form. Instead, add an overlay div that fills the wrapper but sits behind the popup — since both have z-index: auto, stacking follows DOM order, so drag the overlay above the popup in the navigator to push it behind visually. Now clicks inside the popup never reach the overlay; only clicks outside do. Rather than maintain two close triggers (button + overlay), give both the same custom attribute (dialog-animation="close") and target the attribute — one trigger, cleaner structure.
Scroll lock is a state change, not an animation. Locking scroll means setting the body’s overflow to hidden; releasing it means visible. That’s instant, so it’s a set action, added at 0s of the open timeline and restored automatically on reverse. One mobile gotcha: changing the body’s overflow can shift viewport calculations and break the fixed wrapper, so give the wrapper height: 100dvh. Unlike vh (which is based on the initial viewport and doesn’t track the mobile browser UI expanding/collapsing), dvh — dynamic viewport height — always matches the real visible area.
How to use it
-
Structure + ARIA. Wrapper:
position: fixed, full, high z-index,display: none,height: 100dvh, centered flex. On the dialog:role="dialog",aria-modal="true",aria-labelledby="modal-title"(the heading’s id). Make open/close controls custombuttonelements witharia-labels. -
Open timeline. On the open button, a GSAP click interaction with three actions: set wrapper
display: flexat 0s; animate wrapper opacity from0%(0.8s, ease in-out power1); animate popup move-Y from2rem(same timing). -
Close by reverse. On the close button, add a click trigger with control: reverse.
-
Outside-click overlay. Add a
popup-closing-overlaydiv (position: absolute, full) and drag it above the popup in the navigator. Give the overlay and the close button the same attributedialog-animation="close", then point the close trigger at that attribute and delete the duplicate. -
Scroll lock. Add a set action at 0s targeting
body(custom selector), settingoverflow: hidden. Publish and test on the live link — open locks scroll, close restores it once the reverse finishes.