Modal
An edge-independent modal dialog with a full-page fallback.
Try it
validated-form
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 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.pycomponent_cookbook/forms.pycomponent_cookbook/urls.pytemplates/component_cookbook/partials/modal.htmltemplates/component_cookbook/modal.htmltemplates/component_cookbook/base.html
"""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)
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
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"),
]
{% 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 %}
{% 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 %}
{% 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 %}
<a class="bw-btn bw-btn--primary" href="{% url 'components:demo-modal' %}?title={{ title|urlencode }}&size={{ size }}&backdrop_dismiss={{ backdrop_dismiss|yesno:'true,false' }}&header_recipe={{ header_recipe }}&footer_recipe={{ footer_recipe }}" hx-get="{% url 'components:demo-modal' %}?title={{ title|urlencode }}&size={{ size }}&backdrop_dismiss={{ backdrop_dismiss|yesno:'true,false' }}&header_recipe={{ header_recipe }}&footer_recipe={{ footer_recipe }}" hx-target="#bw-modal-root" hx-swap="innerHTML">Open message form</a>
context = {'title': 'Send a demonstration message', 'size': 'md', 'backdrop_dismiss': True, 'header_recipe': 'plain', 'footer_recipe': 'plain'}
Guide
Add it to your project
- Create a consumer template extending `brickwork/components/_modal.html`.
- Fill `title` and `body`; optionally fill `footer` with `<footer class="bw-modal__footer">…</footer>`.
- 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.
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. |
| footer_recipe | string | plain | No | Footer treatment. Allowed: plain, muted, actions. Constraint: Use `plain`, `muted`, or `actions` 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. |
| footer | Optional footer markup. The caller supplies `<footer class="bw-modal__footer">…</footer>`. 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