Components
Components are reusable, interactive UI elements built with Preact. They’re registered with the framework and can be mounted anywhere in your templates.
Creating a Component
Section titled “Creating a Component”Basic Structure
Section titled “Basic Structure”Create static/src/js/components.js in your module:
const { registry, html, useState } = window.fullfinity;
// Register a componentregistry.component('MyButton', function({ label, onClick }) { return html` <button class="my-button" onClick=${onClick}> ${label} </button> `;});Using in Templates
Section titled “Using in Templates”Mount the component in a Jinja2 template:
<div data-component="MyButton" data-props='{"label": "Click Me"}'></div>The framework automatically:
- Finds elements with
data-component - Parses
data-propsas JSON - Renders the component into the element
Component Examples
Section titled “Component Examples”Counter with State
Section titled “Counter with State”registry.component('Counter', function({ initial = 0 }) { const { useState, html } = window.fullfinity; const [count, setCount] = useState(initial);
return html` <div class="counter"> <button onClick=${() => setCount(c => c - 1)}>-</button> <span>${count}</span> <button onClick=${() => setCount(c => c + 1)}>+</button> </div> `;});Form with Validation
Section titled “Form with Validation”registry.component('ContactForm', function({ submitUrl }) { const { useState, html } = window.fullfinity; const [formData, setFormData] = useState({ name: '', email: '', message: '' }); const [errors, setErrors] = useState({}); const [submitting, setSubmitting] = useState(false); const [success, setSuccess] = useState(false);
function updateField(field, value) { setFormData(prev => ({ ...prev, [field]: value })); // Clear error when field changes if (errors[field]) { setErrors(prev => ({ ...prev, [field]: null })); } }
function validate() { const newErrors = {}; if (!formData.name.trim()) newErrors.name = 'Name is required'; if (!formData.email.includes('@')) newErrors.email = 'Valid email required'; if (!formData.message.trim()) newErrors.message = 'Message is required'; setErrors(newErrors); return Object.keys(newErrors).length === 0; }
async function handleSubmit(e) { e.preventDefault(); if (!validate()) return;
setSubmitting(true); try { const response = await fetch(submitUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formData) }); if (response.ok) { setSuccess(true); setFormData({ name: '', email: '', message: '' }); } } finally { setSubmitting(false); } }
if (success) { return html`<div class="success">Thank you! We'll be in touch.</div>`; }
return html` <form onSubmit=${handleSubmit}> <div class="form-group"> <label>Name</label> <input type="text" value=${formData.name} onInput=${e => updateField('name', e.target.value)} class=${errors.name ? 'error' : ''} /> ${errors.name && html`<span class="error-text">${errors.name}</span>`} </div>
<div class="form-group"> <label>Email</label> <input type="email" value=${formData.email} onInput=${e => updateField('email', e.target.value)} class=${errors.email ? 'error' : ''} /> ${errors.email && html`<span class="error-text">${errors.email}</span>`} </div>
<div class="form-group"> <label>Message</label> <textarea value=${formData.message} onInput=${e => updateField('message', e.target.value)} class=${errors.message ? 'error' : ''} ></textarea> ${errors.message && html`<span class="error-text">${errors.message}</span>`} </div>
<button type="submit" disabled=${submitting}> ${submitting ? 'Sending...' : 'Send Message'} </button> </form> `;});Product Card with API Call
Section titled “Product Card with API Call”registry.component('ProductCard', function({ productId }) { const { useState, useEffect, html, bus } = window.fullfinity; const [product, setProduct] = useState(null); const [loading, setLoading] = useState(true); const [quantity, setQuantity] = useState(1); const [adding, setAdding] = useState(false);
// Fetch product data on mount useEffect(() => { async function fetchProduct() { const response = await fetch(`/api/products/${productId}`); const data = await response.json(); setProduct(data); setLoading(false); } fetchProduct(); }, [productId]);
async function addToCart() { setAdding(true); try { await fetch('/api/cart/add', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ product_id: productId, quantity }) }); // Notify other components bus.emit('cart:updated'); } finally { setAdding(false); } }
if (loading) { return html`<div class="loading">Loading...</div>`; }
return html` <div class="product-card"> <img src=${product.image} alt=${product.name} /> <h3>${product.name}</h3> <p class="price">$${product.price.toFixed(2)}</p>
<div class="quantity"> <button onClick=${() => setQuantity(q => Math.max(1, q - 1))}>-</button> <span>${quantity}</span> <button onClick=${() => setQuantity(q => q + 1)}>+</button> </div>
<button class="add-to-cart" onClick=${addToCart} disabled=${adding} > ${adding ? 'Adding...' : 'Add to Cart'} </button> </div> `;});Bot Protection with ALTCHA
Section titled “Bot Protection with ALTCHA”A built-in Altcha component adds a privacy-friendly proof-of-work challenge to any public form (contact, signup, subscribe) — no third-party service, no cookies, no tracking. Drop it inside your <form>:
<form id="my-form"> <!-- your fields … --> <div data-component="Altcha" data-props='{"name": "altcha"}'></div> <button type="submit">Send</button></form>On mount it fetches a challenge from GET /altcha/challenge, solves it in the background, and injects a hidden <input name="altcha"> holding the solved token (plus a small “Verifying… / Verified” status line). Serialize the form as usual — the token rides along under the name you gave it.
Then verify the token server-side before trusting the submission, from your own Controller:
from fullfinity.engine.altcha import verify_solution
@route("/my/form/submit", methods=["POST"], auth="public")async def submit(self, request): body = await request.json() if not verify_solution(body.get("altcha") or ""): return {"success": False, "message": "Human verification failed."} # … process the submission …verify_solution returns True only for a fresh, correctly-solved, server-signed token, and each token is single-use (replay-protected), so a captured payload can’t be resubmitted. If you post JSON (rather than a plain form submit), read the token from the hidden input and send it as a top-level altcha value.
The html Tagged Template
Section titled “The html Tagged Template”The html function uses htm to parse JSX-like syntax in template literals:
const { html } = window.fullfinity;
// Basic elementshtml`<div class="container">Hello</div>`
// Expressionshtml`<span>${user.name}</span>`
// Event handlershtml`<button onClick=${handleClick}>Click</button>`
// Conditional renderinghtml`${isVisible && html`<div>Visible!</div>`}`
// Listshtml` <ul> ${items.map(item => html`<li key=${item.id}>${item.name}</li>`)} </ul>`
// Components inside componentshtml` <div> <${ChildComponent} prop="value" /> </div>`
// Fragmentshtml` <> <div>First</div> <div>Second</div> </>`Differences from JSX
Section titled “Differences from JSX”| JSX | htm |
|---|---|
className | class |
htmlFor | for |
| Self-closing required | Optional |
| Build step required | No build step |
Passing Props from Templates
Section titled “Passing Props from Templates”Props are passed via data-props as JSON:
<!-- Simple props --><div data-component="Greeting" data-props='{"name": "World"}'></div>
<!-- With Jinja2 variables --><div data-component="ProductCard" data-props='{"productId": {{ product.id }}, "showPrice": true}'></div>
<!-- Complex objects (use tojson filter) --><div data-component="DataTable" data-props='{{ table_config | tojson }}'></div>Accessing Props in Components
Section titled “Accessing Props in Components”registry.component('Greeting', function(props) { // Destructure props const { name, greeting = 'Hello' } = props;
return html`<h1>${greeting}, ${name}!</h1>`;});
// Or use default parametersregistry.component('Greeting', function({ name, greeting = 'Hello' }) { return html`<h1>${greeting}, ${name}!</h1>`;});Component Lifecycle
Section titled “Component Lifecycle”Use useEffect for lifecycle management:
registry.component('LiveData', function({ endpoint }) { const { useState, useEffect, html } = window.fullfinity; const [data, setData] = useState(null);
useEffect(() => { // On mount: start polling const interval = setInterval(async () => { const response = await fetch(endpoint); setData(await response.json()); }, 5000);
// Cleanup: stop polling on unmount return () => clearInterval(interval); }, [endpoint]); // Re-run if endpoint changes
return html`<pre>${JSON.stringify(data, null, 2)}</pre>`;});Nested Components
Section titled “Nested Components”Components can render other components:
registry.component('Card', function({ title, children }) { const { html } = window.fullfinity; return html` <div class="card"> <h3>${title}</h3> <div class="card-body">${children}</div> </div> `;});
registry.component('ProductList', function({ products }) { const { html } = window.fullfinity; const Card = registry.component('Card');
return html` <div class="product-list"> ${products.map(product => html` <${Card} title=${product.name}> <p>${product.description}</p> <span class="price">$${product.price}</span> <//> `)} </div> `;});Toasts
Section titled “Toasts”Every toast on a server-rendered page goes through window.fullfinity.toast. Do not
build your own — the framework owns the whole visual vocabulary so that a success toast
raised by your module and one raised by any other are the same object, and so that the
card follows the active website theme (a hand-rolled one is a white slab on a dark theme).
Pick an intent; the colour, the icon and how long it stays are decided for you:
| Intent | Use it for | Stays |
|---|---|---|
success | The thing the user asked for happened | 4s |
error | It failed and they need to know why | 8s |
warning | It happened, but not cleanly — or it needs care | 6s |
info | Something worth knowing, no action implied | 5s |
neutral | An acknowledgement with nothing to report | 4s |
const { toast } = window.fullfinity;
toast.success('Added to your cart');toast.error('That voucher has expired');toast.warning('Only 2 left in stock');toast.info('Your order is being prepared', 'Order #1042'); // second arg is the titletoast.neutral('Notifications turned off');toast.show({ type, title, message, duration }) is the low-level form, for when the
intent is decided at runtime. duration: 0 keeps the toast up until it is dismissed;
otherwise leave it alone — the intent already sets it.
toast.show({ type: order.paid ? 'success' : 'warning', message: order.status_text });Each call returns { close } so a long-lived toast can be dismissed programmatically.
Toasts after a redirect
Section titled “Toasts after a redirect”A page that redirects after a POST can carry its message in the query string; the framework raises it on arrival and strips the parameters from the URL:
/shop/checkout?notify_title=Order+placed¬ify_message=We+emailed+your+receipt¬ify_color=greennotify_color names a colour rather than an intent, matching the vocabulary backend
actions already speak — green (success), orange (warning), red (error), blue
(info), gray (neutral). See Actions.
PinInput
Section titled “PinInput”Short fixed-length codes — a two-factor challenge, an emailed one-time code, an invite
PIN — get window.fullfinity.PinInput: one box per character instead of one open field.
The length is then part of the instruction, the user can see how far along they are, and
the behaviour people expect comes with it — auto-advance, backspace steps back, arrows
navigate, and pasting (or a browser/iOS one-time-code autofill, which arrives as one long
string in whichever box has focus) spreads the whole code across the boxes.
const { PinInput, html, useState } = window.fullfinity;
function Challenge({ onVerified }) { const [code, setCode] = useState(''); return html` <${PinInput} label="Verification code" length=${6} value=${code} onInput=${setCode} onComplete=${(entered) => onVerified(entered)} /> `;}It is controlled: value is the whole string and onInput(next) fires on every change,
so the parent owns the state (clear it to reset the boxes after a failed attempt).
| Prop | Default | |
|---|---|---|
length | 6 | Number of boxes |
value / onInput | — | The whole code as one string |
onComplete | — | Called once the last box is filled — submit from here |
type | 'number' | 'number' (digits only, numeric keypad on mobile) or 'alphanumeric' |
label | — | Rendered above the boxes; also the accessible name |
autoFocus | true | Focus the first box on mount |
disabled | false | While a submission is in flight |
id | — | Base id; boxes get <id>-0…<id>-N |
The boxes stay left-to-right under RTL — a code is read in the order it was issued. It
styles itself from the active website theme’s --theme-* variables, like every other
framework surface.
A code the user might not type in full-length digits needs a way out. Two-factor
backup codes are xxxx-xxxx, not six digits, so the login challenge offers a plain
field beside the boxes (“Use a backup code instead”). Give the pin input the format it
is for, and a fallback for anything else the endpoint accepts.
The equivalent inside the backend app is the PinInput widget on a form field —
see Widgets.
Bottom Chrome
Section titled “Bottom Chrome”More than one thing wants the bottom edge of a page: a store’s sticky buy bar, a floating
chat launcher, a bar your own module pins there. Each is position: fixed and knows nothing
about the others, so on a phone — where a bar spans nearly the full width — they claim the
same pixels and whichever paints last hides the rest.
bottomChrome is the shared answer: bars stack instead of piling up. A bar claims the
space it occupies with an order — lower sits closer to the screen edge — and the framework
gives it the offset to sit at, while publishing the total height of the stack for anything
floating above.
import { bottomChrome, useEffect } from '/core/static/src/js/framework.js';
// In a component: claim while mounted, release on unmount.useEffect(() => bottomChrome.claim(barRef.current, { order: 10 }), []);
// Outside a component: keep the release function and call it when the bar hides.const release = bottomChrome.claim(bar, { order: 0 });release();There are two properties, for two different jobs:
/* A bar IN the stack — position yourself where the framework puts you. */.my-bar { position: fixed; bottom: var(--ff-chrome-offset, 0px); /* set on your element */}
/* Anything floating ABOVE the stack — clear all of it. */.my-launcher { position: fixed; bottom: calc(1rem + var(--ff-bottom-chrome, 0px)); /* set on :root */}Heights are summed, not maxed — stacked bars don’t overlap, so the space consumed is
their total. Both properties are removed when nothing is claimed, so the 0px fallback is
the normal case: write it on every read and your element also behaves correctly on a page with
no bottom chrome at all.
Choosing an order. A customer-facing action belongs at the edge, in thumb reach, and
anything administrative stacks above it: an ecommerce buy bar claims 0, the website’s admin
toolbar claims 10. Pick a number relative to those, not an arbitrary one.
Claims re-measure on resize and when a claimed element changes size, and a claimant that goes
display: none keeps its claim but takes no space — so a bar that shows and hides with scroll
does not have to re-register on every toggle. Release when the bar is destroyed, and the stack
closes up behind it.
Error Handling
Section titled “Error Handling”Wrap components in error boundaries:
registry.component('SafeComponent', function({ children }) { const { useState, html } = window.fullfinity; const [error, setError] = useState(null);
if (error) { return html`<div class="error">Something went wrong: ${error.message}</div>`; }
try { return children; } catch (e) { setError(e); return null; }});Best Practices
Section titled “Best Practices”1. Keep Components Focused
Section titled “1. Keep Components Focused”// Good: Single responsibilityregistry.component('AddToCartButton', function({ productId }) { ... });registry.component('QuantitySelector', function({ value, onChange }) { ... });
// Bad: Too many responsibilitiesregistry.component('ProductEverything', function({ productId }) { // Fetches, displays, adds to cart, reviews, etc.});2. Extract Reusable Logic
Section titled “2. Extract Reusable Logic”// Reusable fetch hookfunction useFetch(url) { const { useState, useEffect } = window.fullfinity; const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null);
useEffect(() => { fetch(url) .then(r => r.json()) .then(setData) .catch(setError) .finally(() => setLoading(false)); }, [url]);
return { data, loading, error };}
// Use in componentsregistry.component('UserProfile', function({ userId }) { const { data: user, loading, error } = useFetch(`/api/users/${userId}`); // ...});3. Use Semantic Class Names
Section titled “3. Use Semantic Class Names”// Good: Semantic, CSS-friendlyreturn html`<div class="product-card product-card--featured">...</div>`;
// Bad: Inline styles, non-semanticreturn html`<div style="padding: 10px; border: 1px solid #ccc">...</div>`;4. Handle Loading States
Section titled “4. Handle Loading States”registry.component('DataWidget', function({ endpoint }) { const { useState, useEffect, html } = window.fullfinity; const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null);
useEffect(() => { fetch(endpoint) .then(r => r.json()) .then(setData) .catch(setError) .finally(() => setLoading(false)); }, [endpoint]);
if (loading) return html`<div class="skeleton">Loading...</div>`; if (error) return html`<div class="error">Failed to load</div>`; return html`<div class="data">${JSON.stringify(data)}</div>`;});File Structure
Section titled “File Structure”Recommended module structure:
my_module/├── static/│ └── src/│ └── js/│ └── components.js # All components for this module├── models/├── views/└── manifest.yamlFor larger modules, you can split into multiple files and import:
import './product-card.js';import './cart-widget.js';import './checkout-form.js';However, this requires your module JS files to be ES modules (type="module" in script tag, which is already set by the framework).