django python ux 16 October 2022

Better select forms with django and Bootstrap 5

Small tweaks to improve user experience and avoid dropdowns

web design

Photo by Kelly Sikkema on Unsplash


I enjoy working with Django for many reasons but mostly since I can focus on what I want my app to do and django happily takes care of the rest. In 2022 it's probably not the sexiest choice when it comes to web frameworks, but there are good reasons it is still as popular as it is. There is a recent wave of excitement about server side rendering frameworks thanks to a new batch of tools like HTMX, django-unicorn and django-sockpuppet that give django superpowers and put into question the React based paradigm for building apps. But that's a digression for another time.

Django takes care of a lot of stuff behind the scenes and that is a solid productivity boost. Being a mature framework means that is has opinions (mostly for very good reasons) and provides you with defaults that are sensible in most o the cases. There are however cases when it's worth to make some changes to improve end-user experience.

One such aspect is the default UI widget for selecting items. I am of course talking about dropdowns. They have been around since forever and although being familiar they are usually not the best choice when it comes to modern UX standards. Out in the wild you can find professionals suggesting caution when using dropdowns and calls to stop using drop-downs at all.

Personally I would like to adhere to good UX practices as far as I can and although my level of comfort increases with proximity to backend side of things I cannot be annoyed by all the low hanging fruit that sometimes plague otherwise good products. Wanting things to look good without a massive design effort is one of the reasons I usually reach for a frontend framework, most often bootstrap. With django that means using django crispy forms to get the forms looking sharp and consistent with an extra line in the template.

Making choices

One of the things I was recently working on is a django-based dashboard where I wanted to have the option to choose the aggregation level between: daily, weekly, monthly aggregates. Of course my first pass was the default forms.Select widget that is a dropdown. Once I saw the result on the screen though, I felt something is off, and that this should probably be improved. Usually I try to move on until I have a working prototype that I can iterate over. Following the Kent Beck's principle

make it work, make it right, make it fast

When I got everything wired up and working it seemed like a good time get back to the select widget and scratch that itch.

On the django side I had a form to represent the choices

from django import forms

class FrequencyForm(forms.Form):
    choices = [
        ("1D", "Daily"),
        ("1W", "Weekly),
        ("1M", "Monthly")
    ]
    freq = forms.ChoiceField(label="Frequency", choices=choices)

which was being rendered in a template with pretty standard django

{% load crispy_forms_tags %}
...
{{ form|crispy }}

The effect isn't surprising either

I didn't want for the options to be hidden, so the next best thing was to switch to a radio select by changing the for widget to RadioSelect and keeping the template as is

from django import forms

class FrequencyForm(forms.Form):
    choices = [
        ("1D", "Daily"),
        ("1W", "Weekly),
        ("1M", "Monthly")
    ]
    freq = forms.ChoiceField(
        label="Frequency",
        choices=choices,
        widget=forms.RadioSelect(),
    )

This gives

Bettter but I still wasn't happy with the result. When looking for inspiration I found this post suggesting a segmented button that reminded me of what I saw in latest Bootstrap 5 docs so I decided to give it a go since I was already using Bootstrap.

Looking at the example from Bootstrap it became clear that I would have to render the form manually. The example I started from was this:

<div class="btn-group" role="group" aria-label="Basic radio toggle button group">
<input type="radio" class="btn-check" name="btnradio" id="btnradio1" autocomplete="off" checked>
<label class="btn btn-outline-primary" for="btnradio1">Radio 1</label>

<input type="radio" class="btn-check" name="btnradio" id="btnradio2" autocomplete="off">
<label class="btn btn-outline-primary" for="btnradio2">Radio 2</label>

<input type="radio" class="btn-check" name="btnradio" id="btnradio3" autocomplete="off">
<label class="btn btn-outline-primary" for="btnradio3">Radio 3</label>
</div>

which looks like

Pretty nice! I went ahead and updated my template and instead of the {{ form|crispy }} elements I went ahead and unfolded the form and added the classes and attributes to render the button group:

<div class="btn-group" role="group" aria-label="Basic radio toggle button group">
    {% for choice in form.freq %}
        {{ choice.tag }}
        <label class="btn btn-outline-primary" for="{{ choice.id_for_label }}">{{ choice.choice_label }}</label>
    {% endfor %}
</div>

Everything looked nice when rendered however when clicking on the buttons they wouldn't stay selected as they should. They would change back to unselected which was annoying. I had to compare the original HTML with my rendered version to realize that the <input> tags in my case were missing s class="btn-check" attribute, so I modified the form to have that added

class FrequencyForm(forms.Form):
    choices = [
        ("1D", "Daily"),
        ("1W", "Weekly),
        ("1M", "Monthly")
    ]
    freq = forms.ChoiceField(
        label='Category',
        choices=choices,
        widget=forms.RadioSelect(attrs={'class':'btn-check'}),
    )

Adding that attribute to the form did the trick. With that little tweak I was able to achieve way better UX and eliminate and unnecessary dropdown.

This was it for me but in case you have a few more choices to select from (maybe 5-9 items) and you still would like to present them to the user this might not be a good choice since button group will most likely be too wide for small screens and overflow.

In this case you can switch to splitting choices into individual buttons and arranging them in a grid rather than a single row. Then your CSS framework can take over and manage the responsive layout for smaller screens where you probably want to either switch to a more vertical layout or a grid layout.

Let me add two more choices to my original form to illustrate how could it look like

class FrequencyForm(forms.Form):
    choices = [
        ("1D", "Daily"),
        ("1W", "Weekly),
        ("1M", "Monthly")
        ("1Q", "Quarterly")
        ("1Y", "Yearly")
    ]
    freq = forms.ChoiceField(
        label='Category',
        choices=choices,
        widget=forms.RadioSelect(attrs={'class':'btn-check'}),
    )

In the template we need to wrap all choice fields in a row and each input + label tag in a col with appropriate widths for each display breakpoint. This is bootstrap specific so check out bootstrap layout if you want to know more.

<form>
<div class="row">
{% for choice in form.freq %}
    <div class="col-4 col-md-3 col-lg-2">
    {{ choice.tag }}
    <label class="btn btn-outline-primary" for="{{ choice.id_for_label }}">{{ choice.choice_label }}</label>
    </div>
{% endfor %}
</form>

This is now rendered as independent radio buttons

Summary

If you're using django and a css framework like bootstrap it does not take much effort to adapt your forms and improve overall user experience and at the same time conform to modern standard for look and feel of your web app. If you have similar quick improvements please share below since I'm always on the lookout for how to improve usability.