Accessible CMS Sidebar in Webflow (Scroll Lock + Focus Trap)

Advanced 49:08 webflowgsapaccessibilitycmsfocus-trap

Build a CMS-powered sidebar in Webflow with GSAP — per-card content, slide-in, scroll lock, outside-click close, and a real focus trap for keyboard users.

Key takeaways

  • Put one sidebar inside each CMS collection item, so every card gets its own panel bound to that item's content — there's no single shared sidebar to wire up.
  • GSAP's target filters (previous sibling of, parent of, contains the trigger) let one attribute-driven interaction find the right sidebar relative to whichever card was clicked.
  • Scroll lock is an overflow-hidden class toggled by a set action at the start of the open animation and removed at the end of the close (start 0.8s), so the page never scrolls behind an open panel.
  • Accessibility is more than a slide-in: role="dialog", aria-modal="true", aria-labelledby tied to the heading's id (via the CMS slug), real button elements with aria-labels, and aria-hidden on decorative icons.
  • A true dialog needs a focus trap — a small script that moves focus into the panel on open, keeps Tab cycling inside it, closes on Escape, and returns focus to the card that opened it.

Video chapters

  1. 00:00 Intro
  2. 00:49 Building the sidebar structure
  3. 19:47 Crafting the open interaction
  4. 32:45 Creating the close interaction
  5. 43:19 Completing the accessibility setup
  6. 48:05 Outro

This lesson builds a sidebar that does far more than slide in from the edge. Each card in a CMS grid opens its own sidebar with extra detail, the page scroll locks while it’s open, you can dismiss it by clicking outside, and — the part most tutorials skip — it’s genuinely accessible: proper dialog semantics and a real focus trap for keyboard and screen-reader users.

The interesting techniques are two: using GSAP’s relationship-based target filters so one interaction serves every card, and layering the accessibility that turns a pretty panel into a proper dialog.

How it works

One sidebar per CMS item. Because the sidebar lives inside the collection item, every card renders its own panel bound to that item’s fields — thumbnail, title, summary, sidebar image. Click a card and the matching sidebar is already right there. The sidebar wrapper is position: fixed, full-viewport, high z-index, display: none by default; inside it sit a semi-transparent overlay (position: absolute, full) and a content wrapper pushed right (margin-left: auto, ~50% width, max-width, overflow: auto so tall content stays scrollable).

Attribute-driven targeting with filters. Since there are many identical sidebars, the interaction can’t target by class — it has to find this card’s sidebar. The trick is custom attributes (data-animate, data-sidebar-open, data-sidebar-close) plus GSAP’s target filters, which target relative to the trigger: previous sibling of trigger (the sidebar next to the open button), within parent of trigger (the overlay/content inside the shared item), and contains trigger (the sidebar that wraps whichever close control was clicked). One interaction, correct target every time.

Open, close, and scroll lock. The open interaction is a set action flipping the sidebar’s display to flex (0s), an animate fading the overlay in (from opacity 0), an animate sliding the content in (from move-X 100%), and a set action adding an overflow-hidden class to the body to lock scroll. The close interaction is the reverse: fade the overlay out, slide the content out, then — at start 0.8s, once the animation finishes — set display back to none and remove the scroll-lock class. Both the close button and the overlay carry data-sidebar-close, so a single close interaction handles button-click and click-outside.

Real accessibility, not decoration. Every element that triggers an action is a real button (custom element, type="button") with an aria-label, and decorative icons get aria-hidden="true". The sidebar itself gets role="dialog", aria-modal="true", and aria-labelledby pointing at the heading’s id — set from the CMS slug so each item’s dialog announces its own title. Finally tabindex="-1" makes the panel focusable by script without adding it to the tab order.

The focus trap. A dialog isn’t accessible until focus is managed. A small script (in Before </body>) remembers which card opened the panel, moves focus into the sidebar (to the close button) on open, traps Tab so it cycles through the panel’s controls instead of the page behind it, closes on Escape, and returns focus to the originating card on close. That’s what makes it behave like a true modal dialog for keyboard users. Here’s that script, straight from the cloneable:

<!-- Site Settings → Custom Code → Before </body> tag -->
<script>
  document.addEventListener('DOMContentLoaded', () => {
    let lastTrigger = null;

    const focusableSelector = `
    a[href],
    button:not([disabled]),
    input:not([disabled]),
    textarea:not([disabled]),
    select:not([disabled]),
    [tabindex]:not([tabindex="-1"])
  `;

    document.querySelectorAll('[data-sidebar-open]').forEach((trigger) => {
      trigger.addEventListener('click', () => {
        lastTrigger = trigger;

        const item = trigger.closest("[role='listitem']");
        const sidebar = item?.querySelector('[data-animate="sidebar"]');
        if (!sidebar) return;

        setTimeout(() => {
          const closeButton = sidebar.querySelector('[data-sidebar-close-button]');
          if (closeButton) closeButton.focus({ preventScroll: true });
          else sidebar.focus({ preventScroll: true });
        }, 50);
      });
    });

    document.addEventListener('keydown', (event) => {
      const openSidebar = Array.from(document.querySelectorAll('[data-animate="sidebar"]')).find((sidebar) => getComputedStyle(sidebar).display !== 'none');

      if (!openSidebar) return;

      if (event.key === 'Escape') {
        const closeButton = openSidebar.querySelector('[data-sidebar-close-button]') || openSidebar.querySelector('[data-sidebar-close]');

        closeButton?.click();

        setTimeout(() => {
          lastTrigger?.focus({ preventScroll: true });
        }, 50);

        return;
      }

      if (event.key !== 'Tab') return;

      const focusable = Array.from(openSidebar.querySelectorAll(focusableSelector)).filter((el) => el.offsetParent !== null);

      if (!focusable.length) {
        event.preventDefault();
        openSidebar.focus({ preventScroll: true });
        return;
      }

      const first = focusable[0];
      const last = focusable[focusable.length - 1];

      if (event.shiftKey && document.activeElement === first) {
        event.preventDefault();
        last.focus({ preventScroll: true });
      }

      if (!event.shiftKey && document.activeElement === last) {
        event.preventDefault();
        first.focus({ preventScroll: true });
      }
    });

    document.querySelectorAll('[data-sidebar-close]').forEach((closeButton) => {
      closeButton.addEventListener('click', () => {
        setTimeout(() => {
          lastTrigger?.focus({ preventScroll: true });
        }, 50);
      });
    });
  });
</script>

How to use it

  1. Structure (inside the CMS item). sidebar div: position: fixed, full, z-index ~3000, display: none. Inside: an overlay (position: absolute, full, black 50%) and a sidebar_content-wrapper (margin-left: auto, ~50% width, max-width, overflow: auto, position: relative). Bind image/title/summary to CMS fields.

  2. Tag everything. data-animate on the sidebar, overlay, and content wrapper (distinct values); data-sidebar-open on the card’s open button; data-sidebar-close on both the close button and the overlay. Make the open/close/overlay controls real button custom elements with aria-labels.

  3. Open interaction. set display flex (target sidebar via previous sibling of trigger), animate overlay opacity from 0, animate content move-X from 100%, set overflow-hidden on body — all at 0s, 0.8s, ease in-out expo.

  4. Close interaction. Trigger on data-sidebar-close. Reverse the animations (overlay to 0, content to 100%), then at start 0.8s set display none (target via contains trigger) and remove the overflow-hidden class.

  5. Accessibility. On the sidebar: role="dialog", aria-modal="true", aria-labelledby=<slug> (and set the heading’s id to the same slug), tabindex="-1". Paste the focus-trap script into *Before </body>*. Publish and test with the keyboard (Tab, Escape) on the live link.

The script above is the one from the cloneable. Note the attribute names it expects: the panel is data-animate="sidebar" and lives inside a [role="listitem"]; the close control inside it is data-sidebar-close-button (that’s where focus lands on open). Match these to your build.

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.

  • Target filters — resolving a target relative to the trigger

    An action's target is not one setting but three composed into a sentence — what to target, a filter, and the element the filter is measured against — so "the sidebar that is the previous sibling of the trigger" is Previous sibling of + Trigger element, not a single menu option; this is what lets one interaction serve every card in a CMS collection.

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

  • Trap keyboard focus inside an open panel

    A GSAP interaction can animate a dialog but cannot manage focus, so a keyboard user Tabs straight out of it into the page behind; this script moves focus in on open, cycles Tab inside the panel, closes on Escape, and returns focus to the control that opened it.

  • A style cleanup deletes the classes your interactions toggle

    A class that exists only to be added by a GSAP set action is applied to no element, so Webflow flags it as unused and any style cleanup deletes it — publishing itself does not strip it, which means this breaks work that was already correct, later, when someone tidies the project; keep an element wearing the class so it is genuinely used and never appears in a cleanup list.

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

  • 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

How do I give each CMS card its own sidebar in Webflow?
Place the sidebar div inside the collection item itself. Because it's part of the item template, every card renders its own sidebar bound to that item's fields (image, title, summary), so clicking a card always opens the panel with the matching content — no single shared sidebar to manage.
How does one interaction open the correct sidebar for the clicked card?
Drive it with custom attributes and GSAP's target filters instead of class names. Depending on where the target sits relative to the trigger, use "previous sibling of trigger", "within parent of trigger", or "contains trigger" — so the same interaction resolves to the right sidebar for whichever card fired it.
How do I lock the page scroll while a sidebar is open?
Create an overflow-hidden class (overflow: hidden on the body) and toggle it with a GSAP set action: add it at the start of the open animation, remove it at the end of the close (start 0.8s, matching the animation length) so scroll is restored only once the panel is fully closed. Keep the class applied somewhere in your style guide so Webflow doesn't prune it.
What makes a Webflow sidebar accessible?
On the panel: role="dialog", aria-modal="true", and aria-labelledby pointing to the heading's id (set from the CMS slug so it's unique per item). Make the close and overlay triggers real button elements with type="button" and an aria-label, and add aria-hidden="true" to purely decorative icons.
How do I add a focus trap and keyboard support to a sidebar?
Give the panel tabindex="-1" (focusable by script, not in the tab order) and add a small script that, on open, moves focus to the close button, keeps Tab cycling within the panel, closes on Escape, and returns focus to the card that opened it when it closes.