How to Build No-Code Modal Components for Wagtail CMS Content Editors

adonis simoadonis simo
9 min read

What is a modal ?

A good way to promote something on a website is to make it appear as a modal on several pages. It can appear after a few seconds or right after the user opens the site. It’s very handy when you can do that easily from the CMS, instead of relying on the development team to build it every time. Wagtail does not come with this feature by default, but it's not very difficult to imagine one. During my research, I found Codered's Modal Streamfield, which allows the user to define a popup directly on the page. It’s good, but it does not fit my needs since I have to create many of them if I want the same popup on many pages of my site. So I designed one according to the following needs:

  • I should be able to create it once.

  • I should be able to choose one or many pages where it will appear.

  • I should be able to make it appear after a certain time.

  • I should be able to create one but not display it.

  • I should allow the user to close a modal, and it doesn’t appear anymore during their session.

  • I should be able to edit its content using streamfields.

  • Not allow many popups on the same page to display on top of each other.

Storing the modal data

To do all these, I need a model to store the content of a popup, then I need to display it in the admin panel. So I created a Snippet and added a Snippet view to control how it displays in the admin. The following code shows how to do that.

class PageModal(models.Model):
    DEPTH_CHOICES = [
        (1, _('Direct children only')),
        (2, _('Children and grandchildren')),
        (3, _('Up to 3 levels deep')),
        (-1, _('All descendants (unlimited depth)')),
    ]

    title = models.CharField(
        verbose_name=_('Title'),
        max_length=255
    )

    content = StreamField([
        ('heading', CharBlock(label=_('Heading'))),
        ('paragraph', RichTextBlock(label=_('Paragraph'))),
        ('link', URLBlock(label=_('Link'))),
    ], use_json_field=True, verbose_name=_('Content'))

    # Modal display settings
    display_delay = models.PositiveIntegerField(
        default=0,
        verbose_name=_('Display delay'),
        help_text=_("Delay in seconds before showing the modal")
    )

    cta_text = models.CharField(
        verbose_name=_('Call to action text'),
        max_length=50,
        blank=True
    )

    cta_page = models.ForeignKey(
        'wagtailcore.Page',
        verbose_name=_('Call to action page'),
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name='+',
    )

    cta_link = models.URLField(
        verbose_name=_('Call to action external link'),
        blank=True
    )

    # Page association
    pages = models.ManyToManyField(
        'wagtailcore.Page',
        verbose_name=_('Pages'),
        related_name='modals',
        help_text=_("Select pages where this modal should appear")
    )

    include_children = models.BooleanField(
        verbose_name=_('Include children'),
        default=False,
        help_text=_("Show on child pages of selected pages")
    )

    child_depth = models.IntegerField(
        verbose_name=_('Child depth'),
        choices=DEPTH_CHOICES,
        default=1,
        help_text=_("How many levels of child pages should this modal appear on?"),
    )

    is_displayed = models.BooleanField(
        verbose_name=_('Is visible'),
        default=True,
        help_text=_("If enabled, the modal will be visible")
    )

    use_session_storage = models.BooleanField(
        verbose_name=_('Use session storage'),
        default=False,
        help_text=_('If enabled, the modal will only show once per session')
    )
    panels = [
        MultiFieldPanel([
            FieldPanel('title'),
            FieldPanel('content'),
        ], heading="Modal Content"),
        MultiFieldPanel([
            FieldPanel('cta_text'),
            FieldRowPanel([
                FieldPanel('cta_page'),
                FieldPanel('cta_link'),
            ], heading="CTA Link (choose one)"),
        ], heading="Call To Action Settings"),

        MultiFieldPanel([
            FieldPanel('display_delay'),
            FieldRowPanel([
                FieldPanel('is_displayed'),
                FieldPanel('use_session_storage'),
            ], heading="Visibility Settings")
        ], heading="Display Settings"),
        MultiFieldPanel([
            FieldPanel('pages', widget=forms.CheckboxSelectMultiple),
            FieldRowPanel([
                FieldPanel('include_children'),
                FieldPanel('child_depth'),
            ], heading="Child Page Settings"),
        ], heading="Page Selection"),
    ]

    def __str__(self):
        return self.title

    def get_cta_url(self):
        if self.cta_page:
            return self.cta_page.url
        return self.cta_external_url or ''


class PageModalAdmin(SnippetViewSet):
    model = PageModal
    menu_label = 'Page Modals'
    menu_icon = 'placeholder'
    menu_order = 300
    add_to_settings_menu = True
    list_display = ('title', 'is_displayed', 'display_delay', 'include_children')

This gives me the base setup to start. Let’s see what this model allows us to do:

  • Define the content of the popup using title and content attributes.

  • Store the display settings (timeout, is it displayed, etc.).

  • Store the page where I want it to be displayed.

  • Store information to know if it should be displayed in children of the selected page or not.

  • Store all the links and texts for the CTA (Call To Action) of the modal.

The admin page looks like this:

Sending the modals to the frontend

Loading the modals on each page

The PageModalAdmin Viewset helps display it properly on the admin page. I can define which columns to show in the list of modals (popups). The next step is to load each available popup into the site frontend. We will use a template tag to inject all the modals registered to the page being rendered. The following snippet does that:

# modal_tags.py
import json
from django import template
from django.core.serializers.json import DjangoJSONEncoder


register = template.Library()


@register.inclusion_tag('home/modals/page_modal.html', takes_context=True)
def page_modals(context):
    current_page = context.get('page')

    if not current_page:
        return {'modals': []}

    # Get modals directly assigned to this page
    modals = current_page.modals.filter(is_displayed=True)

    ancestors = current_page.get_ancestors()
    for ancestor in ancestors:
        # Get all modals that:
        # 1. Are assigned to this ancestor AND
        # 2. Have include_children enabled AND
        # 3. Meet the depth requirement
        ancestor_modals = ancestor.modals.filter(include_children=True, is_displayed=True)

        for modal in ancestor_modals:
            # Calculate the distance between the ancestor and current page
            distance = len(current_page.get_ancestors()) - len(ancestor.get_ancestors())

            # Check if this page is within the specified depth
            if modal.child_depth == -1 or distance <= modal.child_depth:
                modals = modals | ancestor_modals.filter(pk=modal.pk)

    modal_data = []
    for modal in modals:
        modal_data.append({
            'title': modal.title,
            'content': list(modal.content.raw_data),
            'display_delay': modal.display_delay,
            'cta_text': modal.cta_text,
            'cta_link': modal.cta_link,
            'cta_page': modal.cta_page.url,
            'use_session_storage': modal.use_session_storage,
        })
    return {
        'modals': json.dumps(modal_data, cls=DjangoJSONEncoder)
    }

And include the result in the base template this way.

{% load modal_tags %}
<head>
{% page_modals %}
</head>

Handling the modal’s display

We also need JS code that will process the available popups and decide whether to display them. In the previous template tag, I added a template named home/modals/page_modal.html. Here is the content:

{% if modals %}
<script>
    class ModalQueue {
        static queue = [];
        static currentlyShowing = false;

        static add(modal) {
            this.queue.push(modal);
            this.processQueue();
        }

        static processQueue() {
            if (this.currentlyShowing || this.queue.length === 0) return;

            const nextModal = this.queue[0];
            this.currentlyShowing = true;
            nextModal.display();
        }

        static modalClosed() {
            this.queue.shift();
            this.currentlyShowing = false;
            this.processQueue();
        }
    }

    class ModalHandler {
        constructor(modalData) {
            this.modal = modalData;
            this.shown = false;
            this.storageKey = `modal_${this.modal.title.replace(/\s+/g, '_').toLowerCase()}`;
            this.init();
        }

        init() {
            // Only check session storage if the feature is enabled
            if (this.modal.use_session_storage && this.hasBeenShown()) {
                return;
            }

            setTimeout(() => {
                ModalQueue.add(this);
            }, this.modal.display_delay * 1000);
        }

        hasBeenShown() {
            return sessionStorage.getItem(this.storageKey) === 'shown';
        }

        markAsShown() {
            if (this.modal.use_session_storage) {
                sessionStorage.setItem(this.storageKey, 'shown');
            }
        }

        display() {
            if (this.shown) return;

            const modalHtml = `
                <div class="fixed inset-0 z-50 flex items-center justify-center modal-container">
                    <!-- Backdrop -->
                    <div class="absolute inset-0 bg-black opacity-80"></div>

                    <!-- Modal content -->
                    <div class="relative z-10 bg-white rounded-lg max-w-lg w-2/3 mx-auto p-6">
                        <h2 class="text-2xl font-bold mb-4">${this.modal.title}</h2>
                        <div class="modal-content mb-6">
                            ${this.renderContent()}
                        </div>
                        ${this.renderCTA()}
                        <button class="close-modal absolute top-4 right-4 text-gray-500 hover:text-gray-700">
                            <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
                            </svg>
                        </button>
                    </div>
                </div>
            `;

            document.body.insertAdjacentHTML('beforeend', modalHtml);
            this.shown = true;
            this.markAsShown();

            const modalElement = document.querySelector('.modal-container');
            const closeButton = modalElement.querySelector('.close-modal');

            const closeModal = () => {
                modalElement.classList.add('opacity-0');
                setTimeout(() => {
                    modalElement.remove();
                    ModalQueue.modalClosed();
                }, 200);
            };

            closeButton.addEventListener('click', closeModal);
            modalElement.addEventListener('click', (e) => {
                if (e.target === modalElement) closeModal();
            });
        }

        renderContent() {
            return this.modal.content.map(block => {
                switch (block.type) {
                    case 'heading':
                        return `<h3 class="text-xl font-bold mb-2">${block.value}</h3>`;
                    case 'paragraph':
                        return `<div class="prose max-w-none mb-4">${block.value}</div>`;
                    case 'link':
                        return `<a href="${block.value}" class="text-primary-600 hover:underline block mb-2">${block.value}</a>`;
                    default:
                        return '';
                }
            }).join('');
        }

        renderCTA() {
            if (!this.modal.cta_text) return '';

            const url = this.modal.cta_page || this.modal.cta_url;
            const target = this.modal.cta_url ? 'target="_blank" rel="noopener noreferrer"' : '';


            return `
                <a href="${url}" ${target}
                   class="inline-block bg-primary-600 text-white px-6 py-2 rounded hover:bg-primary-700 transition-colors duration-200">
                    ${this.modal.cta_text}
                </a>
            `;
        }
    }
    document.addEventListener('DOMContentLoaded', function() {
        const modals = {{ modals|safe }};
        modals.forEach(modalData => {
            new ModalHandler(modalData);
        });
    });
</script>
{% endif %}

The modal handler

The ModalHandler class is called as many times as there are modals to display on the page. Everything starts here:

document.addEventListener('DOMContentLoaded', function() {
        const modals = {{ modals|safe }};
        modals.forEach(modalData => {
            new ModalHandler(modalData);
        });
    });

It loads the JSON representation of a popup using {{modals|safe}} into an instance of the ModalHandler class. Let’s break down what it does.

constructor(modalData) {
            this.modal = modalData;
            this.shown = false;
            this.storageKey = `modal_${this.modal.title.replace(/\s+/g, '_').toLowerCase()}`;
            this.init();
        }

Store all the data in the instance, and it also creates a storageKey, which is important for later. It finishes by calling the .init() method.

init() {
    // Only check session storage if the feature is enabled
    if (this.modal.use_session_storage && this.hasBeenShown()) {
        return;
    }

    setTimeout(() => {
      ModalQueue.add(this);
    }, this.modal.display_delay * 1000);
}

This method checks if the popup should be displayed only once per session by verifying if it can use_session_storage and if it has already been shown. The method (hasBeenShown()) does this by checking the browser’s sessionStorage. The init() method uses setTimeout to schedule the display of the modal by adding it to a ModalQueue and setting the timeout to the number of seconds specified by the user. This is important because, due to the nature of this system, the same page might need to display many popups at the same time, but we don’t want to display all of them at once. So, we enqueue them while considering the number of seconds after which they should appear.

Many modals, so let's enqueue them !

From this point on, the class ModalQueue handles the lifecycle of a modal. Let’s study it.

static add(modal) {
            this.queue.push(modal);
            this.processQueue();
        }

        static processQueue() {
            if (this.currentlyShowing || this.queue.length === 0) return;

            const nextModal = this.queue[0];
            this.currentlyShowing = true;
            nextModal.display();
        }

        static modalClosed() {
            this.queue.shift();
            this.currentlyShowing = false;
            this.processQueue();
        }

The .add() method inserts a popup into the queue and calls processQueue(), which will check if there is a modal currently being displayed or if there is no popup in the queue. If not, it will get the nextModal from the queue and call its .display() method (remember this is in the ModalHandler class). This method is responsible for displaying the actual modal on the page. The modalClosed is called when a modal is closed so it can trigger the queue processing to check if there is a popup to display.

Let’s study the display() method from the ModalHandler. It contains the actual HTML code that renders the modal. It does this by calling the methods renderContent and renderCTA, whose job is to load the various parts of the modal. After displaying, it sets up the necessary actions for when the user closes it. Here is the code block.

const modalElement = document.querySelector('.modal-container');
const closeButton = modalElement.querySelector('.close-modal');
const closeModal = () => {
     modalElement.classList.add('opacity-0');
     setTimeout(() => {
        modalElement.remove();
        ModalQueue.modalClosed();
     }, 200);
};
closeButton.addEventListener('click', closeModal);
modalElement.addEventListener('click', (e) => {
    if (e.target === modalElement) closeModal();
});

It basically adds an event listener to the click event and links it to both the closeButton and the modalElement. Notice how the closeModal() function works: it removes the modalElement (which is the modal itself) and calls ModalQueue.modalClosed(). This last method is part of the queue handler.

static modalClosed() {
    this.queue.shift();
    this.currentlyShowing = false;
    this.processQueue();
}

It removes the last modal from the queue and marks that there is no modal being displayed, and then it calls processQueue() to verify if there is another popup to be displayed.

The end result

And the result look like this on the frontend of the site.

Note this code is compatible with TailwindCss and not bootstrap, you may need to adapt if you wan to use for your own site.

Conclusion

There are still many possible improvements, like allowing different styles of modals, setting the background color, or adjusting the width from the back office page, among other things. As a Wagtail developer, I prefer to make most features editable by my clients through the admin panel. This approach significantly reduces ongoing work for simple tasks that users might request while serving visitors on the site.

I am Adonis SIMO, and I build web applications and websites with Django and Wagtail. Feel free to contact me if you want to discuss a project. I would be happy to help.

1
Subscribe to my newsletter

Read articles from adonis simo directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

adonis simo
adonis simo

I’m a Certified Cloud & Software Engineer based in Douala, Cameroon, with over 4 years of experience. I am currently working as a Remote Tech Product Manager at Workbud. I am technically proficient with python, JavaScript and AWS Cloud Platform. In the last couple of years I have been involved in the product management world and I love writing articles and mentoring because I enjoy sharing knowledge.