Creating Website Themes
This guide explains how to create custom themes for the Fullfinity Website Builder module.
Overview
Section titled “Overview”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:
- Module has
category: module_category_themesinmanifest.yaml - Website module is installed
Theme Architecture
Section titled “Theme Architecture”The website module provides BASE templates that are pure Bootstrap 5 with minimal styling.
Themes provide the visual experience by:
- Section Template Overrides (
templates/section_templates.yaml) - Enhanced markup with theme-specific classes - CSS Styling (
static/css/main.css) - Fonts, colors, animations, visual enhancements - 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.
Theme Module Structure
Section titled “Theme Module Structure”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 imageCreating a Theme
Section titled “Creating a Theme”1. Create the Module Structure
Section titled “1. Create the Module Structure”mkdir -p modules/website_theme_mytheme/{views,templates,static/{css,js,images}}2. manifest.yaml
Section titled “2. manifest.yaml”name: My Custom Themeidentifier: website_theme_mythemeversion: '1.0'category: module_category_themesdescription: A beautiful custom theme for Fullfinity websitesdependencies:- websiteicon: Paletteimage: /static/images/thumbnail.pngstatic_paths:- css- js- imagesImportant: The category must be "module_category_themes" for auto-discovery.
3. Theme Definition (views/theme.yaml)
Section titled “3. Theme Definition (views/theme.yaml)”- 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: [...]Theme Fields
Section titled “Theme Fields”| Field | Description |
|---|---|
identifier | Unique identifier (should match module identifier) |
name | Display name |
description | Theme description |
theme_module | Module identifier (for asset path resolution) |
thumbnail | Path to preview image |
css_files | Array of CSS files (relative to module’s static folder) |
js_files | Array of JS files (relative to module’s static folder) |
css_variables | CSS custom properties injected into pages |
settings_schema | Schema for theme customization UI |
presets | Predefined color combinations |
default_header | Header style a site adopts with this theme (see below) |
default_footer | Footer style a site adopts with this theme |
Header and footer styles are shared, not theme-private
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_tone | Meaning |
|---|---|
Auto (default) | Dark when the section’s settings carry a background_image, else light |
Dark | Always dark behind the header — light chrome |
Light | Always 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.
4. CSS Variables
Section titled “4. CSS Variables”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:
| Part | Owner | Notes |
|---|---|---|
id | shared vocabulary | Keep the base’s. Renaming it strands existing content. |
label | your theme | Reword freely — “Questions”, “Q&A”, anything. |
| which fields you offer | your theme | Offering fewer is fine; a minimal footer needn’t have link columns. |
| a genuinely new setting | your theme | New concept, new key. |
| markup | your theme | The 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 readsRenaming 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.
Declaring variables
Section titled “Declaring variables”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:
| Role | Meaning |
|---|---|
background | The page ground — what a page sits on |
surface | A raised card or panel. Never darker than background. |
band | A 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.
Standard Variable Reference
Section titled “Standard Variable Reference”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.
| Category | Variable | Description |
|---|---|---|
| Typography | --theme-heading_font | Heading font family |
--theme-body_font | Body font family | |
| Colors | --theme-primary | Primary brand color |
--theme-primary-foreground | Text/icon color that sits on primary | |
--theme-secondary | Secondary/accent color | |
--theme-accent | Highlight/focus color. Defaults to primary | |
--theme-success | Success state | |
--theme-success-foreground | Text/icon color on success | |
--theme-warning | Warning state | |
--theme-warning-foreground | Text/icon color on warning | |
--theme-danger | Error/danger state | |
--theme-danger-foreground | Text/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 |
Boxed layouts
Section titled “Boxed layouts”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 boxThe 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.
Sticking content below the header
Section titled “Sticking content below the header”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.
Overlay headers and the first section
Section titled “Overlay headers and the first section”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.
Using Theme Variables in Module CSS
Section titled “Using Theme Variables in Module CSS”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.
Portal Integration
Section titled “Portal Integration”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.
5. Settings Schema
Section titled “5. Settings Schema”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: medium6. Color Presets
Section titled “6. Color Presets”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:
- the
default:declared for that setting insettings_schema - anything of the same name in
css_variables - 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-*).
Creating Section Templates
Section titled “Creating Section Templates”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: /contactSection Template Fields
Section titled “Section Template Fields”| Field | Description |
|---|---|
identifier | Unique identifier (same as base = override) |
name | Display name in section picker |
category | Category: Hero, Features, Content, CTA, Testimonials, Pricing, FAQ, Team, Contact, Footer |
icon | Tabler icon name |
sequence | Display order in section picker |
template | Jinja2 template filename (in same folder) |
schema | Settings schema for section editor |
default_settings | Default values for settings (see Configuration defaults vs starter copy below) |
context_provider | For 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')wheneverxis a list-ish repeater key — it is never wrong, and it also yieldsNonerather than anUndefinedfor 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 genericitemsin schemas you are authoring fresh. Keepitemsonly where you are matching an existing section’s vocabulary so content survives a theme switch.
Static vs. dynamic sections
Section titled “Static vs. dynamic sections”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.
Setting Types
Section titled “Setting Types”| Type | Description | Properties |
|---|---|---|
text | Single line text | id, label, default, placeholder |
textarea | Multi-line text | id, label, default, placeholder |
color | Color picker | id, label, default |
image | Image picker | id, label, default |
url | URL input | id, label, default, placeholder |
email | Email input | id, label, default |
checkbox | Boolean toggle | id, label, default |
number | Number input | id, label, default, min, max, step |
select | Dropdown | id, label, default, options |
header | Section header (display only) | content |
repeater | Editable list of items (add/remove/drag-reorder) | id, label, itemLabel, fields (sub-schema), max |
crm_field | Map a value to a CRM lead field (contact forms) | id, label — options supplied at runtime |
Select Options Format
Section titled “Select Options Format”type: selectid: image_positionlabel: Image Positionoptions:- value: left label: Left- value: right label: Rightdefault: rightJinja2 Template Example
Section titled “Jinja2 Template Example”Templates have access to:
settings- Merged section settings (defaults + user overrides)theme_variables- Theme color/font settingsnow()- 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>Complex Settings (Arrays)
Section titled “Complex Settings (Arrays)”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-checkIterate 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 %}Editable Lists (repeater)
Section titled “Editable Lists (repeater)”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: repeaterid: featureslabel: FeaturesitemLabel: Featurefields:- type: text id: title label: Title- type: textarea id: description label: Description- type: icon id: icon label: IconSeed 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 %}).
Contact Forms & CRM Mapping
Section titled “Contact Forms & CRM Mapping”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 declaredcrm_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
Section titled “Theme JavaScript”Theme JavaScript should use the Preact-based fullfinity framework for consistency and proper lifecycle management.
Basic Component Pattern
Section titled “Basic Component Pattern”(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(); }})();Available Framework Features
Section titled “Available Framework Features”| Feature | Usage |
|---|---|
registry.component(name, fn) | Register a component |
useState, useEffect, useRef | Preact hooks |
html\…“ | Tagged template for JSX-like syntax |
signal(), computed(), effect() | Preact signals |
CSS Best Practices
Section titled “CSS Best Practices”1. Use CSS Variables
Section titled “1. Use CSS Variables”:root { --theme-primary: #4f46e5; --theme-secondary: #7c3aed; --theme-radius: 8px;}
.btn-primary { background: var(--theme-primary); border-radius: var(--theme-radius);}2. Scope Section Styles
Section titled “2. Scope Section Styles”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);}3. Responsive Design
Section titled “3. Responsive Design”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; }}4. Animations
Section titled “4. Animations”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;}Using Icons
Section titled “Using Icons”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-takeoffThen in template:
<i class="bi bi-{{ feature.icon }}"></i>Optional: Post-Install Hook
Section titled “Optional: Post-Install Hook”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()- Test with different content lengths - Ensure sections handle long/short text gracefully
- Mobile-first - Use Bootstrap’s responsive utilities and test on mobile
- Accessibility - Use semantic HTML, proper heading hierarchy, and sufficient color contrast
- Performance - Minimize custom CSS/JS, use efficient selectors
- Fallbacks - Always provide default values in templates:
{{ settings.heading or 'Default Heading' }}
- Theme variables - Access theme colors in templates via
theme_variables:{%- set tv = theme_variables or {} -%}{%- set primary = tv.primary or '#4f46e5' -%}