How to build custom Hugo website by Antigravity

Tech

TABLE OF CONTENTS


Foreword

This post documents how I built a custom Hugo theme from scratch for this blog. I wanted full flexibility over the layout and styling, rather than being constrained by someone else’s design decisions. In this post, I’ll refer to my theme as my-custom-theme to keep things generic.

All of the implementation — the templates, CSS, JavaScript, and configuration — was built by Antigravity, Google DeepMind’s AI coding assistant 😂

Theme Structure

Here’s the file structure:

themes/my-custom-theme/
├── layouts/
│   ├── _default/
│   │   ├── baseof.html       # Base template (HTML shell)
│   │   ├── single.html       # Individual post page
│   │   ├── list.html         # Category/tag listing
│   │   ├── taxonomy.html     # Taxonomy page
│   │   ├── term.html         # Term page
│   │   └── _markup/
│   │       └── render-image.html  # Markdown image hook
│   ├── index.html            # Homepage with post grid
│   ├── page/
│   │   └── archives.html     # Archives page
│   ├── partials/
│   │   ├── head.html         # <head> with meta, fonts, CSS
│   │   └── sidebar.html      # Sidebar navigation
│   └── shortcodes/
│       └── img.html          # Custom image shortcode (CDN)
└── static/
    ├── css/main.css          # All styles
    ├── js/lightbox.js        # Image viewer
    └── img/avatar.png        # Profile picture

The Base Template — baseof.html

Every page on the site inherits from this template. It defines the HTML shell, includes the sidebar, and injects the lightbox overlay for image viewing:

<!DOCTYPE html>
<html lang="{{ .Site.LanguageCode }}">
{{ partial "head.html" . }}
<body>
    <div class="container">
        {{ partial "sidebar.html" . }}
        <main class="main-content">
            {{ block "main" . }}{{ end }}
        </main>
    </div>

    <!-- Lightbox Overlay -->
    <div id="lightbox-overlay" onclick="closeLightbox()">
        <img id="lightbox-img" src="" alt="Lightbox zoom view">
    </div>

    <script src="{{ "/js/lightbox.js" | relURL }}"></script>
</body>
</html>

The sidebar is a fixed panel on the left with the site avatar, title, and navigation links. It automatically maps SVG icons to menu items based on their names (Home, Archives, Categories):

<aside class="sidebar">
    <div class="sidebar-branding">
        <a href="{{ .Site.BaseURL | relURL }}">
            <img src="{{ "/img/avatar.png" | relURL }}" class="sidebar-avatar">
        </a>
        <a href="{{ .Site.BaseURL | relURL }}" class="sidebar-logo">
            {{ .Site.Title }}
        </a>
    </div>
    
    <nav class="sidebar-nav">
        <!-- Menu items with auto-mapped SVG icons -->
    </nav>
</aside>

The icon mapping uses Hugo’s template logic to match menu item names to inline SVGs, so I don’t need any external icon library.

Homepage — Post Grid

The homepage displays posts in a card grid layout. Each card shows the cover image (or a placeholder), the date, and the title:

{{ define "main" }}
<div class="post-grid">
    {{ range .Paginator.Pages }}
    <a href="{{ .Permalink }}" class="post-card">
        <img src="{{ $image }}" class="post-card-img" loading="lazy">
        <div class="post-card-body">
            <span class="post-card-date">{{ .Date.Format "2006-01-02" }}</span>
            <h2 class="post-card-title">{{ .Title }}</h2>
        </div>
    </a>
    {{ end }}
</div>
{{ end }}

Single Post Page

The article page shows the cover image, title, date, category pills, and the content. It also includes previous/next navigation at the bottom:

{{ define "main" }}
<article class="article-page">
    <header class="article-header">
        <span class="article-meta">{{ .Date.Format "2006-01-02" }}</span>
        <h1 class="article-title">{{ .Title }}</h1>
        {{ with .Params.categories }}
        <div class="article-categories">
            {{ range . }}
            <a href="{{ "categories/" | relURL }}{{ . | urlize }}" 
               class="category-pill">{{ . }}</a>
            {{ end }}
        </div>
        {{ end }}
    </header>

    <!-- Featured image -->
    <!-- Article content -->
    <!-- Prev/Next navigation -->
</article>
{{ end }}

Image Lightbox

When a user clicks on any image in a post, a full-screen overlay displays the high-resolution version. The lightbox is built with vanilla JavaScript — no libraries needed:

function openLightbox(url) {
    const overlay = document.getElementById('lightbox-overlay');
    const img = document.getElementById('lightbox-img');
    img.src = url;
    overlay.style.display = 'flex';
    document.body.style.overflow = 'hidden';
}

The lightbox also includes a smart auto-pairing feature: when two images are placed consecutively in a post, they automatically arrange into a side-by-side layout with matched heights based on their aspect ratios.

Smart Image Pairing

The lightbox.js file scans for consecutive <figure> elements and wraps them in .image-pair containers. It calculates the aspect ratio of each image and adjusts the flex proportions so they align perfectly:

const ar1 = img1.naturalWidth / img1.naturalHeight;
const ar2 = img2.naturalWidth / img2.naturalHeight;
current.style.flex = ar1 + " 1 0%";
next.style.flex = ar2 + " 1 0%";

This means I can just write two img shortcodes on consecutive lines, and they’ll automatically render as a beautiful paired layout without any extra markup.

Configuration — hugo.toml

The Hugo configuration is minimal:

baseURL = 'https://yourdomain.com/'
title = 'My Blog'
theme = 'my-custom-theme'
languageCode = 'ja'

[params]
description = 'A personal blog'
avatar = 'img/avatar.png'

[taxonomies]
category = 'categories'

[markup.goldmark.renderer]
unsafe = true

I disabled tags (only using categories) and enabled unsafe HTML rendering in Goldmark so I can embed custom HTML when needed.

Additional elements

Solving Mobile Layout & Scaling Issues

One of the trickiest parts of building a custom theme is ensuring everything looks perfect on mobile. During development, we encountered several “blowout” issues where images or tables would stretch the layout wider than the screen. Here’s how we solved them:

1. Preventing Layout Blowout

Sometimes, high-resolution images or long navigation text can force a CSS Grid container to expand beyond the viewport. We fixed this by using minmax(0, 1fr) for our mobile grid columns and adding overflow-x: hidden to the body to ensure the viewport remains locked to the screen width.

@media (max-width: 1024px) {
  .container {
    grid-template-columns: minmax(0, 1fr);
  }
  html, body {
    overflow-x: hidden;
    width: 100%;
  }
}

2. Responsive Markdown Tables

Markdown tables are notoriously difficult to make responsive. If you simply apply display: block to a table to enable horizontal scrolling, it loses its ability to stretch to 100% width, leaving a gap on the right.

Instead, we used a Hugo Render Hook. We created a file at layouts/_default/_markup/render-table.html that automatically wraps every markdown table in a scrollable container:

<div class="scrollable-table-wrapper" style="overflow-x: auto; -webkit-overflow-scrolling: touch;">
  <table>
    <!-- Table content -->
  </table>
</div>

This ensures the table stretches naturally on desktop but becomes scrollable on mobile without breaking the layout.

3. Scaling and Typography

We refined the mobile experience by adjusting font sizes for better readability on small screens. For example, shrinking the main .article-title and body text slightly ensures that content feels balanced on a phone. We also added a global max-width: 100% for all images within the mobile media query to prevent them from “bleeding” over the edges.

Standard markdown links [text](url) were initially unstyled (default blue). We added a specific rule to the CSS to ensure they use the theme’s --accent-color and have a nice hover effect:

.article-content a {
  color: var(--accent-color);
  text-decoration: none;
  font-weight: 500;
  transition: color 0.2s;
}

Table of Contents Integration

To improve navigation in longer posts, I integrated an inline Table of Contents (TOC) that appears just before the main article content.

1. Layout Implementation

In layouts/_default/single.html, I injected Hugo’s built-in .TableOfContents variable. I also added a “TABLE OF CONTENTS” title and a separator line to give it a more formal structure:

{{ if .TableOfContents }}
<div class="article-toc">
    <h2 class="toc-title">TABLE OF CONTENTS</h2>
    <hr class="toc-separator">
    {{ .TableOfContents }}
</div>
{{ end }}

2. Card-Style Design

The TOC is styled as a distinct “card” with a light background (#f5f5f7), rounded corners, and generous padding. This separates it visually from the rest of the content without being too distracting:

.article-toc {
  background-color: #f5f5f7;
  border-radius: 16px;
  padding: 24px 32px;
  margin-bottom: 40px;
}

.toc-title {
  font-size: 0.75rem;
  font-weight: 700;
  letter-spacing: 0.1em;
  color: var(--text-secondary);
}

3. Hierarchical Hierarchy

I used CSS to remove default bullets and apply indentation to nested list items (H3, H4). This preserves the hierarchy of the post structure while maintaining a clean, minimalist look. The TOC is also configured in hugo.toml to capture headings from level 2 down to 4.

Consistent Code Aesthetics

To bring the same “framed” look to technical content, I updated the styling for inline code tags.

1. TOC-Inspired Inline Code

Inline code tags now use the same light-gray background (#f5f5f7) and subtle border as the TOC. I used a specific CSS selector to ensure these styles only apply to inline text and don’t interfere with the dark macOS-style code blocks:

/* Inline Code only (excludes code blocks) */
:not(pre) > code {
  background-color: #f5f5f7;
  padding: 0.2em 0.4em;
  border-radius: 6px;
  border: 1px solid rgba(0, 0, 0, 0.08);
  font-family: "SF Mono", monospace;
  font-size: 0.9em;
}

2. Protecting Code Blocks

Because the main code blocks (.highlight) use a dark, high-contrast theme, it was crucial to ensure they didn’t inherit the light-gray background. I added a reset to keep them exactly as designed:

.highlight code {
  background-color: transparent !important;
  border: none !important;
  padding: 0 !important;
  color: inherit !important;
}

This dual-themed approach allows for quick, readable inline references while maintaining a professional developer-centric look for full snippets.

Key Design Decisions

  • No JavaScript frameworks — Everything is vanilla JS and CSS. The entire site is fast and dependency-free.
  • Lazy loading everywhere — All images use loading="lazy" and decoding="async" for smooth scrolling.
  • Mobile responsive — Tables scroll horizontally, images scale to viewport width, and the sidebar nav wraps nicely on small screens.
  • CDN-first images — All images are served from Cloudflare R2 using a custom shortcode (see my other post on how that works).

Extra thoughts

To be honest, I initially tried modifying an existing Hugo theme, but it proved to be quite difficult and used a lot of context tokens for Antigravity. Ultimately, I decided it would be more efficient to build a custom theme from scratch.

This approach allowed me to strip away unnecessary dependencies that might have become technical debt later on. While I’m not a software engineer and my code might not be the simplest or cleanest by professional standards, I’m incredibly happy with the result.