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.

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 focus-trap / keyboard script comes from the cloneable — copy it from there rather than reconstructing it, and confirm it matches your attribute names.

Resources

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.

← Back to the course