This is a blog series that documents my journey of building a Hugo theme from scratch. The entire series consists of four articles, and this is the third:

Dark Mode

tailwindcss makes handling dark mode very convenient. Based on the original light theme, we only need to set the display style under the dark theme through the dark: prefix.

Add the darkMode: 'class' configuration in tailwind.config.js, and then we can modify the styles in the theme. For details on dark mode, please refer to the official documentation:

It’s worth mentioning the dark mode toggle button. To keep the theme as simple as possible and avoid referencing additional front-end frameworks, I researched some common theme button toggle examples in tailwindcss and found that using input + peer + group could achieve the desired effect very well. The code is as follows:

<div class="darkmode-toggle flex flex-none ml-2">
  <label class="flex items-center px-3 cursor-pointer rounded-full bg-gray-100 dark:bg-gray-600">
    <input type="checkbox" class="sr-only peer">
    <div class="group flex flex-row gap-1 justify-center h-8 px-1 rounded-full bg-white dark:bg-gray-700">
      <i class="h-6 w-6 flex-none rounded-full bg-yellow-400 place-self-center peer-checked:group-[]:invisible">
        {{ partial "icon" "brightness-down" }}
      <i class="h-6 w-6 flex-none rounded-full place-self-center invisible peer-checked:group-[]:visible">
        {{ partial "icon" "moon-stars" }}

I use <input type="checkbox"> to control the theme variable, and then define a group of icons using <div class="group">. Each icon uses peer-checked:group-[]:invisible and peer-checked:group-[]:visible to control visibility and hiding. This way, I only need to pay attention to the value of the checkbox, while the icon switching of the button is completely handled by tailwindcss.

const themeToggle = document.querySelector('.darkmode-toggle input');

themeToggle.addEventListener('change', function () {
  if (this.checked) {
    localStorage.theme = dark;
  } else {
    localStorage.theme = light;

Regarding group and peer, tailwindcss describes them as:

The dark theme implementation took me a day of spare time, and the final code is at #75c6b41 .

Responsive Design

tailwindcss also makes responsive design easy to use. My goal is to support both mobile and PC. When designing with mobile-first in mind,tailwindcss uses prefix-less components to design mobile styles first, and then uses prefixed components (e.g., md:) to override styles at other screen sizes. My modifications are as follows:

index ad2e93f..e5b7d76 100644
--- a/layouts/_default/list.html
+++ b/layouts/_default/list.html
@@ -15,9 +15,9 @@

   <div class="flex flex-col w-full max-w-4xl lg:max-w-5xl relative">
     <div class="flex flex-row">
-      <section class="flex flex-col w-2/3">
+      <section class="flex flex-col w-full md:w-2/3">
         {{ range (.Paginate $pages).Pages }}
-          <article class="flex flex-col gap-y-3 p-6 mt-6 rounded-lg shadow-md bg-white dark:bg-gray-700">
+          <article class="flex flex-col gap-y-3 p-6 mt-6 mx-2 md:mx-0 rounded-lg shadow-md bg-white dark:bg-gray-700">
             <h2 class="text-4xl font-semibold text-slate-800 dark:text-slate-200">
               <a href="{{ .RelPermalink }}">{{ .Title | markdownify }}</a>
@@ -40,7 +40,7 @@
         {{ end }}
-      <aside class="flex flex-col w-1/3 mx-3 top-0 sticky self-start">
+      <aside class="hidden md:flex flex-col md:w-1/3 mx-3 top-0 sticky self-start">
         {{ partial "sidebar.html" . }}

As the code shows, I changed the section tag to w-full by default and then used md:w-2/3 to display 2/3 width on larger screens.

The final code is at #279de84 .

Multilingual Support

Hugo natively supports multiple languages. First, we need to introduce configuration for multiple languages in the configuration file. For example, our example site has the configuration file exampleSite/config/_default/languages.toml, with the content:

  languageCode = 'en-US'
  languageDirection = 'ltr'
  languageName = 'English'
  weight = 1
    identifier = "post"
    name = "Post"
    pageRef = "post"
    weight = 10

    identifier = "about"
    name = "About"
    pageRef = "about"
    weight = 20

  languageCode = 'zh-CN'
  languageDirection = 'ltr'
  languageName = '中文'
  weight = 2
    identifier = "post"
    name = "文章"
    pageRef = "post"
    weight = 10

    identifier = "about"
    name = "关于"
    pageRef = "about"
    weight = 20

Then, we need to organize and place the previously hardcoded strings in the theme into the i18n folder and modify the strings in the theme using the i18n function for replacement. For example, part of the strings in the layouts/404.html file:

diff --git a/layouts/404.html b/layouts/404.html
index 77d8885..32ec853 100644
--- a/layouts/404.html
+++ b/layouts/404.html
@@ -2,11 +2,11 @@
   <main class="grid place-items-center w-full min-h-full max-w-4xl lg:max-w-5xl px-6 py-24">
     <div class="text-center w-full">
       <p class="text-base font-semibold text-indigo-600 dark:text-indigo-300">404</p>
-      <h1 class="mt-4 text-3xl font-bold tracking-tight text-slate-800 dark:text-slate-200 sm:text-5xl">Page not found</h1>
-      <p class="mt-6 text-base leading-7 text-slate-400">Sorry, we couldn't find the page you're looking for.</p>
+      <h1 class="mt-4 text-3xl font-bold tracking-tight text-slate-800 dark:text-slate-200 sm:text-5xl">{{ T "404.page_not_found" }}</h1>
+      <p class="mt-6 text-base leading-7 text-slate-400">{{ T "404.sorry" }}</p>
       <div class="mt-10 flex items-center justify-center gap-x-6">
         <a href="/" class="rounded-md bg-indigo-600 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">
-          Go back home
+          {{ T "404.go_back_home" }}

Finally, adjust the navigation bar to add a language switch button and a language list.

hugo multilingual language switch

For the complete code, please refer to #0244464 .

Additional Benefits

My native language is Chinese, and I can read English quite well, so I initially only implemented Chinese and English. But how can I add more languages to the theme? 2023 was a year of significant growth for large language models, and with the help of LLMs, I translated the language files into more languages like Russian and Japanese using prompts.

Here, I used the gemini interface to process the i18n files:

import google.generativeai as genai

# get key from
model = genai.GenerativeModel('gemini-pro')

prompt_template = '''You are a translator and your task is to translate l10n file to different languages.
The l10n file is provided in TOML format. The file contains {{ KEY }} for variables and use
`one` for singular and `other` for plural.

The TOML file is quoted in triple backtick. Please translate the content to {lang}
and keep the original content structure, also remove triple backtick in output:


en_file_content = ''
with open('./en.toml', 'r') as f:
    en_file_content =

prompt = prompt_template.format(lang='Chinese', en_file_content=en_file_content)

response = model.generate_content(prompt)

In the prompt, I included some hugo and code-related instructions, like the file format is TOML, using {{ KEY }} for variables, and handling singular and plural with onefor singular andother for plural. I also used instructions to ensure the LLM output retained the original format. I tested with Chinese, which I’m familiar with, and the above code output the following:

table_of_contents = "目录"
open_main_menu = "打开主菜单"
open_lang_switcher = "打开语言切换器"
  one = "阅读时长一分钟"
  other = "阅读时长{{ .Count }}分钟"

darkmode_toggle = "切换深色模式"

go_back_home = "返回主页"
sorry = "抱歉,找不到您要查找的页面。"
page_not_found = "页面未找到"

powered_by = "由{{ .Generator }} {{ .Heart }} {{ .Theme }}提供支持"
copyright_with_since ="{{ .CopyrightSign }} {{ .SinceYear }} - {{ .CurrentYear}} {{ .Copyright }}"
copyright_wo_since ="{{ .CopyrightSign }} {{ .CurrentYear}} {{ .Copyright }}"

newer_posts = "较新的文章"
older_posts = "较早的文章"

categories = "分类"
tags = "标签"
series = "系列"

The final result was quite good. The file structure was correct, and the variables were preserved. I used a similar method to generate localization files for other languages. Of course, I don’t actually speak Russian, Japanese, etc., so I couldn’t accurately judge the quality of the translations.



Hugo provides shortcodes to extend markdown rendering capabilities. shortcodes are reusable code snippets that can make editing markdown more efficient. Hugo has some built-in common shortcodes, like figure, gist, etc. You can refer to .

Some of my blogs use asciinema to record terminal interactions, and I use shortcodes to conveniently embed asciinema. Hugo searches for shortcodes code in a specific order, with the file name being the final name used in markdown:

  1. /layouts/shortcodes/<SHORTCODE>.html
  2. /themes/<THEME>/layouts/shortcodes/<SHORTCODE>.html

I created the layouts/shortcodes/asciinema.html file and referenced the official embedding documentation: . I wrote the following content in the html:

{{ $id := .Get 0 }}

<script async id="asciicast-{{ $id }}" src="{{ $id }}.js"></script>

where .Get 0 means getting the first parameter. Of course, you can also use named parameters. The usage is:

{{< asciinema 239367 >}}

The effect is as follows:

Render Hooks

Hugo uses goldmark to render markdown, and render hooks allow developers to override the rendering of specific components. Currently supported are:

  • image
  • link
  • heading
  • codeblock

Simply, we can add additional attributes to the a tag rendered by the link to ensure security. For example, add the filelayouts/_default/_markup/render-link.html with the content:

<a href="{{ .Destination | safeURL }}"{{ with .Title }} title="{{ . }}"{{ end }}{{ if strings.HasPrefix .Destination "http" }} target="_blank" rel="noopener"{{ end }}>{{ .Text | safeHTML }}</a>

This code adds the rel="noopener" attribute when rendering links to external sites.

Of course, more complex operations can be added to the rendering of images, such as lazy loading, scaling images to fit different resolutions, and converting images to compressed formats. You can refer to the processing in layouts/_default/_markup/render-image.html .

Code Block Syntax Highlighting and Copying

Hugo uses chroma for code highlighting. It works well with the default configuration, but I want to go further, such as having the code block theme switch along with the light and dark modes, and providing code block copying functionality.

To support custom styles, we need to adjust the code highlighting configuration in the config/_default/hugo.toml file to use CSS classes to apply highlighting styles:

  noClasses = false

Then use the command to generate code highlighting styles for our light and dark themes:

hugo gen chromastyles --style=solarized-light
hugo gen chromastyles --style=solarized-dark

Write the styles into the main style file. For the dark theme, you can use the column editing mode of the IDE to add .dark before each line of style to restrict it. The effects under light and dark themes are as follows:

hugo syntex highlight light
hugo syntex highlight dark

However, in some cases, chroma’s default CSS styles may conflict with tailwindcss’s typography plugin. For example, when the line number configuration is enabled, you will find that there is a large gap between the code line numbers and the code block:

hugo syntex highlight lineno error

This is because chroma uses table elements when rendering line numbers, while typography adds additional margins to pre elements. In this case, we need to find the conflict points and then override some styles. The following CSS styles will remove the margins and border-radius of the pre elements in code blocks with line numbers and change the display property of the table:

/* fix highlight with line number */
.highlight .chroma .lntable {
  margin: 1.7142857em 0;
  padding: 0.8571429em 1.1428571em;
  border-radius: 0.375rem;
  overflow: hidden;

.highlight .chroma .lntable pre {
  margin: 0;
  padding: 0.8571429em 0;
  border-radius: 0;

/* */
.highlight .chroma:has(table) {
  background-color: transparent;

.highlight .lntable tr {
  display: flex;

.highlight .lntable tr .lntd:last-child {
  flex-grow: 1;

Code block copying is achieved by checking if there are pre elements on the page, then finding the .highlight elements on the page, and adding corresponding copy buttons. I used the statement {{- if (findRE "<pre" .Content 1) }} in baseof.html to search for elements, which reduces JavaScript loading. Additionally, note that code blocks with line numbers will render two code elements, and we need to take the last one. Relevant code can be found at #4d62bba and #df6ac6d .


When allowing users to modify the theme, we need to keep the original CSS and JavaScript code. But for general direct users, we need to shield these code compilation logic while ensuring that the final generated static resources are compressed. Here we can use the pipes feature provided by Hugo. For tailwindcss, I initially placed the original CSS style in assets/css/main.css, the compiled code in the static folder static/main.css, and directly referenced the static file in head.html:

<!-- Theme CSS (/static/main.css) -->
<link rel="stylesheet" href="{{ "main.css" | relURL }}" />

I wanted to display the original CSS style during development but use the compressed version with a hashed filename when deployed. Ultimately, I adjusted the location of the CSS files, placing both the pre-compiled and post-compiled CSS code in assets/css (so that Hugo can process the resources), and adjusted the inclusion of CSS files:

{{- $styles := resources.Get "css/index.css" -}}
{{- $env := getenv "HUGO_THEME_DEVELOPMENT" -}}

{{- if eq $env "true" }}
  {{- $styles = resources.Get "css/main.css" | postCSS (dict "config" "./postcss.config.js") -}}
{{ end -}}

{{- if hugo.IsDevelopment }}
  {{- $styles = $styles | resources.ExecuteAsTemplate (printf "css/" now.UnixMilli) . -}}
{{ else }}
  {{- $styles = $styles | minify | fingerprint -}}
{{ end -}}
<link rel="stylesheet" href="{{ $styles.RelPermalink }}">

Here, I use HUGO_THEME_DEVELOPMENT to identify the theme development mode, and process CSS styles using PostCSS in theme development mode. When using the hugo server command, Hugo is in development mode, hugo.IsDevelopment is true, and filenames with timestamps are used in development mode to prevent caching. In production mode, | minify | fingerprint is applied for compression and file caching.

Regarding the environments corresponding to different Hugo commands, you can refer to the following table:

hugo --environment stagingstaging
hugo serverdevelopment
hugo server --environment stagingstaging

Similarly, we can handle JavaScript files in a similar way, such as for code block copying:

{{- $jsCopy := resources.Get "js/code-copy.js" | js.Build "code-copy.js" -}}
{{- if not hugo.IsDevelopment }}
  {{- $jsCopy = $jsCopy | minify | fingerprint -}}
{{ end -}}
<script src="{{ $jsCopy.RelPermalink }}"></script>


I’ve completed most of the code writing, but I still need to check if the pages rendered by my theme conform to some standards and best practices. I used the built-in lighthouse developer tool in chrome to check the site.

It can be seen that the performance score of the site is good, but there is room for improvement in accessibility and SEO. For example, the contrast of some colors in the theme, the alt attribute of images, etc.

hugo theme lighthouse original report

Based on lighthouse’s suggestions, I made adjustments and optimizations for each suggestion, and finally, the lighthouse score reached a high level:

hugo theme lighthouse report after modification