Skip to content

Components

Components are reusable, interactive UI elements built with Preact. They’re registered with the framework and can be mounted anywhere in your templates.

Create static/src/js/components.js in your module:

my_module/static/src/js/components.js
const { registry, html, useState } = window.fullfinity;
// Register a component
registry.component('MyButton', function({ label, onClick }) {
return html`
<button class="my-button" onClick=${onClick}>
${label}
</button>
`;
});

Mount the component in a Jinja2 template:

<div
data-component="MyButton"
data-props='{"label": "Click Me"}'
></div>

The framework automatically:

  1. Finds elements with data-component
  2. Parses data-props as JSON
  3. Renders the component into the element
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>
`;
});
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>
`;
});
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>
`;
});

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 function uses htm to parse JSX-like syntax in template literals:

const { html } = window.fullfinity;
// Basic elements
html`<div class="container">Hello</div>`
// Expressions
html`<span>${user.name}</span>`
// Event handlers
html`<button onClick=${handleClick}>Click</button>`
// Conditional rendering
html`${isVisible && html`<div>Visible!</div>`}`
// Lists
html`
<ul>
${items.map(item => html`<li key=${item.id}>${item.name}</li>`)}
</ul>
`
// Components inside components
html`
<div>
<${ChildComponent} prop="value" />
</div>
`
// Fragments
html`
<>
<div>First</div>
<div>Second</div>
</>
`
JSXhtm
classNameclass
htmlForfor
Self-closing requiredOptional
Build step requiredNo build step

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>
registry.component('Greeting', function(props) {
// Destructure props
const { name, greeting = 'Hello' } = props;
return html`<h1>${greeting}, ${name}!</h1>`;
});
// Or use default parameters
registry.component('Greeting', function({ name, greeting = 'Hello' }) {
return html`<h1>${greeting}, ${name}!</h1>`;
});

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>`;
});

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>
`;
});

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:

IntentUse it forStays
successThe thing the user asked for happened4s
errorIt failed and they need to know why8s
warningIt happened, but not cleanly — or it needs care6s
infoSomething worth knowing, no action implied5s
neutralAn acknowledgement with nothing to report4s
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 title
toast.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.

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&notify_message=We+emailed+your+receipt&notify_color=green

notify_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.

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).

PropDefault
length6Number of boxes
value / onInputThe whole code as one string
onCompleteCalled once the last box is filled — submit from here
type'number''number' (digits only, numeric keypad on mobile) or 'alphanumeric'
labelRendered above the boxes; also the accessible name
autoFocustrueFocus the first box on mount
disabledfalseWhile a submission is in flight
idBase 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.

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.

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;
}
});
// Good: Single responsibility
registry.component('AddToCartButton', function({ productId }) { ... });
registry.component('QuantitySelector', function({ value, onChange }) { ... });
// Bad: Too many responsibilities
registry.component('ProductEverything', function({ productId }) {
// Fetches, displays, adds to cart, reviews, etc.
});
// Reusable fetch hook
function 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 components
registry.component('UserProfile', function({ userId }) {
const { data: user, loading, error } = useFetch(`/api/users/${userId}`);
// ...
});
// Good: Semantic, CSS-friendly
return html`<div class="product-card product-card--featured">...</div>`;
// Bad: Inline styles, non-semantic
return html`<div style="padding: 10px; border: 1px solid #ccc">...</div>`;
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>`;
});

Recommended module structure:

my_module/
├── static/
│ └── src/
│ └── js/
│ └── components.js # All components for this module
├── models/
├── views/
└── manifest.yaml

For larger modules, you can split into multiple files and import:

components.js
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).