Skip to content

Creating Website Themes

This guide explains how to create custom themes for the Fullfinity Website Builder module.

A theme in Fullfinity is a standalone module that provides:

  • Theme Configuration - CSS variables, fonts, settings schema, color presets
  • Section Templates - Jinja2 templates that override base templates with theme-specific markup
  • Static Assets - CSS, JavaScript, and images

Themes are auto-discovered when:

  1. Module has category: module_category_themes in manifest.yaml
  2. Website module is installed

The website module provides BASE templates that are pure Bootstrap 5 with minimal styling.

Themes provide the visual experience by:

  1. Section Template Overrides (templates/section_templates.json) - Enhanced markup with theme-specific classes
  2. CSS Styling (static/css/main.css) - Fonts, colors, animations, visual enhancements
  3. JavaScript (static/js/main.js) - Interactive effects (Preact-based)

Key Principle: Templates are looked up by identifier at render time. When a theme provides a template with the same identifier as the base website module, the theme’s template is used.

modules/website_theme_mytheme/
├── manifest.yaml # Module metadata (category = Themes)
├── __init__.py # Optional hooks (post_install, etc.)
├── views/
│ └── theme.yaml # WebsiteTheme record
├── templates/
│ ├── section_templates.yaml # Section template definitions
│ ├── hero_centered.jinja # Hero section template
│ ├── features_grid.jinja # Features section template
│ ├── footer.jinja # Footer template
│ └── ... # Other section templates
└── static/
├── css/main.css # Theme styles
├── js/main.js # Theme JavaScript (Preact)
└── images/
└── thumbnail.png # Theme preview image
Terminal window
mkdir -p modules/website_theme_mytheme/{views,templates,static/{css,js,images}}
name: My Custom Theme
identifier: website_theme_mytheme
version: '1.0'
category: module_category_themes
description: A beautiful custom theme for Fullfinity websites
dependencies:
- website
icon: Palette
image: /static/images/thumbnail.png
static_paths:
- css
- js
- images

Important: The category must be "module_category_themes" for auto-discovery.

[
{
"data_type": "WebsiteTheme",
"identifier": "website_theme_mytheme",
"name": "My Custom Theme",
"description": "A beautiful custom theme",
"theme_module": "website_theme_mytheme",
"thumbnail": "../static/images/thumbnail.png",
"css_files": ["css/main.css"],
"js_files": ["js/main.js"],
"css_variables": {
"--bs-primary": "#4f46e5",
"--bs-secondary": "#7c3aed",
"--theme-primary": "#4f46e5",
"--theme-secondary": "#7c3aed",
"--theme-accent": "#06b6d4",
"--theme-text": "#1f2937",
"--theme-text-muted": "#6b7280",
"--theme-border": "#e5e7eb",
"--theme-footer-bg": "#111827",
"--theme-footer-text": "#d1d5db"
},
"settings_schema": [...],
"presets": [...]
}
]
FieldDescription
identifierUnique identifier (should match module identifier)
nameDisplay name
descriptionTheme description
theme_moduleModule identifier (for asset path resolution)
thumbnailPath to preview image
css_filesArray of CSS files (relative to module’s static folder)
js_filesArray of JS files (relative to module’s static folder)
css_variablesCSS custom properties injected into pages
settings_schemaSchema for theme customization UI
presetsPredefined color combinations

CSS variables from css_variables are injected into the page’s <style> tag. Use both Bootstrap variables (--bs-*) and theme-specific variables (--theme-*).

Every theme MUST define the complete standard variable set. Other modules (ecommerce, blog, etc.) rely on these variables to style their pages. If a theme omits any variable, those modules will fall back to hardcoded defaults that may not match the theme’s design.

All themes must provide every variable listed below in their :root block:

CategoryVariableDescription
Typography--theme-heading_fontHeading font family
--theme-body_fontBody font family
Colors--theme-primaryPrimary brand color
--theme-secondarySecondary/accent color
--theme-accentHighlight/focus color
--theme-successSuccess state
--theme-warningWarning state
--theme-dangerError/danger state
Text--theme-textPrimary text color
--theme-text_mutedSecondary/muted text
--theme-heading_colorHeading text color
--theme-subheading_colorSubheading text color
Backgrounds--theme-backgroundPage background
--theme-surfaceCard/panel background
--theme-surface-hoverSurface hover state
--theme-borderDefault border color
--theme-border-hoverBorder hover state
Navbar--theme-navbar_bgNavbar background
--theme-navbar_textNavbar text color
Footer--theme-footer_bgFooter background
--theme-footer_textFooter text color
--theme-footer_text_mutedFooter muted text
Gradients--theme-gradientPrimary gradient
--theme-gradient-subtleSubtle/light gradient
--theme-primary-hoverPrimary color hover state
--theme-secondary-hoverSecondary color hover state
Shadows--theme-shadow-xsExtra small shadow
--theme-shadow-smSmall shadow
--theme-shadowDefault shadow
--theme-shadow-mdMedium shadow
--theme-shadow-lgLarge shadow
--theme-shadow-coloredBrand-colored shadow
Border Radius--theme-radius-smSmall (4px)
--theme-radiusDefault (6px)
--theme-radius-lgLarge (8px)
--theme-radius-xlExtra large (12px)
--theme-radius-2xl2x large (16px)
--theme-radius-fullPill shape (100px)
Transitions--theme-transition-fastFast (0.1s)
--theme-transitionDefault (0.15s)
--theme-transition-slowSlow (0.25s)
--theme-transition-smoothSmooth easing (0.3s)
Buttons--theme-btn-primary-bgPrimary button background
--theme-btn-primary-textPrimary button text
--theme-btn-primary-borderPrimary button border
--theme-btn-primary-hover-bgPrimary button hover
--theme-btn-secondary-bgSecondary button background
--theme-btn-secondary-textSecondary button text
--theme-btn-secondary-borderSecondary button border
--theme-btn-secondary-hover-bgSecondary button hover
Utility--theme-primary-foregroundText on primary backgrounds
--theme-icon-filterCSS filter for icons (e.g. invert(1) for dark mode)
--theme-login-logo-filterCSS filter for login page logo

Module CSS should use --theme-* variables directly with raw fallbacks:

.my-card {
background: var(--theme-surface, #fafafa);
border: 1px solid var(--theme-border, #e5e7eb);
border-radius: var(--theme-radius-lg, 0.75rem);
color: var(--theme-text, #1f2937);
}

The fallback values ensure the module works even without a theme installed. When a theme is active, its variables override the fallbacks automatically.

The customer portal automatically inherits your theme’s CSS variables. No extra configuration needed - the portal will match your theme out of the box.

Define customizable settings for the theme editor. Settings are grouped into sections:

"settings_schema": [
{
"name": "Colors",
"settings": [
{ "type": "header", "content": "Primary Colors" },
{ "type": "color", "id": "primary", "label": "Primary", "default": "#4f46e5", "info": "Buttons, links" },
{ "type": "color", "id": "secondary", "label": "Secondary", "default": "#7c3aed" }
]
},
{
"name": "Typography",
"settings": [
{ "type": "font", "id": "heading", "label": "Heading Font", "default": "Inter, system-ui, sans-serif" },
{ "type": "font", "id": "body", "label": "Body Font", "default": "Inter, system-ui, sans-serif" }
]
},
{
"name": "Layout",
"settings": [
{
"type": "select",
"id": "border_radius",
"label": "Border Radius",
"options": [
{ "value": "none", "label": "None" },
{ "value": "small", "label": "Small" },
{ "value": "medium", "label": "Medium" },
{ "value": "large", "label": "Large" }
],
"default": "medium"
}
]
}
]

Provide predefined color combinations for quick theming:

"presets": [
{
"name": "Default",
"settings": {}
},
{
"name": "Ocean",
"settings": {
"primary": "#0ea5e9",
"secondary": "#06b6d4",
"accent": "#14b8a6"
}
},
{
"name": "Forest",
"settings": {
"primary": "#22c55e",
"secondary": "#16a34a",
"accent": "#84cc16"
}
}
]

Section templates define the HTML structure for page building blocks. Each template has:

  • A Jinja2 template file (.jinja)
  • A settings schema
  • Default settings values

Section Template Definition (templates/section_templates.yaml)

Section titled “Section Template Definition (templates/section_templates.yaml)”
[
{
"data_type": "WebsiteSectionTemplate",
"identifier": "hero_centered",
"name": "Hero Centered",
"category": "Hero",
"icon": "IconLayoutRows",
"sequence": 1,
"template": "hero_centered.jinja",
"schema": [
{ "type": "text", "id": "heading", "label": "Heading" },
{ "type": "textarea", "id": "subheading", "label": "Subheading" },
{ "type": "image", "id": "background_image", "label": "Background Image" },
{ "type": "checkbox", "id": "show_buttons", "label": "Show Buttons", "default": true },
{ "type": "text", "id": "primary_button_text", "label": "Primary Button Text" },
{ "type": "url", "id": "primary_button_link", "label": "Primary Button Link" }
],
"default_settings": {
"heading": "Build Something Amazing",
"subheading": "The modern platform for growing businesses.",
"background_image": "",
"show_buttons": true,
"primary_button_text": "Get Started",
"primary_button_link": "/contact"
}
}
]
FieldDescription
identifierUnique identifier (same as base = override)
nameDisplay name in section picker
categoryCategory: Hero, Features, Content, CTA, Testimonials, Pricing, FAQ, Team, Contact, Footer
iconTabler icon name
sequenceDisplay order in section picker
templateJinja2 template filename (in same folder)
schemaSettings schema for section editor
default_settingsDefault values for settings
TypeDescriptionProperties
textSingle line textid, label, default, placeholder
textareaMulti-line textid, label, default, placeholder
colorColor pickerid, label, default
imageImage pickerid, label, default
urlURL inputid, label, default, placeholder
emailEmail inputid, label, default
checkboxBoolean toggleid, label, default
numberNumber inputid, label, default, min, max, step
selectDropdownid, label, default, options
headerSection header (display only)content
{
"type": "select",
"id": "image_position",
"label": "Image Position",
"options": [
{ "value": "left", "label": "Left" },
{ "value": "right", "label": "Right" }
],
"default": "right"
}

Templates have access to:

  • settings - Merged section settings (defaults + user overrides)
  • theme_variables - Theme color/font settings
  • now() - Current datetime function
{#- Hero Centered Section -#}
{%- set tv = theme_variables or {} -%}
{%- set primary = tv.primary or '#4f46e5' -%}
{%- set secondary = tv.secondary or '#7c3aed' -%}
<section class="section-hero hero-centered py-5">
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-8 text-center">
{% if settings.heading %}
<h1 class="display-4 fw-bold mb-4">{{ settings.heading }}</h1>
{% endif %}
{% if settings.subheading %}
<p class="lead text-muted mb-4">{{ settings.subheading }}</p>
{% endif %}
{% if settings.show_buttons %}
<div class="d-flex gap-3 justify-content-center">
{% if settings.primary_button_text %}
<a href="{{ settings.primary_button_link or '#' }}" class="btn btn-primary btn-lg">
{{ settings.primary_button_text }}
<i class="bi bi-arrow-right ms-2"></i>
</a>
{% endif %}
</div>
{% endif %}
</div>
</div>
</div>
</section>

For repeatable content like features or testimonials, define arrays in default_settings:

"default_settings": {
"heading": "Our Features",
"features": [
{ "title": "Easy to Use", "description": "Simple interface", "icon": "hand-thumbs-up" },
{ "title": "Fast", "description": "Lightning performance", "icon": "lightning" },
{ "title": "Secure", "description": "Enterprise security", "icon": "shield-check" }
]
}

Iterate in template:

{% for feature in settings.features or [] %}
<div class="col-md-4">
<div class="feature-icon" style="background: linear-gradient(135deg, {{ primary }} 0%, {{ secondary }} 100%);">
<i class="bi bi-{{ feature.icon }}"></i>
</div>
<h5>{{ feature.title }}</h5>
<p>{{ feature.description }}</p>
</div>
{% endfor %}

Theme JavaScript should use the Preact-based fullfinity framework for consistency and proper lifecycle management.

(function() {
'use strict';
function initTheme() {
if (!window.fullfinity) {
setTimeout(initTheme, 50);
return;
}
const { registry, useEffect, useRef, useState, html } = window.fullfinity;
// Navbar scroll effect
registry.component('MyThemeNavbarEffect', function MyThemeNavbarEffect() {
useEffect(() => {
const navbar = document.querySelector('.navbar');
if (!navbar) return;
const onScroll = () => {
if (window.scrollY > 50) {
navbar.classList.add('scrolled');
} else {
navbar.classList.remove('scrolled');
}
};
window.addEventListener('scroll', onScroll, { passive: true });
onScroll(); // Initial check
// Cleanup on unmount
return () => window.removeEventListener('scroll', onScroll);
}, []);
return null; // Non-visual component
});
// Scroll animations
registry.component('MyThemeScrollAnimation', function MyThemeScrollAnimation() {
useEffect(() => {
if (!('IntersectionObserver' in window)) {
document.querySelectorAll('[data-animate]').forEach(el => {
el.classList.add('animated');
});
return;
}
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('animated');
observer.unobserve(entry.target);
}
});
}, { threshold: 0.1 });
document.querySelectorAll('[data-animate]').forEach(el => observer.observe(el));
return () => observer.disconnect();
}, []);
return null;
});
// Auto-mount components
const components = ['MyThemeNavbarEffect', 'MyThemeScrollAnimation'];
components.forEach(name => {
if (!document.querySelector(`[data-component="${name}"]`)) {
const el = document.createElement('div');
el.setAttribute('data-component', name);
el.style.display = 'none';
document.body.appendChild(el);
}
});
console.log('[MyTheme] Initialized');
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initTheme);
} else {
initTheme();
}
})();
FeatureUsage
registry.component(name, fn)Register a component
useState, useEffect, useRefPreact hooks
html\…“Tagged template for JSX-like syntax
signal(), computed(), effect()Preact signals
:root {
--theme-primary: #4f46e5;
--theme-secondary: #7c3aed;
--theme-radius: 8px;
}
.btn-primary {
background: var(--theme-primary);
border-radius: var(--theme-radius);
}

Prefix styles with section class names to avoid conflicts:

.section-hero .hero-title {
font-size: 3.5rem;
letter-spacing: -0.03em;
}
.section-features .feature-card {
border: 1px solid var(--theme-border);
border-radius: var(--theme-radius);
}

Use Bootstrap breakpoints and mobile-first approach:

.section-hero .hero-title {
font-size: 2rem;
}
@media (min-width: 768px) {
.section-hero .hero-title {
font-size: 2.5rem;
}
}
@media (min-width: 992px) {
.section-hero .hero-title {
font-size: 3.5rem;
}
}

Define reusable animations:

@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
[data-animate] {
opacity: 0;
}
[data-animate].animated {
animation: fadeInUp 0.6s ease forwards;
}

Themes use Bootstrap Icons. The icon CSS is included automatically.

In templates, use the full icon class:

<i class="bi bi-arrow-right"></i>

For dynamic icons from settings, store just the icon name:

{ "icon": "rocket-takeoff" }

Then in template:

<i class="bi bi-{{ feature.icon }}"></i>

Create __init__.py to run code when the theme is installed:

from fullfinity.engine.context import env_ctx
async def post_install():
"""Set this theme as default for websites without a theme."""
env = env_ctx.get()
Website = env("Website")
WebsiteTheme = env("WebsiteTheme")
theme = await WebsiteTheme.filter(identifier="website_theme_mytheme").first()
if not theme:
return
# Set as default for websites without a theme
websites = await Website.filter(theme__isnull=True).all()
for website in websites:
website.theme = theme.id
await website.save()
  1. Test with different content lengths - Ensure sections handle long/short text gracefully
  2. Mobile-first - Use Bootstrap’s responsive utilities and test on mobile
  3. Accessibility - Use semantic HTML, proper heading hierarchy, and sufficient color contrast
  4. Performance - Minimize custom CSS/JS, use efficient selectors
  5. Fallbacks - Always provide default values in templates:
    {{ settings.heading or 'Default Heading' }}
  6. Theme variables - Access theme colors in templates via theme_variables:
    {%- set tv = theme_variables or {} -%}
    {%- set primary = tv.primary or '#4f46e5' -%}