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

In the knowledge base

Reference pages derived from this lesson — the same material reorganised by concept, so you can look one thing up without rewatching. In English.

  • Build an accessible dialog or slide-in panel

    A dialog is one GSAP timeline played forward to open and reversed to close, plus four non-negotiable ARIA attributes, real button elements, an outside-click overlay stacked behind the panel, and a scroll lock — and it is not finished until focus is managed.

  • Lock page scroll while an overlay is open

    Scroll locking in Webflow is a state change, not an animation, so it belongs in a GSAP set action at the start of the open timeline — either flipping body overflow to hidden via a custom selector, or toggling an overflow-hidden class when the close animation needs to finish first.

  • A full-height overlay is cut off or jumps on mobile

    vh is measured against the initial viewport and does not track the mobile browser's UI expanding and collapsing, so a 100vh fixed overlay is taller than the visible area and its bottom is unreachable; use 100dvh, which always matches the real visible height.

  • It works in the Designer but not live — or the reverse

    Custom code, non-standard CSS properties, first-paint flicker, scroll lock and keyboard focus all behave differently in the Designer and in plain preview than on the published site, and the Designer additionally shows you your animations' initial states, so half-hidden content while building is expected rather than broken.

  • overflow hidden on the body — right for scroll lock, wrong for clipping

    Overflow hidden on the body does exactly one thing — it disables scrolling for the whole page — so using it to crop something that spills sideways breaks the site to fix one section, while using it as an overlay's scroll lock is correct because there disabling scroll is the goal; clip at the container instead, and make sure every close path releases the lock.

  • Reordering in the navigator doesn't change what's on top

    An absolutely positioned element paints above a static sibling no matter where it sits in the DOM, so reordering does nothing; give the shared parent position: relative so both children keep z-index: auto, and then DOM order decides — the element that comes later renders on top.

  • Webflow's native Button is wrong for animated controls

    Webflow's native Button element renders as an anchor and cannot nest children, which makes it wrong twice over — semantically wrong for anything that performs an action rather than navigating, and structurally useless for the layered hover effects that need an icon or overlay inside.

  • Verified identifiers

    Every exact attribute name, class name, variable name, SVG id and selector used across the GSAP-in-Webflow corpus, each traced to the cloneable it was copied from; copy these verbatim and never reconstruct one from memory, because a wrong identifier fails silently.

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.