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.yaml) - 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
default_headerHeader style a site adopts with this theme (see below)
default_footerFooter style a site adopts with this theme
Section titled “Header and footer styles are shared, not theme-private”

A site picks its header and footer from the styles the website module ships — classic, centered, transparent, drawer and contact-and-booking — and any theme renders whichever one the site chose. Naming one as your default_header sets what a site adopts when it activates your theme; it does not restrict what it can switch to.

Each style declares its own settings (sticky, a phone number, a call-to-action label), and those defaults apply on a site that has never opened the settings form — so a header behaves as its form says it does, unconfigured.

Ship your own style only if you need an arrangement none of them expresses. Restyling an existing one needs no new template: they read the same --theme-* roles as everything else, so a header follows your palette, typography and radius for free.

Restyle the header by setting roles, never by writing .navbar rules

Section titled “Restyle the header by setting roles, never by writing .navbar rules”

The header’s appearance has exactly one owner: the shell draws it from the --theme-navbar_* roles below. Override the roles. Do not write .navbar, .navbar-brand or .nav-link rules in your theme CSS — a private copy governs every header style, including any added after your theme ships, which your rules would then style wrongly or not at all.

:root {
--theme-navbar_backdrop: saturate(180%) blur(20px);
--theme-navbar_link_radius: var(--theme-radius-full);
--theme-navbar_brand_size: var(--theme-text-2xl);
}

The brand follows --theme-heading_font by default, so declaring a heading typeface is usually all a header needs to look like yours.

Overlay headers read against the section behind them

Section titled “Overlay headers read against the section behind them”

The transparent style has no ground of its own, so its ink is read against whatever the page starts with — which differs per page. A section registered under the Hero category declares this with overlay_tone in section_templates.yaml:

overlay_toneMeaning
Auto (default)Dark when the section’s settings carry a background_image, else light
DarkAlways dark behind the header — light chrome
LightAlways light behind the header — the palette’s own ink

Auto is right for a hero that is dark only when the site owner supplies a photo. State Dark when your hero is always dark — a poster hero that paints its own image and exposes no background setting has nothing for Auto to read.

Light chrome comes from --theme-navbar_overlay_text / _hover_color, and the bar carries --theme-navbar_overlay_scrim — a faint top-down gradient, because the top strip of a photograph is where the sky is, and light text over a bright sky is unreadable however dark the rest of the image. Once scrolled, the bar has a real background and reverts to the solid roles.

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, the customer portal) rely on these variables to style their pages. If a theme omits any variable, those modules will fall back to the neutral defaults, so the page renders half-themed.

Section schemas: what a theme owns, and what it doesn’t

Section titled “Section schemas: what a theme owns, and what it doesn’t”

A theme ships its own markup for a section identifier. It does not need to ship a schema: omit schema: and the section inherits the base module’s, and keeps inheriting it as the base evolves. Declare one only where your theme genuinely differs.

That matters because a schema field’s id is a storage key, not a design decision. It is what lets a page’s saved settings survive a theme switch:

PartOwnerNotes
idshared vocabularyKeep the base’s. Renaming it strands existing content.
labelyour themeReword freely — “Questions”, “Q&A”, anything.
which fields you offeryour themeOffering fewer is fine; a minimal footer needn’t have link columns.
a genuinely new settingyour themeNew concept, new key.
markupyour themeThe whole point of a theme.
# Reword the control, keep the key — content survives a theme switch
- type: repeater
id: items # shared: the base declares this
label: Questions # yours: what the user reads

Renaming items to questions gains nothing (same control, same behaviour) and empties every existing FAQ when a site switches to your theme, because the content stays under the old key. All three shipped themes had made exactly that mistake.

A value for a field your theme doesn’t offer is never deleted. It stays in the page’s settings, hidden while your theme is active, and reappears if the site switches back — so trying a theme is safe, and reversible with nothing lost.

In css_variables, a theme role is declared by its bare name — background, not --theme-background. The --theme- prefix is added when the variable is emitted. Only custom properties that are not theme roles (Bootstrap’s --bs-*) keep their prefix, because those are not ours to rename:

css_variables:
--bs-primary: '#635bff' # not a theme role - passes through verbatim
primary: '#635bff' # a role - emitted as --theme-primary
background: '#ffffff'
surface: '#ffffff'
band: '#f6f9fc'

Both spellings render correctly, but the bare form is canonical and is enforced for shipped themes.

Structural roles have a required direction

Section titled “Structural roles have a required direction”

Three roles describe elevation, and consumers rely on their relationship, not just their values:

RoleMeaning
backgroundThe page ground — what a page sits on
surfaceA raised card or panel. Never darker than background.
bandA tinted strip inside a surface — table headers, totals rows, alternating sections

surface must never be darker than background. A consumer that draws a card reads surface and expects it to lift off the page; ship the pair inverted and every card renders as if pressed into the page instead.

The common mistake is using surface for a tinted section strip. That is band. If your theme tints alternating sections, those use band, and surface remains the colour your cards actually fill with — the two are frequently different, and on a theme with a pure white page they may be the same value with the border carrying the structure.

If you support dark mode, the contract holds in both modes.

You do not have to declare any of these. The platform declares every role below before your stylesheet loads, deriving each one from the handful of primitives you do set — --theme-primary, --theme-surface, --theme-text, --theme-border, --theme-border_radius. A theme that sets only its palette gets a complete, coherent set for free: the radius scale steps off your border_radius (set it to 0 and every step stays square), hover and border-hover mix toward your ink so they self-correct in light and dark, and the button roles default to your accent pair.

Declare a variable only to override the derived default — and prefer overriding a primitive to overriding the twenty roles derived from it.

The one thing worth knowing: a role you leave undeclared still resolves. Earlier this was not true — consumers wrote literal fallbacks like var(--theme-radius, 6px), so a theme that omitted a role silently froze to a hardcoded value that stopped following the palette. Those literals are gone; the defaults below are real and they track your colours.

CategoryVariableDescription
Typography--theme-heading_fontHeading font family
--theme-body_fontBody font family
Colors--theme-primaryPrimary brand color
--theme-primary-foregroundText/icon color that sits on primary
--theme-secondarySecondary/accent color
--theme-accentHighlight/focus color. Defaults to primary
--theme-successSuccess state
--theme-success-foregroundText/icon color on success
--theme-warningWarning state
--theme-warning-foregroundText/icon color on warning
--theme-dangerError/danger state
--theme-danger-foregroundText/icon color on danger

Each status color carries its own foreground because the readable pairing differs by hue and cannot be derived: white reads on green and red, but a mid-amber needs near-black text. Override a status color and override its foreground with it — otherwise you keep the default’s pairing against your new hue, which is how a badge ends up unreadable. | Text | --theme-text | Primary text color | | | --theme-text_muted | Secondary/muted text | | | --theme-heading_color | Heading text color | | | --theme-subheading_color | Subheading text color | | Layout | --theme-content_width | Box width. none (default) = full-bleed | | | --theme-gutter | Inline padding inside the box | | | --theme-canvas | The ground outside the box | | Backgrounds | --theme-background | Page ground | | | --theme-surface | Raised card/panel — never darker than background | | | --theme-band | Tinted strip inside a surface (table headers, totals) | | | --theme-hover | Interactive hover fill. Derived: mixed from text into surface, so one declaration darkens a light palette and lightens a dark one | | | --theme-border | Default border color | | | --theme-border-hover | Border hover state. Derived from border toward text | | Typography | --theme-heading_font | Heading typeface — also the header brand’s | | | --theme-body_font | Body typeface | | | --theme-font_size | The type dial. Every step below derives from it | | | --theme-text-xs-3xl | 0.75 / 0.875 / 1 / 1.125 / 1.25 / 1.5 / 1.875 × the dial | | Navbar | --theme-navbar_bg | Navbar background | | | --theme-navbar_text | Navbar text color | | | --theme-navbar_padding | Bar padding. Never change it on scroll — the controls shift | | | --theme-navbar_border | Bottom rule | | | --theme-navbar_backdrop | backdrop-filter (blur/saturate). Default none | | | --theme-navbar_scrolled_bg | Ground once the bar has landed | | | --theme-navbar_scrolled_shadow | Depth once the bar has landed | | | --theme-navbar_brand_font | Brand typeface. Defaults to heading_font | | | --theme-navbar_brand_size | Brand size. Defaults to text-xl | | | --theme-navbar_brand_weight / _tracking / _hover_color | Brand type and hover | | | --theme-navbar_logo_height | Max height of the brand mark | | | --theme-navbar_link_size / _weight / _color | Nav link type | | | --theme-navbar_link_hover_color / _hover_bg | Nav link hover | | | --theme-navbar_link_padding / _gap / _radius | Nav link box | | | --theme-navbar_link_border / _hover_border_color | Nav link border treatment | | | --theme-navbar_overlay_text / _hover_color | Chrome for an overlay header on a dark hero | | | --theme-navbar_overlay_scrim | Gradient behind an overlay header on a dark hero | | Footer | --theme-footer_bg | Footer background | | | --theme-footer_text | Footer text color | | | --theme-footer_text_muted | Footer muted text | | Gradients | --theme-gradient | Primary gradient | | | --theme-gradient-subtle | Subtle/light gradient | | | --theme-primary-hover | Primary color hover state | | | --theme-secondary-hover | Secondary color hover state | | Shadows | --theme-shadow-xs | Extra small shadow | | | --theme-shadow-sm | Small shadow | | | --theme-shadow | Default shadow | | | --theme-shadow-md | Medium shadow | | | --theme-shadow-lg | Large shadow | | | --theme-shadow-colored | Brand-colored shadow | | Border Radius | --theme-border_radius | The dial. Every step below is derived from it — set this one and the scale follows | | | --theme-radius-sm | border_radius × 0.5 | | | --theme-radius | border_radius — the workhorse (controls, chips) | | | --theme-radius-lg | border_radius × 1.5 | | | --theme-radius-xl | border_radius × 2 | | | --theme-radius-2xl | border_radius × 3 | | | --theme-radius-3xl | border_radius × 4 | | | --theme-radius-full | Pill shape | | Assets | --theme-icon-filter | CSS filter applied to icons. Default none; use to invert monochrome assets | | | --theme-login-logo-filter | Same, for the logo on the sign-in screen | | Transitions | --theme-transition-fast | Fast (0.1s) | | | --theme-transition | Default (0.15s) | | | --theme-transition-slow | Slow (0.25s) | | | --theme-transition-smooth | Smooth easing (0.3s) | | Buttons | --theme-btn-primary-bg | Primary button background | | | --theme-btn-primary-text | Primary button text | | | --theme-btn-primary-border | Primary button border | | | --theme-btn-primary-hover-bg | Primary button hover | | | --theme-btn-secondary-bg | Secondary button background | | | --theme-btn-secondary-text | Secondary button text | | | --theme-btn-secondary-border | Secondary button border | | | --theme-btn-secondary-hover-bg | Secondary button hover | | Utility | --theme-primary-foreground | Text on primary backgrounds | | | --theme-icon-filter | CSS filter for icons (e.g. invert(1) for dark mode) | | | --theme-login-logo-filter | CSS filter for login page logo |

A theme becomes boxed by declaring one variable:

css_variables:
content_width: 1200px # default is `none` — full-bleed
canvas: '#eef1f4' # the ground the box sits on
gutter: 2rem # inline padding inside the box

The shell applies it to the page’s header, main and footer, not to sections — so no section template changes, and Bootstrap’s .container resolves to min(its breakpoint, your box), which is the wanted behaviour. Where the box’s padding and a section’s own .container padding double up, tune gutter.

Opting a surface out. Every page stamps what it is on <body>:

<body class="ff-surface-web ff-theme-crisp"> <!-- a website page -->
<body class="ff-surface-portal ff-theme-crisp"> <!-- the customer portal -->
<body class="ff-surface-preview"> <!-- a section thumbnail -->

so a boxed theme can exclude a surface that should stay full-bleed:

body.ff-surface-portal { --theme-content_width: none; }

ff-theme-<identifier> is stamped alongside it, so a theme can scope rules to itself when several are installed.

Use the shell’s ff-sticky class. Do not write position: sticky yourself:

<aside class="order-summary ff-sticky"></aside>
/* a wider gap for this one element */
.order-summary { --ff-sticky-gap: 2rem; } /* default: 1rem */

Why not Bootstrap’s .sticky-top? That pins to the viewport (top: 0), which is correct for a header and wrong for anything below one — the element spends every scroll underneath the header. ff-sticky offsets by the header’s real height instead.

The height cannot be a constant: it changes with the brand logo, the header style the site has chosen, and whether the navigation has wrapped. The shell measures whatever is actually pinned and publishes it as --theme-header_height, which you may read but should not set. A header that scrolls away publishes 0, so ff-sticky degrades to a plain gap.

.sticky-top remains correct for a header itself — that is the one element that should pin to the viewport.

One header style overlays the page instead of sitting above it, so the first section on the page is underneath the navigation bar. You do not have to do anything about this. The first section reserves the header’s height automatically, and a section registered under the Hero category is excused — a hero paints a full-bleed background, and the header floating over it is the point of that style. Its content is given clearance automatically too.

That default is deliberately the safe one: a section this framework has never seen reserves, so nothing you write can end up hidden behind the navigation bar.

The only reason to read further is if you want a hero’s own spacing on those pages rather than the default clearance. Add the header height to whatever top padding you designed:

/* `body:has(> header .fixed-top)` = the page carries an overlay header.
The section wrapper is marked when the section is a Hero. */
body:has(> header .fixed-top) main > .ff-overlay-host:first-child > .my-hero {
padding-top: calc(var(--theme-header_height, 4.5rem) + 6rem); /* 6rem = your design */
}

Never hardcode the header’s height — it varies with the brand logo, the chosen header style and whether the navigation has wrapped. Two shipped themes once pinned it at 73px and pulled the hero up by that amount unconditionally, which clipped the hero’s first line under every other header style.

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 maps its styling onto these roles 1:1 — it derives no colours of its own and ships no portal-specific CSS. Define the roles correctly and the portal matches your theme with no extra configuration.

This is also why the structural contract above matters: the portal is the surface that reads surface and band most heavily, so an inverted or missing pair shows up there first — cards that look recessed, or table headers with no tint.

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"

A preset replaces the site’s saved settings rather than adding to them, so the empty settings: {} above is a real choice — “no overrides” — and selecting it returns every role to the value the theme declares. That is what makes it your theme’s own palette.

Which value a role lands on, lowest precedence to highest:

  1. the default: declared for that setting in settings_schema
  2. anything of the same name in css_variables
  3. the preset or individual settings the site has chosen

So a role you declare in both css_variables and settings_schema can never be reset by a preset — the stylesheet value wins over the schema default. Declare a customisable role in one place only: give it a default: in the schema, and keep css_variables for non-role custom properties (Bootstrap’s --bs-*).

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: LayoutList
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 (see Configuration defaults vs starter copy below)
context_providerFor a dynamic section: module.path:function that builds its runtime render context (see below). Static sections omit it.

Configuration defaults vs starter copy — mark your prose content: true

Section titled “Configuration defaults vs starter copy — mark your prose content: true”

A default is resolved schema defaults < default_settings < what the page stored. That is right for configuration: the page never chose a layout, so your section’s choice stands, and it stays right forever.

It is wrong for words. A page that leaves heading blank would render whichever sentence your section shipped with — and that sentence was written for the page you authored the section from. So copy walks between pages, with nothing raised: the page renders, the grammar is fine, and the only signal is a reader recognising text from somewhere else.

Mark every setting that holds prose with content: true:

schema:
- type: text
id: heading
label: Heading
content: true # words a reader sees
- type: textarea
id: body
label: Body
content: true
- type: select
id: layout
label: Layout # configuration — no marker
options: [{value: cards, label: Cards}, {value: rows, label: Rows}]
default: cards
- type: repeater
id: items
label: Items
fields:
- {type: text, id: title, label: Title, content: true}
- {type: color, id: colour, label: Colour}

What the marker changes:

  • At insert — when the section is added to a page, its content defaults are copied onto that page as starter copy. The editor sees your example text, edits it, and the page owns it.
  • At render — content defaults are not a fallback. A page that stores nothing for a marked key renders nothing for it.

Configuration defaults are unaffected: they still fall back at render, which is what makes a shipped toggle or density work on a page nobody has configured.

A text-typed setting that names a thing rather than saying something — a URL, a component name, a CSS width, an anchor id — is configuration. Leave it unmarked.

Reading settings: settings.get('name') when the key could be a dict method

Section titled “Reading settings: settings.get('name') when the key could be a dict method”

settings is a dict, and Jinja resolves an attribute before a key. So for most keys settings.heading is fine — but when a schema key happens to share a name with a method on dict, the attribute wins and you get the method instead of your value:

{# WRONG — `items` is a dict method, so this iterates a bound method #}
{% for item in settings.items %}
{# RIGHT #}
{% for item in settings.get('items') or [] %}

The failure is a 500 on the rendered page — 'builtin_function_or_method' object is not iterable — and it does not show up until that section is actually placed on a page, because nothing about the schema or the template is invalid on its own.

items is the one that bites in practice (the timeline section and several themes use it as the name of a repeater), but the same applies to keys, values, copy, update, pop and get. Two safe habits:

  • Reach for settings.get('x') whenever x is a list-ish repeater key — it is never wrong, and it also yields None rather than an Undefined for a missing key, which matters when you need to tell “absent” from “set to zero”:

    {# A saved 0 must mean 0, and an absent key must mean the default #}
    {%- set scrim = settings.get('overlay_opacity') %}
    {%- if scrim is none %}{% set scrim = 0.5 %}{% endif %}
  • Prefer a descriptive key (capabilities, tiers, steps) over a generic items in schemas you are authoring fresh. Keep items only where you are matching an existing section’s vocabulary so content survives a theme switch.

Most sections are static: their content is a pure function of their settings — render = template(settings). Nothing else is needed. The page builder previews them by re-rendering the one section and swapping it into the preview in place (no reload), so edits feel instant.

A dynamic section’s content is runtime data that its settings can’t express — a product grid’s catalog, a cart’s lines, a “recent posts” feed. That data is normally supplied by the page’s route, which the standalone section-preview can’t run. Without help, such a section would render blank in the builder.

Declare a context_provider so the framework can build that data for the preview:

- data_type: WebsiteSectionTemplate
identifier: my_recent_orders
name: Recent Orders
template: recent_orders.jinja
context_provider: fullfinity.modules.myapp.web:build_recent_orders_context
schema:
- { type: number, id: limit, label: How many, default: 5 }

The provider is an async function called as provider(website) that returns the extra render context the template reads (alongside settings):

async def build_recent_orders_context(website):
Order = get_model("SaleOrder")
orders = await Order.filter(website=website.id).order_by("created_date DESC").limit(10).all()
return {"orders": orders}

With this, a dynamic section previews through the same in-place path as a static one — no full-page reload, no flicker, scroll preserved. The live route should build the same context (share a helper) so preview and production match. The built-in ecommerce shop grid, cart, and product detail are dynamic sections and use this mechanism.

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
repeaterEditable list of items (add/remove/drag-reorder)id, label, itemLabel, fields (sub-schema), max
crm_fieldMap a value to a CRM lead field (contact forms)id, label — options supplied at runtime
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 %}

A repeater turns an array setting into an add/remove/drag-reorder editor in the section panel. Declare the per-item shape with a nested fields sub-schema (which accepts the same field types):

type: repeater
id: features
label: Features
itemLabel: Feature
fields:
- type: text
id: title
label: Title
- type: textarea
id: description
label: Description
- type: icon
id: icon
label: Icon

Seed the initial rows in default_settings (as the array example above) and iterate settings.features in the template. Each row also carries a generated _id; when a row has no meaningful id of its own, fall back to it as the stable key ({% set key = item.id or item._id %}).

The built-in Contact Form section stores its inputs as an editable fields repeater, so a site editor can add, rename, reorder, and remove form fields without touching code. Submissions are always emailed to the configured recipient. A show_info toggle chooses the layout: on renders the contact-info sidebar beside the form; off renders just the form, centered full-width.

When a CRM bridge (the website_crm module) is installed, each form field also gains a Map to CRM Field control (a crm_field type). Its options are supplied at runtime by the backend hook Website.lead_mappable_fields() — base website returns an empty list, so the control is hidden entirely unless something can be mapped to. Only type-compatible targets are offered for a given field, so an incompatible mapping can’t be authored.

To provide CRM capture from your own module, override two hooks via __inherit__ on Website:

  • lead_mappable_fields() → the list of mappable targets, each { "value": <lead field>, "label": <text>, "field_types": [<compatible form types>] }.
  • capture_contact_lead(fields, settings) → create the lead from the submitted {field_id: value} dict, reading each field’s declared crm_map. Fold any unmapped answer into a notes field so nothing the visitor typed is lost. This is called after the notification email, so capture is additive.

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' -%}