Reusable Django CRUD views, forms, and template fragments with HTMX, Alpine.js, and Tailwind CSS
Project description
django-crispy-crud
Reusable Django CRUD views, forms, and template fragments with HTMX, Alpine.js, and Tailwind CSS.
Installation
With uv:
uv add django-crispy-crud
With pip:
pip install django-crispy-crud
For audit log support (optional):
uv add "django-crispy-crud[audit]"
# or
pip install "django-crispy-crud[audit]"
Setup
Add crispy_crud to your INSTALLED_APPS:
INSTALLED_APPS = [
# ...
"django.contrib.messages",
"django.contrib.staticfiles",
"crispy_forms",
"crispy_tailwind",
"django_htmx",
"django_filters",
"crispy_crud",
]
Add the required middleware:
MIDDLEWARE = [
# ...
"django.contrib.sessions.middleware.SessionMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django_htmx.middleware.HtmxMiddleware",
]
Ensure the messages context processor is included in your template settings:
TEMPLATES = [
{
# ...
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.contrib.messages.context_processors.messages",
# ...
],
},
},
]
Set crispy forms to use Tailwind:
CRISPY_ALLOWED_TEMPLATE_PACKS = "tailwind"
CRISPY_TEMPLATE_PACK = "tailwind"
Your base template must load Alpine.js and HTMX (not bundled):
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script src="https://unpkg.com/htmx.org@2.0.0"></script>
Styling
The package templates use custom CSS classes (btn-primary, btn-secondary, btn-important, btn-disabled, form-link, readonly-form-field, toggle), shipped as a Tailwind CSS v4 entry point at static/crispy_crud/crispy_crud.css.
Setup
Run the crispy_crud_css management command, pointing --relative-to at your Tailwind input CSS file, and paste its output into that file after @import "tailwindcss";:
$ python manage.py crispy_crud_css --relative-to theme/static_src/src/styles.css
/* django-crispy-crud — paste after @import "tailwindcss"; */
@import "../../../.venv/lib/python3.13/site-packages/crispy_crud/static/crispy_crud/crispy_crud.css";
@source "../../../.venv/lib/python3.13/site-packages/crispy_tailwind/templates";
@import "tailwindcss";
@import "../../../.venv/lib/python3.13/site-packages/crispy_crud/static/crispy_crud/crispy_crud.css";
@source "../../../.venv/lib/python3.13/site-packages/crispy_tailwind/templates";
The @source line is needed because {% crispy %} forms render crispy-tailwind's template pack, whose utility classes your build must scan to generate CSS for. Paths are printed relative to --relative-to's parent directory (matching how @import/@source resolve); omit the flag to get absolute paths instead.
Requirements
Requires tailwindcss >= 4.1 — earlier 4.x releases ignore an explicit @source that points into a .gitignore'd directory such as a uv-managed .venv (tailwindlabs/tailwindcss#15452).
Theming
Every colour in the component classes routes through a --crud-* CSS custom property with a package default as its fallback:
| Variable | Default | Affects |
|---|---|---|
--crud-btn-primary-text |
white |
btn-primary text colour |
--crud-btn-primary-bg |
gray-800 |
btn-primary background |
--crud-btn-primary-bg-hover |
gray-700 |
btn-primary background on hover |
--crud-btn-secondary-text |
gray-800 |
btn-secondary text colour |
--crud-btn-secondary-bg |
white |
btn-secondary background |
--crud-btn-secondary-border |
gray-300 |
btn-secondary border colour |
--crud-btn-secondary-bg-hover |
gray-50 |
btn-secondary background on hover |
--crud-btn-important-text |
white |
btn-important text colour |
--crud-btn-important-bg |
red-700 |
btn-important background |
--crud-btn-important-bg-hover |
red-500 |
btn-important background on hover |
--crud-btn-disabled-text |
gray-500 |
btn-disabled text colour |
--crud-btn-disabled-bg |
gray-200 |
btn-disabled background |
--crud-form-link-text |
gray-600 |
form-link text colour |
--crud-form-link-text-hover |
gray-900 |
form-link text colour on hover |
--crud-readonly-text |
gray-500 |
readonly-form-field text colour |
--crud-readonly-bg |
gray-50 |
readonly-form-field background |
--crud-readonly-border |
gray-400 |
readonly-form-field border colour |
--crud-toggle-bg |
gray-200 |
toggle button background (unchecked) |
--crud-toggle-bg-checked |
green-200 |
toggle button background (checked) |
Override a variable in your own CSS to rebrand a component — no fork or package rebuild needed:
:root {
--crud-btn-primary-bg: var(--color-indigo-600);
--crud-btn-primary-bg-hover: var(--color-indigo-500);
}
See the test app's static_src/styles.css for a working example.
Caveats
- Re-run the command after a Python minor version upgrade (the
python3.Xsegment of the path changes) or if the virtualenv moves. - The pasted paths must also resolve wherever your production CSS is built (e.g. in Docker) -- this holds as long as that build installs the virtualenv at the project root with the same Python version.
- Projects with no Tailwind build step aren't supported as a first-class case yet.
static/crispy_crud/css/components.cssis deprecated and will be removed in a future release; usecrispy_crud_cssinstead.
Usage
View mixins
from django.views.generic import CreateView, UpdateView
from django_filters.views import FilterView
from crispy_crud.views import (
BaseModelAppMixin, CrispyFormArgsMixin,
FilterFormMixin, HtmxFilterListMixin,
)
class WidgetListView(BaseModelAppMixin, FilterFormMixin, HtmxFilterListMixin, FilterView):
model = Widget
filterset_class = WidgetFilter
template_name = "myapp/widget_list.html"
partial_template_name = "myapp/fragments/widget_table.html"
section = "widget-list"
create_view_name = "widget-create"
filter_form_class = WidgetFilterForm
class WidgetUpdateView(BaseModelAppMixin, CrispyFormArgsMixin, UpdateView):
model = Widget
form_class = WidgetForm
template_name = "myapp/widget_detail.html"
section = "widgets"
delete_view_name = "widget-delete"
success_url = reverse_lazy("widget-list")
Form helpers
from django import forms
from crispy_crud.forms import CrispyFormMixin
class WidgetForm(CrispyFormMixin, forms.ModelForm):
cancel_action = "widget-list"
class Meta:
model = Widget
fields = ["name", "description", "is_active"]
CrispyFormMixin provides a crispy FormHelper, cancel link, and Alpine.js dirty-state submit button out of the box. You need to set the layout in your form's __init__ and include self.button_holder at the end:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper.layout = Layout(
Field("name"),
Field("description"),
Field("is_active"),
self.button_holder,
)
Filter forms
Create a filter form with a crispy helper using BaseFilterFormHelper:
from crispy_crud.forms import BaseFilterFormHelper
from crispy_forms.layout import Field, Layout
class WidgetFilterFormHelper(BaseFilterFormHelper):
q_field_placeholder = "Search widgets..."
def __init__(self):
super().__init__()
self.form_class = self.form_css
self.layout = Layout(
self.q_field,
Field("is_active", wrapper_class="w-40"),
self.button_holder,
)
class WidgetFilterForm(forms.Form):
q = forms.CharField(required=False)
is_active = forms.BooleanField(required=False)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = WidgetFilterFormHelper()
Set filter_form_class on your list view, and include the HTMX filter form fragment in your list template:
{% include 'crispy_crud/fragments/forms/hx_filter_form.html' with view_name="widget-list" %}
This renders a form that auto-submits via HTMX on input changes -- the search field fires after a 500ms debounce, and select/checkbox fields fire on change. The filter_form_fields template tag automatically generates the correct HTMX trigger selectors for your filter fields.
You also need a django-filter FilterSet on your view:
import django_filters
class WidgetFilter(django_filters.FilterSet):
q = django_filters.CharFilter(field_name="name", lookup_expr="icontains")
class Meta:
model = Widget
fields = ["q", "is_active"]
Template fragments
The package provides reusable template fragments under crispy_crud/fragments/. Include them in your own templates:
{% include 'crispy_crud/fragments/common/messages.html' %}
{% include 'crispy_crud/fragments/common/page_heading.html' %}
{% include 'crispy_crud/fragments/table/table_heading.html' with text="Name" first_column=True %}
{% include 'crispy_crud/fragments/table/table_cell.html' with text=widget.name first_column=True %}
{% include 'crispy_crud/fragments/table/hx_pagination.html' with page=page_obj %}
{% include 'crispy_crud/fragments/details/delete_action.html' %}
{% include 'crispy_crud/fragments/loaders/loading_spinner.html' %}
Static files
Include the Alpine.js components for form state tracking and pagination:
<script src="{% static 'crispy_crud/js/formState.js' %}"></script>
<script src="{% static 'crispy_crud/js/pagination.js' %}"></script>
Configuration
All settings are optional and have sensible defaults:
| Setting | Default | Purpose |
|---|---|---|
CRISPY_CRUD_PAGINATE_BY |
25 |
Default page size |
CRISPY_CRUD_PAGE_SIZE_OPTIONS |
[10, 25, 50, 100] |
Page-size pills shown in pagination |
CRISPY_CRUD_AUDIT_PAGE_SIZE |
25 |
Rows per page in audit history table |
Running the test app
The test app in tests/testapp/ is a working example you can clone as a starting point. To run it locally:
git clone https://github.com/howieweiner/django-crispy-crud.git
cd django-crispy-crud
uv run python manage.py migrate
uv run python manage.py runserver
Then visit http://localhost:8000/widgets/ to see the list view with filtering, pagination, create/update forms, and delete with confirmation modal.
Development
uv run pytest # run tests
uv run ruff check src/ tests/ # lint
uv run ruff format src/ tests/ # format
What's included
View mixins (crispy_crud.views)
- PaginationMixin -- page size from query param, cookie, or config
- HtmxPaginationMixin -- extends PaginationMixin with HTMX URL push
- CrispyFormArgsMixin -- injects
requestinto form kwargs - BaseAppMixin -- section/heading context
- BaseModelAppMixin -- auto-generated headings, create/delete actions, related-object protection
- HtmxFilterListMixin -- switches to partial template on HTMX requests
- FilterFormMixin -- populates filter form in context
- AuditLogMixin -- paginated audit history (requires
django-auditlog)
Form helpers (crispy_crud.forms)
- CrispyFormMixin -- crispy FormHelper with cancel link and Alpine.js dirty-state
- AlpineSubmit -- submit button with Alpine.js attributes
- BaseFilterFormHelper -- filter form helper with search field and reset link
Model mixins (crispy_crud.models)
- AuditOnUpdateOnly -- suppresses auditlog on creation, only logs updates
Template fragments (crispy_crud/fragments/)
common/-- messages, alerts, page headingtable/-- pagination, table heading/cell, OOB add buttondetails/-- delete action with confirmation modal, audit history tableforms/-- filter form, field errors, submit/cancel, read-only fieldsloaders/-- HTMX loading spinner and busy indicator
Template tags (crispy_crud_tags)
- filter_form_fields -- generates HTMX trigger selectors for filter form fields
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file django_crispy_crud-1.1.0.tar.gz.
File metadata
- Download URL: django_crispy_crud-1.1.0.tar.gz
- Upload date:
- Size: 30.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0ab1a352c05800030285958d2a0cbb159257cac688f9735ba1e30c09df5a50e8
|
|
| MD5 |
c5c773f1f2652fcdb3f546aa7ad09fb0
|
|
| BLAKE2b-256 |
6dec57b5c6b00252c073b1bf614a6b3749b2c12444ddca035fb4295434fab85e
|
Provenance
The following attestation bundles were made for django_crispy_crud-1.1.0.tar.gz:
Publisher:
publish.yml on howieweiner/django-crispy-crud
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_crispy_crud-1.1.0.tar.gz -
Subject digest:
0ab1a352c05800030285958d2a0cbb159257cac688f9735ba1e30c09df5a50e8 - Sigstore transparency entry: 2085885665
- Sigstore integration time:
-
Permalink:
howieweiner/django-crispy-crud@3914888d9c53ac5ba6e62230fb8ffbac2a2a49d6 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/howieweiner
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@3914888d9c53ac5ba6e62230fb8ffbac2a2a49d6 -
Trigger Event:
workflow_dispatch
-
Statement type:
File details
Details for the file django_crispy_crud-1.1.0-py3-none-any.whl.
File metadata
- Download URL: django_crispy_crud-1.1.0-py3-none-any.whl
- Upload date:
- Size: 31.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
21ed55163ee5ff8763f9b2fc728348868193fabfd84d4debea592b73232e4cd4
|
|
| MD5 |
0a2c55de0a7571fd92de11f8a805d530
|
|
| BLAKE2b-256 |
10a949feaaf0f561d8f849bfe17c0051abc7d0ac5a6ca42e84003d3074044532
|
Provenance
The following attestation bundles were made for django_crispy_crud-1.1.0-py3-none-any.whl:
Publisher:
publish.yml on howieweiner/django-crispy-crud
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_crispy_crud-1.1.0-py3-none-any.whl -
Subject digest:
21ed55163ee5ff8763f9b2fc728348868193fabfd84d4debea592b73232e4cd4 - Sigstore transparency entry: 2085885747
- Sigstore integration time:
-
Permalink:
howieweiner/django-crispy-crud@3914888d9c53ac5ba6e62230fb8ffbac2a2a49d6 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/howieweiner
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@3914888d9c53ac5ba6e62230fb8ffbac2a2a49d6 -
Trigger Event:
workflow_dispatch
-
Statement type: