Build an Accessible GSAP Popup in Webflow (Scroll Lock)

Advanced 23:50 webflowgsapaccessibilitypopupscroll-lock

Build an accessible modal popup in Webflow with GSAP — proper ARIA roles, a reversible open/close timeline, outside-click to close, and mobile-safe scroll lock.

Key takeaways

  • A modal's accessibility lives in three ARIA attributes: role="dialog", aria-modal="true", and aria-labelledby pointing to the heading's id so screen readers announce it by name.
  • Use a real button (a custom element with type=button), not a styled link — buttons are focusable, keyboard-operable, and announced as actions; add an aria-label to icon-only buttons.
  • One reversible timeline handles open AND close: build the open animation, then set the close trigger's control to reverse — no duplicate animation.
  • Close-on-outside-click needs a dedicated overlay layer behind the popup (so clicks inside the popup don't close it), and merging the close triggers is cleanest via a shared custom attribute.
  • Scroll lock is an instant state change, so use GSAP's set action to flip the body's overflow to hidden; on mobile, size the fixed wrapper with 100dvh (not vh) so browser-UI changes don't break it.

Video chapters

  1. 00:00 Intro
  2. 01:16 Analyzing popup structure and accessibility
  3. 08:07 Building the open/close popup interaction with GSAP
  4. 13:39 Closing the popup by clicking outside it
  5. 16:41 Using HTML attributes to merge multiple triggers
  6. 18:45 Locking page scroll when the popup is open
  7. 23:03 Outro

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

  1. 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 custom button elements with aria-labels.

  2. Open timeline. On the open button, a GSAP click interaction with three actions: set wrapper display: flex at 0s; animate wrapper opacity from 0% (0.8s, ease in-out power1); animate popup move-Y from 2rem (same timing).

  3. Close by reverse. On the close button, add a click trigger with control: reverse.

  4. Outside-click overlay. Add a popup-closing-overlay div (position: absolute, full) and drag it above the popup in the navigator. Give the overlay and the close button the same attribute dialog-animation="close", then point the close trigger at that attribute and delete the duplicate.

  5. Scroll lock. Add a set action at 0s targeting body (custom selector), setting overflow: hidden. Publish and test on the live link — open locks scroll, close restores it once the reverse finishes.

Resources

Frequently asked questions

What ARIA attributes does an accessible modal popup need?
Three on the dialog element: role="dialog" (so screen readers treat it as a dialog, not a generic container), aria-modal="true" (so the background is announced as non-interactive), and aria-labelledby set to the id of the popup's heading, which gives the dialog an accessible name announced on open.
Should a popup close button be a link or a button in Webflow?
A button. Use a custom element with type set to button — it's focusable, works with Enter and Space, and is announced as an action. Webflow's native button renders as a link (an <a>), which represents navigation, not an action. For an icon-only button, add an aria-label like "close dialog" so it isn't announced as just "button".
How do I open and close a popup with a single GSAP animation?
Build the open animation once (e.g. set the wrapper display to flex, fade it in, slide the popup up). Then on the close trigger set the control property to reverse — GSAP plays the whole timeline backward, so you never build a separate closing animation.
How do I close a popup when the user clicks outside it?
Add an overlay div that fills the wrapper but sits behind the popup in the DOM (drag it above the popup in the navigator so the popup stays on top). Give it the close trigger. Because it's behind the popup, clicks inside the popup don't reach it — only clicks outside close the modal. Merge it with the close button by giving both the same custom attribute.
How do I lock page scroll when a popup is open, and why use dvh?
Scroll locking is just setting the body's overflow to hidden — an instant change, so use GSAP's set action (not animate) at the start of the open timeline, and it's restored automatically when the timeline reverses. Give the fixed wrapper height: 100dvh so that on mobile, where the browser UI expands and collapses, the overlay always matches the real visible viewport.

← Back to the course