A dynamic, accessible video modal using in-page content

Managing multiple modals across a Webflow can be a real headache, & video modals can be even worse. This cloneable uses a single modal & therefore a single, reusable interaction for launching & dismissing the modal, it uses an attribute added to the link to easily target hidden content & it uses AJAX to populate the modal with that content. It's keyboard navigable, focus-trapped, & it clears the video on dismissal so you don't get lingering background audio.
Click a movie to launch the modal.

Implementation notes

Last updated: 2/10/2004

Step 1 - Building the UI

The UI for this project can be anything, all that's required is a link to trigger the modal.

Step 2 - Building the modal

The custom modal will consist of 4 major parts: the Modal Wrapper div, which will be shown/hidden in the custom animations, the Modal Background div, which darkens the page & will trigger the closing of the modal on click, the Modal class itself, which styles the modal, & an empty div with the ID #ajax-container, which will house the content pulled in from the collection item page.

The Modal Wrapper needs to be set to position: fixed; & set to full screen & overflow: auto;  so that the div will scroll if the content escapes the viewport. For this project we added a Modal Padding div to define the negative space around the modal. The Modal Background div is also set to position: fixed;& full screen. And the Modal class is set to position: relative; in order that it displays on top of the modal close trigger div.

We also placed a Modal Close Icon nested inside the Modal div. This must be before the #ajax-container div so that it's the first element tabbed into so it must have z-indexing set to display on top of the #ajax-container div. This element is a part of the modal itself, rather than the content, because importing an element with an interaction will break the interaction behavior.

Step 3 - Creating the modal animations

The animations are pretty straightforward... the Modal Wrapper div needs to be set to opacity: 0%; as an initial state, then animated to display: block;opacity: 100%; on click. Then it just needs to be animated back to opacity: 0%;display: none; on dismissal.

The trick to making the modal modular is to define the animations not on the element itself, but as individual classes that can be appended to any element to form a combo class. For this project, we're defining the launch animation on IX - Launch Modal & attaching it to the Movie Card element & we're defining the dismissal animation on IX - Dismiss Modal & attaching it to the Modal BackgroundModal Close Icon elements. These IX classes will need to be created as base classes so that the animations can be attached to them independently, rather than to the combo class. And the launch animations will need to affect all instances of Modal Wrapper while the dismissal animations will only need to affect parent instances.

Step 4 - Creating the modal content

For this project, the modal content is simply a YouTube element inside a padding div. The important part is that it's wrapped in a div with a unique ID, which is nested inside a Hide All div.

Step 5 - Adding the attributes

An attribute will need to be added to each modal link that corresponds with the content it should display in the modal. For instance, if the content div is #modal-godzilla-minus-one, then it's corresponding link will need to have data-modal-content-id="modal-godzilla-minus-one" attributed.

Step 6 - Add the javascript

Paste the following Javascript at the end of the <body> of the page.

<script>

var lastFocusedElement;

$(document).on('click', '.ix---launch-modal', function(event) {
    event.preventDefault();
    $('body').css('overflow', 'hidden');
    lastFocusedElement = this; // Store the clicked element

    // Prepend '#' to form a valid ID selector
    var contentId = '#' + $(this).data('modal-content-id');
    loadContent(contentId); // Load modal content
});

function loadContent(contentId) {
    var $content = $(contentId).html(); // Get the content by ID
    $("#ajax-container").html($content); // Insert content into the modal

    $(".modal-wrapper").attr('tabindex', '-1').focus();
    setupFocusTrap('.modal-wrapper');
}

function setupFocusTrap(modalSelector) {
    // Delay setup to ensure content is loaded and visible
    setTimeout(() => {
        var $modal = $(modalSelector);
        var focusableElementsSelector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
        var $focusableElements = $modal.find(focusableElementsSelector).filter(':visible');
        
        if ($focusableElements.length > 0) {
            var $firstFocusableElement = $focusableElements.first();
            var $lastFocusableElement = $focusableElements.last();

            // Ensure the modal itself is initially focused if no other focusable element is focused
            $modal.attr('tabindex', '-1').focus();

            // Trap focus within the modal
            $modal.off('keydown').on('keydown', function(e) {
                if (e.key === 'Tab') {
                    if (e.shiftKey) { // Shift + Tab
                        if (document.activeElement === $firstFocusableElement[0] || document.activeElement === $modal[0]) {
                            $lastFocusableElement.focus();
                            e.preventDefault();
                        }
                    } else { // Tab
                        if (document.activeElement === $lastFocusableElement[0]) {
                            $firstFocusableElement.focus();
                            e.preventDefault();
                        }
                    }
                }
            });
        }
    }, 100); // Adjust delay based on your modal's behavior
}

$('.ix---dismiss-modal').click(function() {
    $('body').css('overflow', 'auto');
    setTimeout(() => {
        $('#ajax-container').html('');
        if (lastFocusedElement) {
            $(lastFocusedElement).focus();
        }
    }, 200);
});

$(document).keydown(function(e) {
    if (e.key === 'Escape') {
        $('.ix---dismiss-modal').click(); // Trigger modal close
    }
});

</script>

Step 7 - Enabling tabbing into the YouTube iframe

Since we want this modal to be fully keyboard navigable, we'll need to add this javascript to append tabindex="0" to the YouTube iframe.

<script>

$(document).ready(function() {
    // Find all iframes with class '.embedly-embed' inside '.w-video' elements
    $('.w-embed-youtubevideo iframe').each(function() {
        // Set tabindex="0" to make the iframe focusable
        $(this).attr('tabindex', '0');
    });
});

</script>