How to Make a Super Smooth Language Switcher with Django and HTMX

Need a super smooth language switcher that just works everywhere?

Django has a built in helper function called set_language that will translate any given url and redirect the user to that translated url. Very nice. With just a little bit of work, you can make it work even better with HTMX.

What you will learn

In this guide, you'll learn:

  • How Django's built in set_language function works and how to use it
  • How to modify set_language to work with prefix_default_language=False set in i18n_patterns
  • How to make it super smooth with HTMX

Getting started

Before getting started, first you need to setup your website for localization. Check out Testdrive.io's guide for setting up Django's localization framework. The rest of this guide assumes you've got a localized project, a basic understanding of how to use it, and just need a language switcher.

For HTMX stuff, I assume you have HTMX set up and ready to use (if not, refer to my guide on setting up HTMX and the rest of the GRUG stack ).

How to use set_language

The documentation on set_language states:

As a convenience, Django comes with a view, django.views.i18n.set_language(), that sets a user’s language preference and redirects to a given URL or, by default, back to the previous page.

This means that you should be able to use set_language anywhere for any arbitrary page. Nice. The docs also include an example HTML template for using set_language:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
{% load i18n %}
<form action="{% url 'set_language' %}" method="post">{% csrf_token %}
    <input name="next" type="hidden" value="{{ redirect_to }}">
    <select name="language">
        {% get_current_language as LANGUAGE_CODE %}
        {% get_available_languages as LANGUAGES %}
        {% get_language_info_list for LANGUAGES as languages %}
        {% for language in languages %}
            <option value="{{ language.code }}"{% if language.code == LANGUAGE_CODE %} selected{% endif %}>
                {{ language.name_local }} ({{ language.code }})
            </option>
        {% endfor %}
    </select>
    <input type="submit" value="Go">
</form>

There are some important things to understand:

set_language needs a POST request

1
2
<!-- set_language needs to be called in a POST request or else it'll do nothing and redirect back to the current page. -->
<form action="{% url 'set_language' %}" method="post">

The next parameter is optional

1
2
3
<!-- If you include an input in the form with the name "next", 
set_language will redirect to the value set it in. However, this is optional. If `set_language` doesn't get a next parameter in the request, it will use the current page via the HTTP_REFERER. -->
<input name="next" type="hidden" value="{{ redirect_to }}">

The language parameter needs to be set

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<!-- set_language needs a "language" parameter in the request with a value set to a language code in your LANGUAGES setting. -->
<select name="language">
    {% get_current_language as LANGUAGE_CODE %}
    {% get_available_languages as LANGUAGES %}
    {% get_language_info_list for LANGUAGES as languages %}
    {% for language in languages %}
        <option value="{{ language.code }}"{% if language.code == LANGUAGE_CODE %} selected{% endif %}>
            {{ language.name_local }} ({{ language.code }})
        </option>
    {% endfor %}
</select>

For my use, I only have one other language to switch to, so I use the following in my template:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
{% get_current_language as CURRENT_LANGUAGE_CODE %}  
{% get_available_languages as AVAILABLE_LANGUAGES %}  
{% get_language_info_list for AVAILABLE_LANGUAGES as languages %}  
{% for lang in languages %}  
  {% if lang.code != CURRENT_LANGUAGE_CODE %}  
    <form id="language-switcher" 
          action="{% url 'set_language' %}"  
          method="post">  
      {% csrf_token %}  
      <input name="language" type="hidden" value="{{ lang.code }}">  
      <button class="fixed bottom-10 right-10 bg-sky-600 text-sky-50 px-5 py-2 hover:text-neutral-900 hover:bg-yellow-400 transition ease-in-out duration-300"  
              type="submit">{{ lang.name_local }}</button>  
    </form>  
  {% endif %}  
{% endfor %}

This will generate a button for each language in LANGUAGES in your settings.py. The button submits the language code as the value of the language parameter. set_language will use that value to set the user's language preferences for your site.

I also styled it to make the button fixed to the bottom right of the screen.

Now, all we need is to make sure that the set_language view is in our project-level urls.py file:

1
2
# Make sure this is not inside i18n_patterns() or it won't work.
path("i18n/", include("django.conf.urls.i18n")),

You should now have a language switcher that works on every current and future page of your site.

One problem

When I first used set_language, I had a problem that you might run into. I could switch from my default language, but I couldn't switch back to it. The problem was related to the fact that I had prefix_default_language=False set in i18n_patterns. prefix_default_language=False means that when the user is on the default language for my site, urls won't have en/ added to the path (switch to Japanese now and notice that ja/ is added to the beginning of a path after the domain). This is a known bug.

There are two ways to fix the problem:

  1. Set prefix_default_language to True (its default value) and accept that the language code for the default language will be added to all urls.
  2. Slightly modify set_language.

To make set_language work with prefix_default_language=False, we copy the set_language function and add a fix.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
+ from django.conf import settings  
+ from django.http import HttpResponseRedirect  
+ from django.urls import translate_url  
+ from django.utils.http import url_has_allowed_host_and_scheme  
+ from django.utils.translation import check_for_language  
+ from urllib.parse import urlsplit

def set_language(request):  
    """  
    Modified version of Django's set_language built-in.  
    Redirect to a given URL while setting the chosen language in the session    (if enabled) and in a cookie. The URL and the language code need to be    specified in the request parameters.  
    Since this view changes how the user will see the rest of the site, it must    only be accessed as a POST request. If called as a GET request, it will    redirect to the page in the request (the 'next' parameter) without changing    any state.    """    
    next_url = request.POST.get("next", request.GET.get("next"))  

    if (  
            next_url or request.accepts("text/html")  
    ) and not url_has_allowed_host_and_scheme(  
        url=next_url,  
        allowed_hosts={request.get_host()},  
        require_https=request.is_secure(),  
    ):  
        next_url = request.META.get("HTTP_REFERER")  
        if not url_has_allowed_host_and_scheme(  
                url=next_url,  
                allowed_hosts={request.get_host()},  
                require_https=request.is_secure(),  
        ):  
            next_url = "/"  

+    # If next_url is set to the HTTP_REFERER, it will include the url scheme and host.  
+    # Let's grab the path so we can strip the language prefix.    
+    next_url = urlsplit(next_url).path  
+    # Strip out the language code from the url before translating the url.  
+    for lang_code, lang_name in settings.LANGUAGES:  
+        prefix = '/' + lang_code + '/'  
+        if next_url.startswith(prefix):  
+            next_url = next_url[len(prefix) - 1:]  
+            break  

    response = HttpResponseRedirect(next_url) if next_url else HttpResponse(status=204)  
    if request.method == "POST":  
        lang_code = request.POST.get(LANGUAGE_QUERY_PARAMETER)  
        if lang_code and check_for_language(lang_code):  
            if next_url:  
                next_trans = translate_url(next_url, lang_code)  
                if next_trans != next_url:  
                    response = HttpResponseRedirect(next_trans)  
            response.set_cookie(  
                settings.LANGUAGE_COOKIE_NAME,  
                lang_code,  
                max_age=settings.LANGUAGE_COOKIE_AGE,  
                path=settings.LANGUAGE_COOKIE_PATH,  
                domain=settings.LANGUAGE_COOKIE_DOMAIN,  
                secure=settings.LANGUAGE_COOKIE_SECURE,  
                httponly=settings.LANGUAGE_COOKIE_HTTPONLY,  
                samesite=settings.LANGUAGE_COOKIE_SAMESITE,  
            )    return response

Using my HTML language switcher above--where no next parameter is set in the request--the next_url will be automatically set to https://killianarts.online/ja/the/rest/of/the/path or https://killianarts.online/the/rest/of/the/path. With the code we added, the path will be extracted, leaving /ja/the/rest/of/the/path or /the/rest/of/the/path. Then, for /ja/the/rest/of/the/path, we strip out the /ja and use the rest. The translate_url function will do the work of switching the url to the correct one based on the language code.

Smooth swapping with HTMX

By default, the whole page will be reloaded with the page scrolled back to the top. By adding just a few HTMX attributes, we can make it smoothly transition to the other language via the new View Transitions API and maintain the scroll position (and even the state of other DOM elements on the screen, like the Google map on my Contact Us page) using Idiomorph. The switch to a different language will be seamless.

To get started, we need the Idiomorph library. Add this line after your HTMX script import in your template file:

1
<script src="https://unpkg.com/idiomorph/dist/idiomorph-ext.min.js"></script>

Then, modifying the form:

1
2
3
4
5
6
7
8
<form id="language-switcher"  
      action="{% url 'set_language' %}"  
      method="post"  
+     hx-post="{% url 'set_language' %}"  
+     hx-target="body"  
+     hx-push-url="true"
+     hx-ext="morph"
+     hx-swap="morph:innerHTML transition:true show:none">

hx-post="{% url 'set_language' %}" will make an HTMX POST request to the set_language function.

hx-target="body" will make the <body> element the target of whatever the response to hx-post="{% url 'set_language' %}" is.

hx-push-url="true" will update the url in the browser to the url that hx-post returns in its response. set_language simply returns an HttpResponseRedirect, which goes to a different url and returns its response.

We could also replace hx-post, hx-target, and hx-push-url with hx-boost="true", since it would do exactly the same thing. Generally, my impression is that if you already have an older code base that you want to give a quick and easy upgrade so that forms use AJAX requests, things like that, I've heard people suggest using hx-boost. If you're building a new code base or making a significant refactor, you'd be better off using the other attributes since you'll need them in most cases (for example, you probably will need a different target, and you'll often not want to push the url).

As of this writing, because of the bug with set_language above, if you want to set prefix_default_language=False with the fix I added above, I recommend using hx-boost. I don't know why, but for some pages, hx-post returns a 400 error and doesn't complete the redirect.

hx-ext="morph" will allow us to use Idiomorph.

hx-swap is how we decide the way to swap in the response to the request. Setting it to innerHTML will replace the innerHTML of the <body> with the response. That means we're replacing the whole page. You might be wondering, "Hey Micah, if you're going to replace the whole page anyway, why use HTMX at all? Why not simply reload the whole page as usual?"

HTMX doesn't replace the <head>, and thus CSS, JS, etc. imports won't be reloaded. That means a speedier switch with no Flash of Unstyled Content.

Additionally, it allows us to do other cool stuff like use Idiomorph. Putting the morph: modifier before innerHTML will make the swap method use Idiomorph, rather than the default morphing method. Using Idiomorph, when we switch languages, dynamic elements like videos or Google maps will not lose their state. Honestly, not a critical feature, but a cool one

The real meat and potatoes comes next. transition:true will make HTMX use the View Transition API for the swap. We could do fancy stuff using CSS to modify how the current page transitions to the new page returned by set_language, but the defaults are actually just fine.

Finally, show:none will maintain the current browser scroll state. By default, boosted links and forms default to show:top. Using show:none, we'll keep our scroll position on the page.

So easy, even I could do it

Altogether, with just a few lines of HTMX, we can significantly upgrade the language switcher. Now, it's super smooth and lets users switch between languages without distraction or losing their place. Useful for people who want to learn either language or for Japanese people who can't understand what they hell I'm trying to say in Japanese.

Profile Photo

Author

Added

Dec. 2, 2023

Technologies
HTMX HTMX
Django Django