Skip to main content
brickwork
GitHub Log in Get started
Display
Theme
Density
Direction
Brand

Documentation

Modal

An edge-independent modal dialog with a full-page fallback.

All components Component Brickwork 4.3.1

Try it

validated-form

Open full-page preview

Open the configured overlay, submit an empty message for an inline 422 error, then submit valid text to close it and return focus. The link is a full-page no-JavaScript route.

Use for a focused task or confirmation; use step state inside its body instead of nesting modals.

Use it

Working invocation

The live demonstration uses this site's routes. For a portable implementation, download the complete Django and Brickwork cookbook: every file below is shown exactly as it ships in the archive.

Download the consumer cookbook

component_cookbook/views.py

Python

"""Deterministic interaction endpoints: no models, storage, or external calls."""
from __future__ import annotations

from django.contrib import messages
from django.core.paginator import Paginator
from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest
from django.shortcuts import redirect, render
from django.urls import reverse
from django.utils.cache import patch_vary_headers
from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _
from django.views.decorators.http import require_http_methods, require_POST

from .forms import FilterForm, LanguageForm, MessageForm, UploadForm

LANGUAGES = (("python", "Python"), ("django", "Django"), ("htmx", "HTMX"), ("alpine", "Alpine"))
ROWS = tuple({"name": name, "status": status} for name, status in (
    ("Modal response", "ready"), ("Combobox endpoint", "draft"), ("Toast delivery", "ready"),
    ("No-JavaScript floor", "draft"), ("Slide-over focus", "ready"), ("Tooltip focus", "draft"),
    ("Dropdown links", "ready"), ("Native disclosure", "draft"), ("Tag input carrier", "ready"),
    ("Dropzone metadata", "draft"), ("Table selection", "ready"), ("Search request", "draft"),
))


def _htmx(request: HttpRequest) -> bool:
    return request.headers.get("HX-Request") == "true"


def _branch(request: HttpRequest, partial: str, page: str, context: dict, status: int = 200) -> HttpResponse:
    response = render(request, partial if _htmx(request) else page, context, status=status)
    patch_vary_headers(response, ("HX-Request",))
    return response


def _page(request: HttpRequest, template: str, **context) -> HttpResponse:
    return render(request, template, context)


def index(request): return _page(request, "component_cookbook/index.html")
def dropdown(request):
    return _page(request, "component_cookbook/dropdown.html", items=[
        {"label": _("Open cookbook"), "url": reverse("component_cookbook:index"), "icon": "external-link"},
        {"divider": True}, {"label": _("Delete draft"), "url": "#delete", "variant": "danger", "icon": "trash"},
    ])
def tabs(request):
    active = request.GET.get("tab", "overview")
    if active not in {"overview", "api"}:
        return HttpResponseBadRequest("Unknown cookbook tab.")
    return _page(request, "component_cookbook/tabs.html", active=active, tabs=[{"key": "overview", "label": _("Overview")}, {"key": "api", "label": "API"}], overview=mark_safe("<p>Overview content comes from this consumer template.</p>"), api=mark_safe("<p>The API panel uses the same package tab relationship.</p>"))
def tooltip(request): return _page(request, "component_cookbook/tooltip.html", tooltip_id="cookbook-tooltip", text=_("Explains the button"), placement="top")
def disclosure(request): return _page(request, "component_cookbook/disclosure.html")
def tag_input(request): return _page(request, "component_cookbook/tag_input.html")


def modal(request):
    return _branch(request, "component_cookbook/partials/modal.html", "component_cookbook/modal.html", {"form": MessageForm(), "modal_id": "cookbook-message-modal", "close_href": reverse("component_cookbook:index")})


@require_POST
def modal_submit(request):
    form = MessageForm(request.POST)
    context = {"form": form, "modal_id": "cookbook-message-modal", "close_href": reverse("component_cookbook:index")}
    if not form.is_valid():
        return _branch(request, "component_cookbook/partials/modal.html", "component_cookbook/modal.html", context, 422 if _htmx(request) else 200)
    if _htmx(request):
        response = HttpResponse(status=204)
        response["HX-Trigger"] = '{"bw:modal:close": {"id": "cookbook-message-modal"}}'
        return response
    messages.success(request, _("The demo accepted your message without saving it."))
    return redirect("component_cookbook:modal")


def slide_over(request):
    size = request.GET.get("size", "md")
    placement = request.GET.get("placement", "end")
    if size not in {"sm", "md", "lg"} or placement not in {"start", "end"}:
        return HttpResponseBadRequest("Invalid slide-over options.")
    return _branch(request, "component_cookbook/partials/slide_over.html", "component_cookbook/slide_over.html", {"close_href": reverse("component_cookbook:index"), "size": size, "placement": placement})


@require_POST
def toggle(request):
    return _branch(request, "component_cookbook/partials/toggle_outcome.html", "component_cookbook/toggle.html", {"enabled": request.POST.get("notices") == "on"})


@require_POST
def toast(request):
    if not _htmx(request):
        messages.success(request, _("The server delivered this demonstration outcome."))
        response = redirect("component_cookbook:toast-page")
    else:
        response = render(request, "component_cookbook/partials/toast_oob.html")
    patch_vary_headers(response, ("HX-Request",))
    return response


def toast_page(request):
    return _page(request, "component_cookbook/toast_region.html")


def toast_region(request):
    return _page(request, "component_cookbook/toast_region.html")


def combobox(request): return _page(request, "component_cookbook/combobox.html", form=LanguageForm(), options_url=reverse("component_cookbook:combobox-options"))


def combobox_options(request):
    query = request.GET.get("q", "").strip().casefold()[:40]
    return render(request, "component_cookbook/partials/combobox_options.html", {"choices": [choice for choice in LANGUAGES if not query or query in choice[1].casefold()]})


@require_POST
def combobox_submit(request):
    form = LanguageForm(request.POST)
    context = {"form": form, "options_url": reverse("component_cookbook:combobox-options")}
    if form.is_valid():
        context["selected_label"] = dict(LANGUAGES)[form.cleaned_data["language"]]
    return _branch(request, "component_cookbook/partials/combobox_form.html", "component_cookbook/combobox.html", context, 422 if _htmx(request) and not form.is_valid() else 200)


def _rows(query: str, status: str):
    return [row for row in ROWS if (not query or query.casefold() in row["name"].casefold()) and (not status or row["status"] == status)]


def table(request):
    form = FilterForm(request.GET)
    query, status = (form.cleaned_data["q"], form.cleaned_data["status"]) if form.is_valid() else ("", "")
    context = {"filter_form": form, "page_obj": Paginator(_rows(query, status), 4).get_page(request.GET.get("page", 1)), "table_url": reverse("component_cookbook:table"), "form_invalid": not form.is_valid()}
    return _branch(request, "component_cookbook/partials/table_region.html", "component_cookbook/table.html", context)


def search(request):
    query = request.GET.get("q", "").strip()[:40]
    return _page(request, "component_cookbook/search.html", query=query, result_count=len(_rows(query, "")), search_url=reverse("component_cookbook:search"))


@require_http_methods(["GET", "POST"])
def upload(request):
    if request.method == "GET":
        return _page(request, "component_cookbook/upload.html", form=UploadForm())
    form = UploadForm(request.POST, request.FILES)
    context = {"form": form}
    if form.is_valid():
        uploaded = form.cleaned_data["file"]
        context["outcome"] = _("Validated %(name)s (%(size)s bytes). The application did not retain it.") % {"name": uploaded.name, "size": uploaded.size}
        context["form"] = UploadForm()
    return _branch(request, "component_cookbook/partials/upload_form.html", "component_cookbook/upload.html", context, 422 if _htmx(request) and not form.is_valid() else 200)

component_cookbook/forms.py

Python

from django import forms
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _


class MessageForm(forms.Form):
    message = forms.CharField(max_length=80, help_text=_("Up to 80 characters."))


class LanguageForm(forms.Form):
    language = forms.ChoiceField(choices=(("", _("Choose a language")), ("python", "Python"), ("django", "Django"), ("htmx", "HTMX")))


class FilterForm(forms.Form):
    q = forms.CharField(label=_("Filter examples"), max_length=40, required=False)
    status = forms.ChoiceField(required=False, choices=(("", _("All statuses")), ("ready", _("Ready")), ("draft", _("Draft"))))


class UploadForm(forms.Form):
    file = forms.FileField()

    def clean_file(self):
        uploaded = self.cleaned_data["file"]
        if uploaded.size > 256 * 1024:
            raise ValidationError(_("Choose a file smaller than 256 KB."))
        if uploaded.content_type not in {"text/plain", "application/pdf"}:
            raise ValidationError(_("Choose a plain text file or PDF."))
        return uploaded

component_cookbook/urls.py

Python

from django.urls import path

from . import views

app_name = "component_cookbook"

urlpatterns = [
    path("", views.index, name="index"),
    path("dropdown/", views.dropdown, name="dropdown"),
    path("tabs/", views.tabs, name="tabs"),
    path("tooltip/", views.tooltip, name="tooltip"),
    path("disclosure/", views.disclosure, name="disclosure"),
    path("tag-input/", views.tag_input, name="tag-input"),
    path("modal/", views.modal, name="modal"),
    path("modal/submit/", views.modal_submit, name="modal-submit"),
    path("slide-over/", views.slide_over, name="slide-over"),
    path("toggle/", views.toggle, name="toggle"),
    path("toasts/", views.toast_page, name="toast-page"),
    path("toast-region/", views.toast_region, name="toast-region"),
    path("toast/", views.toast, name="toast"),
    path("combobox/", views.combobox, name="combobox"),
    path("combobox/options/", views.combobox_options, name="combobox-options"),
    path("combobox/submit/", views.combobox_submit, name="combobox-submit"),
    path("table/", views.table, name="table"),
    path("search/", views.search, name="search"),
    path("upload/", views.upload, name="upload"),
]

templates/component_cookbook/partials/modal.html

Django template

{% extends "brickwork/components/_modal.html" %}{% load brickwork_forms i18n %}
{% block title %}{% translate "Send a demonstration message" %}{% endblock %}
{% block body %}<form id="cookbook-message-form" method="post" novalidate action="{% url 'component_cookbook:modal-submit' %}" hx-post="{% url 'component_cookbook:modal-submit' %}" hx-target="this" hx-swap="outerHTML">{% csrf_token %}{% bw_form form %}<footer class="bw-modal__footer"><button class="bw-btn bw-btn--primary" type="submit">{% translate "Submit message" %}</button></footer></form>{% endblock %}

templates/component_cookbook/modal.html

Django template

{% extends "component_cookbook/base.html" %}{% load i18n %}{% block heading %}{% translate "Modal" %}{% endblock %}{% block introduction %}{% translate "The GET route is the no-JavaScript page; HTMX swaps the same fragment into the shell root." %}{% endblock %}{% block cookbook_content %}{% include "component_cookbook/partials/modal.html" %}{% endblock %}

templates/component_cookbook/base.html

Django template

{% extends "brickwork/shell/centred.html" %}
{% load static i18n %}
{% block page_title %}{% translate "Brickwork component cookbook" %}{% endblock %}
{% block head_js %}<script>window.BRICKWORK_JS_URL = "{% static 'brickwork/dist/brickwork.js' %}";</script>{% endblock %}
{% block body_js %}
<script src="{% static 'component_cookbook/vendor/htmx.min.js' %}"></script>
<script type="module">
import Alpine from "{% static 'component_cookbook/vendor/alpine.module.js' %}";
import focus from "{% static 'component_cookbook/vendor/focus.module.js' %}";
window.htmx.config.responseHandling = [{code: "204", swap: false}, {code: "422", swap: true}, {code: "[23]..", swap: true}, {code: "[45]..", swap: false, error: true}];
Alpine.plugin(focus);
const { registerBrickworkComponents } = await import(window.BRICKWORK_JS_URL);
registerBrickworkComponents(Alpine);
window.Alpine = Alpine;
Alpine.start();
</script>
{% endblock %}
{% block content %}
<header class="bw-stack bw-stack--sm"><p><a href="{% url 'component_cookbook:index' %}">{% translate "Component cookbook" %}</a></p><h1>{% block heading %}{% endblock %}</h1><p>{% block introduction %}{% endblock %}</p></header>
{% if messages %}{% for message in messages %}<p class="bw-alert bw-alert--success" role="status">{{ message }}</p>{% endfor %}{% endif %}
{% block cookbook_content %}{% endblock %}
{% endblock %}

templates/your_app/component.html

Django template

<a class="bw-btn bw-btn--primary" href="{% url 'components:demo-modal' %}?title={{ title|urlencode }}&amp;size={{ size }}&amp;backdrop_dismiss={{ backdrop_dismiss|yesno:'true,false' }}&amp;header_recipe={{ header_recipe }}&amp;footer_recipe={{ footer_recipe }}" hx-get="{% url 'components:demo-modal' %}?title={{ title|urlencode }}&amp;size={{ size }}&amp;backdrop_dismiss={{ backdrop_dismiss|yesno:'true,false' }}&amp;header_recipe={{ header_recipe }}&amp;footer_recipe={{ footer_recipe }}" hx-target="#bw-modal-root" hx-swap="innerHTML">Open message form</a>

your_app/views.py

Python

context = {'title': 'Send a demonstration message', 'size': 'md', 'backdrop_dismiss': True, 'header_recipe': 'plain', 'footer_recipe': 'plain'}

Guide

Add it to your project

  1. Create a consumer template extending `brickwork/components/_modal.html`.
  2. Fill `title` and `body`; optionally fill `footer` with `<footer class="bw-modal__footer">…</footer>`.
  3. For enhancement, link with real `href` plus `hx-get`, `hx-target="#bw-modal-root"`, and `hx-swap="innerHTML"`; return the same partial from the htmx view.

View this component's source for the installed release

Options

Public API

Option Type Default Required Description
title string Not set Yes Dialog accessible name unless the title block is overridden. Constraint: Must be non-empty when it supplies an accessible heading; a named title block may replace it.
size string md No Maximum panel size. Allowed: sm, md, lg, full. Constraint: Use one of the documented size choices; unsupported values are not a responsive substitute.
backdrop_dismiss boolean True No Whether clicking the backdrop closes it. Constraint: False blocks backdrop dismissal only; Escape remains available by the dialog contract.
modal_id string bw-modal No Event and DOM instance identity. Constraint: Use a unique id-safe identity when more than one modal can be targeted.
close_href string Not set No Full-page fallback destination for the close control. Constraint: Use a real fallback destination so the no-JavaScript close control is never dead.
header_recipe string plain No Sticky header treatment. Allowed: plain, muted, bordered. Constraint: Use `plain`, `muted`, or `bordered` only.

Named blocks

Fill these only when you extend the component template. They are not values passed to an include or tag.

Block What it replaces
body Dialog body markup, commonly a form; use `hx-target=this` and re-render a 422 invalid response. Constraint: Markup-only extension seam; passing a context variable with this name is ignored.

Behaviour

Accessibility and responsive behaviour

Accessibility
On open focus moves to the first focusable element or `[data-bw-autofocus]`; Tab is trapped, Escape closes, focus returns to the trigger and the close control remains visible.
Responsive
Fluid up to its configured cap; `full` fills the viewport.
Without JavaScript
The same partial is visible in normal page flow from the fallback route.

Need help?

Troubleshooting

Contract check: prefixed name (modal_title, modal_body, modal_footer) produces a

Fill `title`, `body`, and `footer`. The removed prefixed block names are silently discarded.

Explore next

Used in examples

Documentation