Building a Bootstrap styled form in vanilla Django.
18 November 2023

I tooted a little while back about how using django-crispy-forms and it's Layout class could ease writing forms. It showed how a minimal Layout could result in a nice looking form with some complex layouts such as two input boxes on the same row.

Here's the content of that toot.

django-crispy-forms layout:

Resulting form:

I followed this up with a bit of a personal challenge. What would it take to recreate this form using vanilla Django. No 3rd party packages allowed.

This post follows my thought process as I worked through this challenge and highlights the challenges I faced. I'd love to hear feedback on these, what am I doing wrong and if there anything here that Django itself could improve.

Let's go!

Create the base form

The first step was to create a test project, I used a single project folder layout putting everything in the urls.py file. Here's the relevant section that shows the base form. I'm also using Django 5.0 (a few weeks away from release at time of writing) as it includes a number of new features that I'll be making use of.

from django import forms


# 🎉 Django 5.0 allows options to be defined as a mapping. 🎉
days = {
    1: "Monday",
    2: "Tuesday",
    3: "Wednesday"
}


class BootstrapForm(forms.Form):
    name = forms.CharField(max_length=50)
    email = forms.CharField(max_length=50)
    appointment_date = forms.ChoiceField(
        choices=days,
        widget=forms.RadioSelect(),
        help_text="Select which day you would like your appointment. We're only open Monday-Wednesday."
    )
    attachment = forms.FileField(
        required=False,
        help_text="Please provide any files you wish us to review prior to your appointment.",
    )

I added a view that returns a TemplateResponse. The template used contains the Bootstrap CSS and JavaScript and renders the form using {{ form }} along with some input buttons. Rendering the whole form using {{ form }} is a key aim here. Here's the part of the template showing the form.

<form action="/" method="post">
    {% csrf_token %}
    {{ form }}
    <input type="button" value="Submit" class="btn btn-primary">
    <input type="button" value="Cancel" class="btn btn-danger">
</form>

By default this will use Django's as_div template, rendering each field in a series of <div> elements. We can now begin to customise the form.

Add css classes to a field's <input>.

Let's begin styling our form by adding classes to the <input> elements.

Form widgets are Django's representation of <input>s. Classes can be added to these by using the Widget.attrs argument. Bootstrap has requires form-control for most widgets but form-check-inputs for radios and checkboxes. Here's an example of how the form can be updated to add these classes.

-    name = forms.CharField(max_length=50)
+    name = forms.CharField(max_length=50, widget=forms.TextInput(attrs={"class": "form-control"}))

Create a custom field template

A BoundField represents the whole field including its label, input, help text and errors. Django 5.0 added the ability to render a BoundField using its as_field_group () method. The template used can be set project-wide, per-field or per-instance.

I began by creating a copy of Django's field.html template in my project. To use this template project-wide create a custom FORM_RENDERER:

# urls.py
from django.forms.renderers import TemplatesSetting


class CustomFormRenderer(TemplatesSetting):
    field_template_name = "forms/field.html"

and in your settings.py file define the FORM_RENDERER setting.

# settings.py
FORM_RENDERER = "bootstrap_form.urls.CustomFormRenderer"

Styling <label> tags.

A custom field template eases adding additional styles and changing the HTML layout. Let's look at this by adding Bootstrap's .form-label to <label> and <legend> elements.

Django allows css classes to be added to labels if the field is required or has errors with Form.error_css_class and Form.required_css_class. However, the requirement here is to always add a class to a label irresepective of form state. An alternative approach is required.

The default template renders the label with {{ field.label_tag }}, and although the label_tag function allows attrs to be added you can't pass arguments to the function with the Django Template Language. We could override the default label.html template, but as it is a small change I included the customisation in the field.html template. Here's the customisation I made to the <label> tag, a similar change was made for <legend>.

- {{ field.label_tag }}
+ <label class="form-label">{{ field.label }}</label>

Styling help text.

Changing the style of help text is straight forward. The field template is updated to change the default .helptext to bootstrap's .form-text.

- {% if field.help_text %}<div class="helptext"{% if field.id_for_label %} id="{{ field.id_for_label}}_helptext"{% endif %}>{{ field.help_text|safe }}</div>{% endif %}
+ {% if field.help_text %}<div class="form-text"{% if field.id_for_label %} id="{{ field.id_for_label}}_helptext"{% endif %}>{{ field.help_text|safe }}</div>{% endif %}

Using a per-field template.

Django's default template for radio inputs is to render it as an unordered list using <ul> and <li> elements. Bootstrap 5 requires the input to be rendered alongside the <div>, in addition the <div> wrapping the <label> and <input> also needs .form-check. See docs

Changes are needed both to the widget and field templates. I began by creating a per-field template and defined this in the form:

     appointment_date = forms.ChoiceField(
         choices=days,
         widget=forms.RadioSelect(),
+        template_name="forms/radio_field.html",
         help_text="Select which day you would like your appointment. We're only open Monday-Wednesday."
    )

I used the field template to customise the layout of both the field and the widget by using the more granular control available in Django for widgets with choices. This allows wrapping of each <label> and <input> pair in a <div> with the required css classes. Here's the full template:

<fieldset>
  <legend class="fs-6">{{ field.label }}</legend>
{% if field.help_text %}<div class="form-text"{% if field.id_for_label %} id="{{ field.id_for_label}}_helptext"{% endif %}>{{ field.help_text|safe }}</div>{% endif %}
{{ field.errors }}
{% for choice in field %}
    <div class="form-check">
    <label for="{{ choice.id_for_label }}" class="form-check-label">{{ choice.choice_label }}</label>
    {{ choice.tag }}
    </div>
{% endfor %}
</fieldset>

Customising the form template.

The fields are looking much better, but we need to customise the layout of the field groups to add some space between them and to have the name and email fields to be on the same row. I copied the div.html template to my project and added it to my form renderer. The template can also be set per-form by defining template_name on the form class.

 class CustomFormRenderer(TemplatesSetting):
     field_template_name = "forms/field.html"
+    form_template_name = "forms/form.html"

Adding classes to each field group.

The fields are currently too close together. To give them some space .mb-3 can be added to <div> wrapping the field group. The relevant line from the template is:

<div{% with classes=field.css_classes %}{% if classes %} class="{{ classes }}"{% endif %}{% endwith %}>

field.css_classes returns the classes for fields which are required of have errors mentioned above. In addition it takes an optional argument to add additional classes. Accessing this method from a Django template is not straight forward. However, given this is already a template .mb-3 can be added directly to the template.

<div{% with classes=field.css_classes %} class="mb-3{% if classes %} {{ classes }}{% endif %}"{% endwith %}>

Customising the form layout

The form we're working towards has the name and email fields on the same row. The custom per-form template can be edited to achieve this. The new as_field_group() method introduced in Django 5.0 eases this as the form can be laid out by accessing each field on the form rendering it as a field group. This avoids needing to write out each element for each field of your form.

The core part of the form template now becomes:

<div class="row">
    <div class="mb-3 col">
    {{ form.name.as_field_group }}
    </div>
    <div class="mb-3 col">
    {{ form.email.as_field_group }}
    </div>
</div>
<div class="mb-3">{{ form.appointment_date.as_field_group }}</div>
<div class="mb-3">{{ form.attachment.as_field_group }}</div>

Customise form errors

The form is now looking pretty similar to our target (radio's are not on the same row -- you'll have to take inspiration from the crispy-form template pack for that 😊). However, there's no errors on this form. If the submitted form returned validation errors these would be styled using Django's defaults. We'll need to customise these to work with Bootstrap.

Django uses a class to hold information about form errors themselves and how to render them. A custom class can be used to change the template used to render errors.

To create a custom error class:

from django.forms.utils import ErrorList

class CustomErrorList(ErrorList):
    template_name = "forms/errors.html"

The template renders each error in a span with the .invalid-feedback class.

{% for error in errors %}
    <span class="invalid-feedback"><strong>{{ error }}</strong></span>
{% endfor %}

To use it in your form you need to define it when the field is created. I overrode the forms __init__ method to ensure that this custom error list is always used.

class BootstrapForm(forms.Form):
    def __init__(self, *args, **kwargs):
        kwargs.update({'error_class': CustomErrorList})
        super().__init__(*args, **kwargs)

While the form will render HTML for Bootstrap 5 style errors they currently don't appear!

For errors to be shown two things are required:

  • The field must have the .is-invalid class
  • The error must come after the field and is must be a sibling.

Adding error styles to widgets

Error styles were discussed above and can easily be added to surrounding <divs> or a <label>. For Bootstrap the error class needs to be added to the <input>.

Django allows additional classes to be added to a widget at render time when using the attrs argument of BoundField.as_widget()

We need to pass in additional error classes to this method when the field has errors. While it's not possible to do this directly from Django a custom template tag can be created to achieve this.

Here's my template filter. It takes in a field and some css classes. It adds the classes provided to any classes already on the field (such as .form-control) and renders the field with the combined classes. Note, this is a minimal example and is not well tested like some solutions in 3rd party packages.

@register.filter
def add_class(field, css_class):
    if widget_class := field.field.widget.attrs.get("class"):
        css_class += " " + widget_class
    return field.as_widget(attrs={"class": css_class})

In the field template we can use this to add the .is-invalid class when the field has errors.

{% if field.errors %}{{ field|add_class:"is-invalid" }}{% else %}{{ field }}{% endif %}

This works great for our name and email fields. However, it doesn't work for the RadioSelect widget!

The <input> for the radio is rendered using {{ choice.tag }} in the template. Adding the above filter here will result in an error. This is because choice.tag is already a string, with choice being a BoundWidget (designed for use with widgets like radios) not a BoundField.

We can update the template filter to also work for BoundWidget.

 @register.filter
 def add_class(field, css_class):
+    if isinstance(field, BoundWidget):
+        # copy data to avoid changing it, this method can be called multiple times
+        data = copy.copy(field.data)
+        if widget_class := data["attrs"].get("class"):
+            data["attrs"]["class"] = css_class + " " + widget_class
+        else:
+            data["attrs"]["class"] = css_class
+        context = {"widget": {**data, "wrap_label": False}}
+        return field.parent_widget._render(field.template_name, context, field.renderer)
     if widget_class := field.field.widget.attrs.get("class"):
         css_class += " " + widget_class
     return field.as_widget(attrs={"class": css_class})```

As per the warning above. This isn't well tested! The key takeaway is that some kind of template filter/tag is how I'd go about solving this. This can be used in our template like this.

{% if field.errors %}{{ choice|add_class:"is-invalid" }}{% else %}{{ choice.tag }}{% endif %}

Changing where errors are rendered in the form

The above section solved the need to add .is-invalid to <input> with errors. Step two is to address where the errors are rendered, they need to be directly after the <input>.

For our standard fields this is straight forward. {{ field.errors }} can be moved from above the input to below.

It's more complex for radios, the error has to follow the last <input> in the group. This is inside a for loop. The Django Template Language allows us to use forloop.last to check if it's the last item in the collection.

Labels and inputs also need switching round for bootstrap errors to display correctly. The core part of the radio field template becomes:

 {% for choice in field %}
     <div class="form-check">
     {% if field.errors %}{{ choice|add_class:"is-invalid" }}{% else %}{{ choice.tag }}{% endif %}
     <label for="{{ choice.id_for_label }}" class="form-check-label">{{ choice.choice_label }}</label>
    {% if forloop.last %}{{ field.errors }}{% endif %}
     </div>
 {% endfor %}

Make use of template filter for all widget classes.

The introduction of a template filter to add classes to widgets in templates means that the styles previously added in the form (which can get quite repetative) can be moved to the template.

For example, the need to define a widget and attrs dict for each field can be removed:

class BootstrapForm(forms.Form):
     ...
-    name = forms.CharField(max_length=50, widget=forms.TextInput(attrs={"class": "form-control"}))
+    name = forms.CharField(max_length=50)

... and can be added for all widgets in the template:

- {% if field.errors %}{{ field|add_class:"is-invalid" }}{% else %}{{ field }}{% endif %}
+ {% if field.errors %}{{ field|add_class:"form-control is-invalid" }}{% else %}{{ field|add_class:"form-control" }}{% endif %}

What about using a custom BoundField?

The key class in rendering of form fields is the BoundField. A custom BoundField could be created which could add custom css classes to widgets or labels. However, to use a custom BoundField every field's get_bound_field() method would need to be overriden. I think if this could be done at the form or project level what would ease this approach.

Conclusion

Reproducing the form using Django's core features proved to be mostly template work making use of per-site, per-form and per-field customisations. Some areas that were more challenging were:

  • Customising errors where a per-form error class needs to be provided.

Here I'd like to see the ability to set a project-wide template on the form renderer, if that's possible. I think most folk would want to customise the ErrorLists template, and not it's other features.

  • Customising classes for <inputs> which can be somewhat repetitive to add to a form and are more complex to add when they are dependant upon the form state, for example when the field has errors.

This seems harder, I'm not sure if there's a change that I'd like to see in Django itself for this. Maybe my solution to this problem isn't the correct one. Let me know your thoughts!