Skip to main content

django-widget-renderers

Let a FormRenderer decide which template each widget uses.

Reference implementation of django/new-features#172. Install it, try it, and please leave feedback on the issue — that's what this package is for.

class DrawerRenderer(TemplatesSetting):
    form_template_name  = "forms/drawer.html#form"     # Django already supports these
    field_template_name = "forms/drawer.html#field"    #

    text_input_template_name = "forms/drawer.html#text_input"   # this package adds these
    select_template_name     = "forms/drawer.html#select"
    textarea_template_name   = "forms/drawer.html#textarea"

That's the whole API. Widget templates get named the same way Django already names form and field templates.

The problem

Django picks a widget's template from the widget class (Widget.template_name). So to restyle widgets across a project, you mutate that class attribute:

forms.TextInput.template_name = "my/text_input.html"   # the status quo

This works. It's also global — you have now restyled every TextInput in the process, including the ones in django.contrib.admin, whose JS-driven widgets key off Django's stock markup. Restyle Select and the admin's autocomplete breaks. Restyle CheckboxInput carelessly and the changelist's "select all" quietly stops working.

The usual advice — subclass your widgets, write a custom template loader, or shadow django/forms/widgets/select.html in your own template dir — all shares the same ceiling:

A global mechanism gives you exactly one styling per widget class per process. Renderer scoping gives you N.

Admin-vs-frontend is just the N=2 case that every project trips over. Drawer forms, page forms, modal forms, a third-party form-styling package alongside your own house style — that's N=4, and no loader can express it, because a loader resolves on the template engine's global state and cannot know which renderer asked.

What this package does

Adds a get_template_name hook to Widget and wires it into _render, so the template becomes a pure function of (widget class, active renderer):

def _render(self, template_name, context, renderer=None):
    if renderer is None:
        renderer = get_default_renderer()
    template_name = self.get_template_name(renderer, template_name)   # <- the only new line
    return mark_safe(renderer.render(template_name, context))

Now two renderers can style the same widget class differently, at the same time, in one process:

widget = forms.TextInput()
widget.render("f", "v", renderer=AlphaRenderer())   # <input class="alpha-input" ...>
widget.render("f", "v", renderer=BetaRenderer())    # <input class="beta-input" ...>
widget.render("f", "v", renderer=TemplatesSetting())  # Django's stock markup

Install

pip install django-widget-renderers
INSTALLED_APPS = [
    ...,
    "django.forms",         # see below — easy to miss
    "widget_renderers",     # its AppConfig.ready() calls install()
]

FORM_RENDERER = "django.forms.renderers.TemplatesSetting"

Or call it yourself, if you want to control ordering:

class MyAppConfig(AppConfig):
    def ready(self):
        from widget_renderers import install
        install()

The patch must be installed at startup — forms are instantiated before they're rendered, so an import-time side effect elsewhere is too late.

Requires Django 4.2+ and Python 3.10+.

You need TemplatesSetting, and therefore django.forms

Your renderer must resolve templates through your project's template engine, which means basing it on TemplatesSetting. Django's default renderer (DjangoTemplates) deliberately uses its own isolated engine that can't see your template dirs, so a select_template_name pointing at "forms/drawer.html" would never be found.

The catch: once you switch to TemplatesSetting, Django's own widget templates have to be findable through your engine too — and they live inside the django.forms app. If it isn't in INSTALLED_APPS (with APP_DIRS: True), every widget you haven't styled blows up:

TemplateDoesNotExist: django/forms/widgets/textarea.html

So: add django.forms to INSTALLED_APPS, and keep APP_DIRS: True (or add Django's form template directory to DIRS explicitly). This isn't specific to this package — it's the standing requirement for TemplatesSetting — but you'll almost certainly meet it here first. See Django's docs on TemplatesSetting.

Declaring a renderer

The attribute name is the snake_case of the widget class plus _template_name — TextInput → text_input_template_name, RadioSelect → radio_select_template_name.

from django.forms.renderers import TemplatesSetting

class WidgetsRenderer(TemplatesSetting):
    text_input_template_name = "forms.html#text_input"
    select_template_name = "forms.html#select"

class MyFormRenderer(WidgetsRenderer):
    # styled widgets PLUS styled form/field wrappers
    form_template_name = "forms.html#form"
    field_template_name = "forms.html#field"

Set FORM_RENDERER = "myapp.renderers.MyFormRenderer" as the project default, or pass a renderer per form (default_renderer = ... / MyForm(renderer=...)).

Template partials (forms.html#text_input) are Django 6.0+. On older versions just use whole template files.

How resolution works

get_template_name walks the widget MRO. For each class it looks for <widget>_template_name on the renderer; first match wins. A class that defines its own template_name short-circuits the walk.

That one rule gives you Python's own attribute-shadowing semantics:

Case Result
Select + select_template_name renderer's template
SelectMultiple (no own template_name) resolves up to select_template_name
MoneyInput(TextInput) with its own template_name keeps its own — a base override can't clobber it
...but money_input_template_name is declared renderer wins — shadowing isn't a lockout
renderer declares nothing widget's own template, i.e. stock Django

Two properties worth calling out:

  • Backwards compatibility is the default, not a feature. Lookup is getattr against the renderer, so a renderer that declares nothing (a plain TemplatesSetting, or Django's DjangoTemplates) leaves every widget exactly where it was. There's no opt-out flag because none is needed.
  • Nothing user-controlled can steer a template. The lookup key comes purely from the widget's class name — never attrs, instance state, or submitted data.
  • The hook is the extension point. Override get_template_name on a widget to change resolution, or to log it while debugging.

A renderer is just an object; there's no base class to inherit and no resolver to register.

Admin

from django.forms.renderers import TemplatesSetting
from widget_renderers import RendererAdminSite

class MyAdminSite(RendererAdminSite):
    form_renderer = TemplatesSetting   # declares nothing -> stock widgets

register() wraps each ModelAdmin (and its inlines) so the chosen renderer is injected as the form's default_renderer, which beats the global FORM_RENDERER. Your site stays styled; the admin stays stock.

Limitations

Both are pinned as tests in tests/test_limitations.py.

MultiWidget subwidgets aren't reached. They're rendered from the parent's context rather than via their own render(), so the hook never sees them. Style the subwidget class directly, or give the MultiWidget a template naming the subwidget templates it wants.

Widgets rendered outside a form fall back to the global default. Widget.render() with no renderer uses get_default_renderer(). Scoping can't help code that never passes a renderer — and Django itself has such code: ModelAdmin.action_checkbox renders CheckboxInput(attrs={"class": "action-select"}) with no renderer, so it picks up your project default inside the admin. If your template drops the caller's class, the changelist's "select all" breaks. (Ask how we know.)

Status

Beta, and deliberately narrow: it exists to test an idea proposed to Django. It runs in production in a banking application, but the API is whatever the upstream discussion decides it should be.

See PROPOSAL.md for what's actually being proposed to Django (a method and one line), what's only scaffolding, and the alternatives considered.

Licence

BSD-3-Clause — same as Django, so the code can be donated upstream without friction.

Metadata

Release files for django-widget-renderers 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for django-widget-renderers 0.1.0
File Size Uploaded Provenance Actions
django_widget_renderers-0.1.0.tar.gz 20.0 kB With provenance Details for django_widget_renderers-0.1.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for django-widget-renderers 0.1.0
File Interpreter ABI Platform Actions
django_widget_renderers-0.1.0-py3-none-any.whl Python 3 none any Details for django_widget_renderers-0.1.0-py3-none-any.whl

Total release size: 29.4 kB

Release files / django_widget_renderers-0.1.0.tar.gz

Download URL django_widget_renderers-0.1.0.tar.gz
Size 20.0 kB
Tags Source
SHA-256 checksum
How to use checksums
1ea47860cfa2b9287211ef867809d2ef9fbe2ce5b019ad1cb34bee74f9f7e7f5
BLAKE2b-256 checksum
How to use checksums
aa16c4dbbb77ad54afed35a7dbd077a3176d0ba58ba0f62999ce702a57cd2558
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.12

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jul 17, 2026.

Transparency log

Release files / django_widget_renderers-0.1.0-py3-none-any.whl

Download URL django_widget_renderers-0.1.0-py3-none-any.whl
Size 9.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
ea44b09bfcd90e0203163e198ef83d9ced66f8d8dbda20d5ef88329e38ffc88a
BLAKE2b-256 checksum
How to use checksums
534040f4aaa0eff892b386bd0f5c376ae1cac13b7d29773ffe0a758a37dabaa6
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.12

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jul 17, 2026.

Transparency log

Security for django-widget-renderers 0.1.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for django_widget_renderers-0.1.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jul 17, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page