Theme Developer Journey
From CSS tweaks to building complete custom themes.
Take control of how your site looks. Start with CSS variable overrides, progress to template recipes, and end with a complete custom theme.
Tip
Duration: ~90 min | Prerequisite: HTML, CSS, basic Jinja2
Themer Quickstart
Customize themes and create your own designs
Themer Quickstart
Learn to customize Bengal themes and create your own designs.
Prerequisites
- Bengal installed
- Basic knowledge of HTML
- Familiarity with CSS
- Basic template syntax (Bengal uses Kida, similar to Jinja2 but with
{% end %}closers)
Understand Theme Resolution
Bengal looks for templates in this order:
- Your project —
templates/(overrides everything) - Your theme —
themes/your-theme/templates/ - Installed themes — Via pip or uv
- Default theme — Built into Bengal
You only need to override what you want to change.
Create a Custom Theme
bengal new theme my-theme
This creates:
themes/my-theme/
├── templates/
│ ├── base.html
│ ├── home.html
│ ├── page.html
│ └── partials/
│ ├── header.html
│ └── footer.html
└── assets/
├── css/
│ └── style.css
├── js/
│ └── main.js
└── images/
Configure Your Theme
Updatebengal.toml:
[theme]
name = "my-theme"
Or use config/_default/theme.yamlfor split configuration:
theme:
name: "my-theme"
Override Templates Selectively
You do not need to copy all templates. Extend the default:
{# themes/my-theme/templates/base.html #}
{% extends "base.html" %}
{% block header %}
<header class="custom-header">
<h1>{{ site.title }}</h1>
{% for item in menus.main %}
<a href="{{ item.href }}">{{ item.name }}</a>
{% end %}
</header>
{% end %}
Everything not overridden inherits from the default theme or parent theme.
Add Custom CSS
Createthemes/my-theme/assets/css/custom.css:
:root {
--color-primary: #3498db;
--color-text: #2c3e50;
}
.custom-header {
background: var(--color-primary);
padding: 2rem;
}
Include in your template:
{% block extra_head %}
<link rel="stylesheet" href="{{ asset_url('css/custom.css') }}">
{% end %}
Template Variables
Key variables available in templates (all support safe dot-notation access):
| Variable | Description |
|---|---|
site.title |
Site title from configuration |
site.description |
Site description |
site.baseurl |
Site base URL |
site.pages |
All pages in the site |
menus.main |
Main navigation menu (safe access) |
page.title |
Current page title |
page.content |
Rendered HTML content (use with| safe) |
page.href |
Page URL with baseurl applied |
page.date |
Publication date |
page.tags |
List of tags |
page.description |
Page description |
params |
Page frontmatter (cascades page → section → site) |
theme |
Theme configuration (safe dot-notation access) |
bengal |
Engine metadata |
Bengal provides 80+ template functions. Common ones:
asset_url('path')— Generate asset URLsurl_for('path')— Generate page URLsget_menu('name')— Get a navigation menutime_ago/date_iso— Format dates ({{ page.date | time_ago }})truncate_chars(text, length)— Truncate text
Debug Theme Issues
# List available themes
bengal utils theme list
# Get theme info
bengal utils theme info default
# Debug theme resolution
bengal utils theme debug
# Discover installed themes
bengal utils theme discover
# Swizzle a template for customization
bengal utils theme swizzle partials/header.html
Next Steps
- Theme Customization — Deep dive into overrides
- Template Functions — All available filters and functions
- Variables Reference — Complete template variables
- Assets — CSS, JS, and image handling
Customize Themes
Customize existing themes without breaking updates
Customize Themes
Customize Bengal themes without breaking theme updates. Use theme inheritance, template overrides, and CSS customization techniques.
Understand Theme Resolution
Bengal resolves themes in this order:
- Project themes -
themes/your-theme/(highest priority) - Installed themes - Installed via pip/uv
- Bundled themes - Built into Bengal (e.g.,
default)
Check Active Theme
# List available themes
bengal utils theme list
# Get theme info
bengal utils theme info default
# Debug theme resolution
bengal utils theme debug
Create a Project Theme
Option 1: Start from Scratch
bengal new theme my-custom-theme
This creates:
themes/my-custom-theme/
├── theme.yaml
├── templates/
│ ├── base.html
│ ├── page.html
│ └── partials/
│ ├── header.html
│ └── footer.html
└── static/
├── css/
│ └── style.css
└── js/
└── main.js
Configure Your Theme
Editbengal.toml:
[theme]
name = "my-custom-theme"
default_appearance = "light"
Override Templates Selectively
Template Inheritance
You don't need to copy all templates. Override only what you need:
themes/my-custom-theme/templates/base.html:
{# Extend default theme's base template #}
{% extends "default/base.html" %}
{# Override only the header block #}
{% block header %}
<header class="custom-header">
<h1>{{ site.title }}</h1>
<nav>
{% for item in menu.main %}
<a href="{{ item.href }}">{{ item.name }}</a>
{% end %}
</nav>
</header>
{% end %}
{# Everything else inherits from default theme #}
Partial Overrides
Override specific partials:
themes/my-custom-theme/templates/partials/footer.html:
<footer class="custom-footer">
<p>© {{ site.author }} {{ "now" | date("%Y") }}</p>
<p>Custom footer content</p>
</footer>
Bengal will use your partial instead of the theme's default.
Page Hero Templates
The page hero templates display the header section of content pages including title, breadcrumbs, description, and stats.
Template Structure
Thepage-hero/directory contains separated hero templates:
templates/partials/
├── page-hero.html # Dispatcher (routes by hero_style)
└── page-hero/ # Separated hero templates
├── _macros.html # Shared macros and helpers
├── index.html # Index page hero template
├── element.html # DocElement pages (modules, classes, commands)
└── section.html # Section-index pages (packages, CLI groups)
The dispatcher (page-hero.html) routes to the appropriate template based on page type and hero_styleconfiguration.
Override Page Hero for API Pages
To customize the hero for API documentation pages:
For element pages (modules, classes, functions, commands):
Overridepartials/page-hero/element.htmlto customize the hero for API documentation elements:
{# themes/my-theme/templates/partials/page-hero/element.html #}
{% from 'partials/page-hero/_macros.html' import hero_element %}
{# Use the macro with custom parameters #}
{{ hero_element(element, params, theme) }}
{# Or create a completely custom hero #}
<div class="page-hero">
<div class="page-hero__badges">
{% include 'autodoc/python/partials/badges.html' %}
</div>
<h1 class="page-hero__title page-hero__title--code">
<code>{{ element.qualified_name }}</code>
</h1>
{% if element.description %}
<div class="page-hero__description">
{{ element.description | markdownify | safe }}
</div>
{% end %}
</div>
For section-index pages:
Overridepartials/page-hero/section.htmlto customize section index heroes:
{# themes/my-theme/templates/partials/page-hero/section.html #}
{% from 'partials/page-hero/_macros.html' import hero_section %}
{# Use the macro #}
{{ hero_section(section, params, theme) }}
{# Or create a custom section hero #}
<div class="page-hero">
<h1 class="page-hero__title">{{ section.title }}</h1>
{% let desc = section.metadata.description %}
{% if desc %}
<div class="page-hero__description">
{{ desc | markdownify | safe }}
</div>
{% end %}
</div>
Using hero_context
For CLI reference sections, pass explicit context to avoid URL sniffing:
{# In autodoc/cli/section-index.html #}
{% let hero_context = {'is_cli': true} %}
{% include 'partials/page-hero/section.html' %}
The hero_context.is_cliflag controls whether stats display:
true: "X Groups, Y Commands"false: "X Packages, Y Modules"
Template Data Access Patterns
Element templates receive aDocElementdataclass—use attribute access:
element.qualified_nameelement.descriptionelement.childrenelement.source_file
Section templates receive aSectionobject—use attribute access:
section.title(section title)section.metadata.description(safe access, returns empty string if missing)section.sorted_pages(sorted child pages)section.sorted_subsections(sorted child sections)
Customize CSS
Method 1: Override Theme CSS
Create your own CSS file that overrides theme styles:
themes/my-custom-theme/static/css/custom.css:
/* Override theme colors */
:root {
--color-primary: #3498db;
--color-text: #2c3e50;
}
/* Custom styles */
.custom-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 2rem;
}
Include in your base template:
themes/my-custom-theme/templates/base.html:
{% extends "default/base.html" %}
{% block extra_head %}
<link rel="stylesheet" href="{{ asset_url('css/custom.css') }}">
{% end %}
Method 2: Use CSS Variables
Many themes support CSS variables. Override them:
themes/my-custom-theme/static/css/overrides.css:
:root {
/* Override default theme variables */
--theme-primary: #3498db;
--theme-secondary: #2ecc71;
--theme-font-sans: 'Inter', sans-serif;
}
Theme Configuration Options
Themes can expose configuration options in two ways:
Theme-Level Configuration (theme.yaml)
Define default configuration in your theme'stheme.yamlfile:
themes/my-custom-theme/theme.yaml:
name: my-custom-theme
version: 1.0.0
# Custom configuration options
show_author: true
show_date: true
sidebar_position: left
color_scheme: light
Site-Level Overrides (bengal.toml)
Override theme defaults in your site'sbengal.toml:
[theme]
name = "my-custom-theme"
# Override theme config values
show_author = true
sidebar_position = "right"
color_scheme = "dark"
Accessing Configuration in Templates
Access theme configuration usingtheme.get()or direct property access:
{# Using theme.get() method (recommended) #}
{% if theme.get('show_author', false) %}
<p>By {{ page.author or site.author }}</p>
{% end %}
{# Direct config key access #}
{% if theme.config.show_author %}
<p>By {{ page.author or site.author }}</p>
{% end %}
Note: All keys in the [theme] section are accessible via theme.get('key') (with default) or theme.config.key(direct access).
Best Practices
Don't Modify Installed Themes
❌ Bad:
# Don't edit installed theme directly
vim $(python -m site --user-site)/bengal/themes/default/templates/base.html
✅ Good:
# Create project theme that extends default
bengal new theme my-theme
# Override only what you need
Use Theme Inheritance
✅ Good:
{% extends "default/base.html" %}
{% block header %}
{# Only override header #}
{% end %}
❌ Bad:
{# Copying entire base.html #}
<!DOCTYPE html>
<html>
{# ... hundreds of lines ... #}
</html>
Troubleshooting
Theme Not Found
Issue:Theme 'my-theme' not found
Solutions:
- Verify theme directory exists:
themes/my-theme/ - Check
theme.tomlhas correctnamefield - Run
bengal utils theme listto see available themes
Template Inheritance Not Working
Issue: Changes to parent theme not reflected
Solutions:
- Verify
extendspath is correct:"default/base.html" - Check theme chain:
bengal utils theme debug - Clear cache:
bengal clean --cache
CSS Not Loading
Issue: Custom CSS not applying
Solutions:
- Use
asset_url()filter:{{ asset_url('css/style.css') }} - Check file location:
themes/your-theme/static/css/ - Hard refresh:
Cmd+Shift+R
Navigation with NavTree
Bengal provides a pre-computed navigation tree for efficient template rendering. Useget_nav_tree(page)to access the navigation structure.
Basic Usage
<nav class="sidebar">
{% for item in get_nav_tree(page) %}
<a href="{{ item.href }}"
{% if item.is_current %}class="active"{% end %}
{% if item.is_in_trail %}class="in-trail"{% end %}>
{{ item.title }}
</a>
{% if item.has_children %}
<ul>
{% for child in item.children %}
<li>
<a href="{{ child.href }}">{{ child.title }}</a>
</li>
{% end %}
</ul>
{% end %}
{% end %}
</nav>
NavNode Properties
Each navigation node provides:
| Property | Type | Description |
|---|---|---|
item.title |
str |
Display title |
item.href |
str |
Page URL with baseurl applied |
item._path |
str |
Site-relative URL without baseurl |
item.icon |
str | None |
Icon identifier |
item.weight |
int |
Sort weight |
item.children |
list[NavNode] |
Child navigation items |
item.is_current |
bool |
True if this is the current page |
item.is_in_trail |
bool |
True if in active trail to current page |
item.is_expanded |
bool |
True if node should be expanded |
item.has_children |
bool |
True if node has children |
item.depth |
int |
Nesting level (0 = top level) |
Scoped Navigation
For section-specific navigation (e.g., docs-only sidebar):
{% let root = page._section.root if page._section else none %}
{% for item in get_nav_tree(page, root_section=root) %}
<a href="{{ item.href }}">{{ item.title }}</a>
{% end %}
Benefits
- Performance: O(1) lookup via cached structure (<1ms render overhead)
- Simplicity: Single function call replaces version-filtering boilerplate
- Consistency: Pre-computed structure ensures consistent navigation across pages
- Version-aware: Automatic version filtering and shared content injection
Seealso
- Templating — Template basics
- Assets — Asset pipeline
- Icon Reference — SVG icons and customization
- Variables Reference — Available template variables
Section 3: Page Not Found
Could not find page:docs/tutorials/swizzle-default-theme
Assets
CSS, JavaScript, images, and fonts
Asset Pipeline
Bengal processes your CSS, JavaScript, images, and fonts with optional minification and fingerprinting.
How Assets Flow
Asset Locations
| Location | Copied To | Processing | Use For |
|---|---|---|---|
static/ |
public/ |
None | Files that don't need processing |
assets/ |
public/ |
Full pipeline | CSS/JS needing minification |
Theme'sstatic/ |
public/ |
None | Theme's static files |
| Page bundles | public/ |
Scope-limited | Page-specific images/data |
Quick Reference
# bengal.toml
[assets]
minify = true
optimize = true
fingerprint = true # main.css → main.a1b2c3.css
{# Basic asset URL (function syntax) #}
<link rel="stylesheet" href="{{ asset_url('css/main.css') }}">
{# Fingerprinting is automatic when enabled in config #}
{# Outputs: /assets/css/main.a1b2c3.css #}
{# Images #}
<img src="{{ asset_url('images/logo.png') }}" alt="Logo">
{# Access assets co-located with current page #}
{% for image in page.resources.match("*.jpg") %}
<img src="{{ image.rel_permalink }}" alt="{{ image.title }}">
{% end %}
Tip
Fingerprinting adds a hash to filenames (main.a1b2c3.css) for cache-busting. Enable it in production for optimal caching.
Icons
Bengal includes a comprehensive Icon Reference powered by Phosphor Icons. Use inline icons in content with{icon}name`` syntax:
{icon}`check:24:icon-success` Task complete!
For custom icons, place SVG files in themes/your-theme/assets/icons/. See the Icon Reference for the full gallery and customization options.
Template Cookbook
Common templating patterns and Bengal-specific features
Template Cookbook
Practical examples showing how to accomplish common tasks with Bengal's templating system.
Content Queries
Work with pages, sections, and taxonomies.
| Example | What You'll Learn |
|---|---|
| List Recent Posts | where, sort_by, limitfilters |
| Group by Category | group_byfilter, nested loops |
| Filter by Multiple Tags | Chaining filters,inoperator |
| Archive Page | group_by_year, group_by_monthfilters |
| Featured Posts | section.featured_posts, highlighting content |
| Template Views | EndpointView, SchemaView, TagViewnormalized objects |
Page Features
Add features to individual pages.
| Example | What You'll Learn |
|---|---|
| Add Table of Contents | page.toc, scroll highlighting |
| Show Reading Time | page.reading_timeproperty and filter |
| Content Freshness | age_days, age_months, "new" badges |
| Author Byline | page.author, avatars, social links |
| Series Navigation | prev_in_series, next_in_series |
| Social Sharing Buttons | share_url(), platform share links |
| Section Statistics | post_count, word_count, totals |
Quick Reference
The Essentials
{# Get pages from a section #}
{% let posts = site.pages |> where('section', 'blog') %}
{# Sort by date, newest first #}
{% let recent = posts |> sort_by('date', reverse=true) %}
{# Limit to 5 #}
{% let latest = recent |> limit(5) %}
{# Or chain it all #}
{% let latest = site.pages
|> where('section', 'blog')
|> sort_by('date', reverse=true)
|> limit(5) %}
Common Filters
| Filter | Purpose | Example |
|---|---|---|
where |
Filter by field | pages |> where('draft', false) |
sort_by |
Sort results | pages |> sort_by('title') |
limit |
Take first N | pages |> limit(10) |
group_by |
Group by field | pages |> group_by('category') |
first |
Get first item | pages |> first |
Both\|> (Kida pipeline) and \| (Jinja-style) work. Examples use \|>for consistency.
See Template Functions for the complete reference.
Show Reading Time
Display estimated reading time using Bengal's reading_time filter
Show Reading Time
Display estimated reading time using Bengal'sreading_timeproperty or filter.
The Pattern
Use the page property (recommended)
<span class="reading-time">
{{ page.reading_time }} min read
</span>
Use the filter on content
<span class="reading-time">
{{ page.content | reading_time }} min read
</span>
Both approaches calculate reading time at 200 words per minute by default.
What's Happening
| Component | Purpose |
|---|---|
page.reading_time |
Property: returns reading time in minutes (cached) |
reading_time |
Filter: counts words, divides by 200 |
Variations
Bengal provides aword_countfilter that strips HTML and counts words:
<span>{{ page.content | word_count }} words · {{ page.content | reading_time }} min read</span>
Both filters work together seamlessly since they use the same word counting logic.
{# 250 words per minute instead of 200 #}
{% let words = page.content | word_count %}
{% let minutes = (words / 250) | round(0, 'ceil') | int %}
<span>{{ minutes }} min read</span>
{% let minutes = page.content | reading_time %}
<span class="reading-time">
{% match minutes %}
{% case m if m < 1 %}
Quick read
{% case 1 %}
1 min read
{% case m %}
{{ m }} min read
{% end %}
</span>
Allow manual override for complex content:
{% let minutes = page.metadata.reading_time ?? page.content | reading_time %}
Then in frontmatter:
---
title: Complex Technical Guide
reading_time: 25 # Override calculated time
---
Seealso
- Template Functions — All filters
- List Recent Posts — Include reading time in post lists
List Recent Posts
Query and display recent content using Bengal's filters
List Recent Posts
Display the most recent posts from a section using Bengal's query filters.
The Pattern
{% let posts = site.pages
|> where('section', 'blog')
|> where('draft', false)
|> sort_by('date', reverse=true)
|> limit(5) %}
<ul class="recent-posts">
{% for post in posts %}
<li>
<a href="{{ post.href }}">{{ post.title }}</a>
<time>{{ post.date | date('%B %d, %Y') }}</time>
</li>
{% end %}
</ul>
What's Happening
| Filter | Purpose |
|---|---|
where('section', 'blog') |
Only pages in theblog/directory |
where('draft', false) |
Exclude drafts |
sort_by('date', reverse=true) |
Newest first |
limit(5) |
Take only 5 |
date('%B %d, %Y') |
Format date (alias fordateformat) |
Variations
{# All non-draft pages, any section #}
{% let recent = site.pages
|> where('draft', false)
|> sort_by('date', reverse=true)
|> limit(10) %}
{% let posts = site.pages |> where('section', 'blog') |> sort_by('date', reverse=true) %}
{% let featured = posts |> first %}
{% let rest = posts |> offset(1) |> limit(4) %}
<div class="featured">
<h2>{{ featured.title }}</h2>
<p>{{ featured.description }}</p>
</div>
<ul class="more-posts">
{% for post in rest %}
<li><a href="{{ post.href }}">{{ post.title }}</a></li>
{% end %}
</ul>
{# In a sidebar, show recent posts excluding the current one #}
{% let others = site.pages
|> where('section', 'blog')
|> sort_by('date', reverse=true)
|> limit(6) %}
{% for post in others if post._path != page._path %}
<a href="{{ post.href }}">{{ post.title }}</a>
{% end %}
Seealso
- Template Functions — All filter options
- Group by Category — Organize posts by category
Templating
Kida template engine, layouts, inheritance, and partials
Templating
Bengal's template system uses Kida as the default engine, with support for Jinja2 and custom engines.
Template Engines
- Kida (local reference) — Bengal's native template engine (default). Unified
{% end %}blocks, pattern matching, pipeline operators - Jinja2 — Industry-standard engine with excellent documentation and tooling
- Custom engines — Bring your own via the plugin API
Tip
Kida is Jinja2-compatible: Your existing Jinja2 templates work without changes. Use Kida-specific features incrementally.
Template Lookup Order
Bengal searches: Your project → Theme → Bengal defaults
Quick Start
{# templates/layouts/single.html #}
{% extends "baseof.html" %}
{% block content %}
<article>
<h1>{{ page.title }}</h1>
{{ page.content | safe }}
</article>
{% end %}
{# templates/baseof.html #}
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}{{ page.title }}{% end %}</title>
</head>
<body>
{% block header %}{% include "partials/header.html" %}{% end %}
{% block content %}{% end %}
{% block footer %}{% include "partials/footer.html" %}{% end %}
</body>
</html>
{# templates/partials/header.html #}
<header>
<nav>
{% for item in site.menus.main %}
<a href="{{ item.href }}">{{ item.title }}</a>
{% end %}
</nav>
</header>
Key Concepts
| Concept | Syntax | Purpose |
|---|---|---|
| Extends | {% extends "base.html" %} |
Inherit from parent template |
| Block | {% block name %}...{% end %} |
Replaceable section |
| Include | {% include "partial.html" %} |
Insert another template |
| Variable | Templating |
Output a value |
| Filter | {{ text \| truncate(100) }} |
Transform a value |
Template Inheritance
Kida Features
Kida is Bengal's default template engine:
- Unified syntax:
{% end %}closes all blocks (no more{% end %},{% end %}) - Pattern matching:
{% match %}...{% case %}for cleaner conditionals - Pipeline operator:
|>for readable filter chains - Fragment caching: Built-in
{% cache %}directive - Jinja2 compatible: Existing Jinja2 templates work without changes
Choose Your Engine
Kida is the default. To use a different engine, configurebengal.toml (or config/_default/site.yaml):
site:
template_engine: jinja2 # Options: kida (default), jinja2, mako
Custom Engines (BYOR)
Bring your own template engine via the protocol API. Your engine automatically gets all 80+ template functions if it satisfies theTemplateEnvironmentprotocol:
from bengal.rendering.engines import register_engine
from bengal.rendering.template_functions import register_all
class MyEngine:
def __init__(self, site):
self.site = site
self.template_dirs = [site.root_path / "templates"]
# Environment must have globals, filters, tests dicts
self._env = MyEnvironment()
# Automatically get all 80+ template functions!
register_all(self._env, site)
def render_template(self, name: str, context: dict) -> str:
# Your implementation
...
register_engine("myengine", MyEngine)
Then configure:
site:
template_engine: myengine
See Bring Your Own Template Engine for the complete guide.
Tip
Override sparingly: You only need to create templates you want to customize. Usebengal utils theme swizzle <template>to copy a template for customization. Let the rest fall through to theme defaults.
Template Functions
Quick overview of filters and functions available in templates
Template Functions
Bengal provides 80+ template functions and filters organized by category.
Functions vs Filters
Bengal provides filters (transform values) and functions (standalone operations).
Filters use the pipe operator:
{{ text | upper }}
{{ pages |> where('draft', false) |> sort_by('date') }}
Functions are called directly:
{{ get_page('docs/about') }}
{{ get_data('data/config.json') }}
{{ ref('docs/getting-started') }}
Tip
Quick rule: Transform a value? Use a filter (|). Perform an operation? Use a function (direct call).
See Template Functions Reference for complete explanation.
Function Categories
| Category | Examples | Use For |
|---|---|---|
| Collection | where, sort_by, group_by, limit |
Querying and filtering pages |
| Navigation | get_section, get_breadcrumbs, get_nav_tree |
Building navigation |
| Linking | ref, doc, anchor, relref |
Cross-references |
| Text | truncatewords, slugify, markdownify |
Text transformations |
| Date | time_ago, days_ago, date_iso |
Date formatting |
| i18n | t, current_lang, locale_date |
Internationalization |
Quick Examples
Filter Pages
{% let posts = site.pages
|> where('type', 'blog')
|> where('draft', false)
|> sort_by('date', reverse=true)
|> limit(10) %}
Build Navigation
{% let docs = get_section('docs') %}
{% let crumbs = get_breadcrumbs(page) %}
Cross-Reference
{{ ref('docs/getting-started') }}
{{ ref('docs/api', 'API Reference') }}
Seealso
- Template Functions Reference — Complete documentation with all parameters and examples
- Theme Variables Reference — Available template variables (
page,site,section) - Kida Syntax — Kida template engine syntax
Section 10: Page Not Found
Could not find page:docs/theming/variables