DocsCustom UI
Custom UI
Three ways to move past the default renderer, in order of how much you take over: swap one field's markup, drop to a headless provider and lay out every field yourself in React, or run the same engine outside React entirely.
Buttonless forms
For tiny feedback widgets — a thumbs up/down article rating, a one-tap CSAT score, or an inline pulse check — set settings.submitMode to "auto". The default renderer hides the first submit button and submits after a discrete answer such as a select, dropdown, checkbox, rating, or linear scale. If that answer opens a text field, upload, or custom component, the renderer brings the submit action back for that follow-up.
Add submissionLimit: "once_per_visitor" when each browser should only answer once. Try the live one on the right — one tap submits.
"use client";
import { FilloForm, createClient, defineForm } from "@usefillo/react";
const client = createClient({ key: "pk_…" });
const articleVote = defineForm({
id: "article-vote",
title: "Was this article helpful?",
pages: [{ id: "p1", blocks: [
{ id: "vote", kind: "select", label: "Was this article helpful?", required: true,
options: [
{ id: "up", label: "Yes", icon: "thumbs_up" },
{ id: "down", label: "No", icon: "thumbs_down" },
] },
{ id: "unclear", kind: "long_text", label: "What was unclear?",
visibleIf: [{ fieldId: "vote", op: "eq", value: "down" }] },
]}],
settings: {
submitMode: "auto", // hides the first submit button
submissionLimit: "once_per_visitor",
submitLabel: "Send note", // shown only when the text field opens
successTitle: "Thanks",
successMessage: "That helps us tune the docs.",
},
});
export function ArticleVote() {
return <FilloForm form={articleVote} client={client} showTitle={false} />;
}Live — try it
Swap any field
<FilloForm
client={client}
formId="cust-feedback"
components={{
rating: ({ field, value, setValue, error }) => (
<MyEmojiRating
label={field.label}
value={value}
onChange={setValue}
error={error}
/>
),
}}
/>Every field kind accepts an override via components — on @usefillo/react and @usefillo/dom alike. For full control in React, drop to useFillo() and own the entire render — the hook exposes data, errors, page state, navigation and submit; outside React, the same escape hatch is headless in any framework below.
Your own layout (React)
<FilloForm> renders a default layout — a stacked form with pages and a submit button. When the form needs to blend into a product workflow, reach for <FilloProvider>: it runs the engine — validation, conditional logic, uploads, submit — but renders no layout at all.
You place fields with <FormField>, render any field from scratch with useField(), and drive everything through useFillo().
import { FilloProvider, FormField, useField, useFillo } from "@usefillo/react";
// <FilloProvider> runs the engine but renders no layout — you build it
// all. Works with a schema, or a defineForm() form (which also syncs).
<FilloProvider form={feedback} client={client}>
<div className="grid grid-cols-2 gap-4">
<FormField id="first" /> {/* default render, placed by you */}
<FormField id="last" />
</div>
<YourMarketingBlock /> {/* anything, anywhere */}
<BareEmail /> {/* a field rendered from scratch */}
<SubmitButton /> {/* your button → useFillo().submit() */}
</FilloProvider>
// A field rendered with zero Fillo chrome:
function BareEmail() {
const { value, error, setValue } = useField("email");
return <input value={value ?? ""} onChange={(e) => setValue(e.target.value)} />;
}See a live, working version in the examples gallery.
Multi-page wizards
<FilloForm> handles pages automatically. In a custom layout, useFillo() gives you the page state and navigation — next() validates the current page before advancing, and conditional logic is already applied to blocks.
function MyWizard() {
const {
blocks, // blocks on the current page, after visibility logic
pageIndex, pageCount, isFirstPage, isLastPage,
next, back, submit, status,
} = useFillo();
return (
<>
{blocks.map((b) => <FormField key={b.id} id={b.id} />)}
<nav>
{!isFirstPage && <button onClick={back}>Back</button>}
<button onClick={() => (isLastPage ? submit() : next())}>
{isLastPage ? "Submit" : `Next (${pageIndex + 1} of ${pageCount})`}
</button>
</nav>
</>
);
}Headless in any framework
Not React? createFormController() (in @usefillo/dom and @usefillo/core) is the same engine <FilloForm> runs — validation, conditional logic, pages, spam checks, and submit — exposed as a subscribable store that renders nothing.
You lay out the fields and add your own markup around them: call setValue, next()/submit(), and re-read getState() inside subscribe(). In Vue or Svelte, mirror getState() into reactive state; the React <FilloProvider> is a thin wrapper over this same engine.
import { createClient, createFormController } from "@usefillo/dom";
const client = createClient({ key: "pk_…" });
// The engine: validation, conditional logic, pages, spam checks, submit.
// It renders nothing — you do. formId targets the published workspace form.
const fillo = createFormController({
formId: "bug-report",
client,
form: {
version: 1, title: "Bug report", settings: {},
pages: [{ id: "p1", blocks: [
{ id: "summary", kind: "long_text", label: "Summary", required: true },
{ id: "email", kind: "email", label: "Email" },
]}],
},
});
// Your layout, your markup, fields wherever you want:
const root = document.querySelector<HTMLElement>("#form")!;
root.innerHTML = `
<h2>Tell us what broke</h2>
<textarea id="summary"></textarea>
<aside class="callout">📎 Screenshots help.</aside> <!-- your markup, mid-form -->
<input id="email" type="email" />
<p id="err" class="err"></p>
<button id="send">Send</button>`;
const summary = root.querySelector<HTMLTextAreaElement>("#summary")!;
const email = root.querySelector<HTMLInputElement>("#email")!;
const send = root.querySelector<HTMLButtonElement>("#send")!;
summary.oninput = () => fillo.setValue("summary", summary.value);
email.oninput = () => fillo.setValue("email", email.value);
send.onclick = () => void fillo.submit();
// Re-read getState() and repaint the reactive bits on every change:
fillo.subscribe(() => {
const s = fillo.getState(); // { data, errors, status, blocks, … }
root.querySelector("#err")!.textContent = s.errors.summary ?? "";
send.disabled = s.status === "submitting";
if (s.status === "submitted") root.innerHTML = "<p>Thanks!</p>";
});Your own field types
To re-render a field kind Fillo already ships — say radios instead of the default — pass a components override (above). To add a field kind Fillo doesn’t ship at all, use a custom block and supply its renderer in customComponents. The value is whatever your component sets — Fillo stores it, shows it in the grid, and only enforces required; your component does any richer validation.
// 1. Define a field kind we don't ship, in your schema:
{ id: "accent", kind: "custom", component: "color",
label: "Brand color", config: { swatches: ["#18181b", "#16a34a"] } }
// 2. Render it with your own component:
<FilloForm
form={form}
client={client}
customComponents={{
color: ({ field, value, setValue }) => (
<SwatchPicker
swatches={field.config.swatches}
value={value}
onChange={setValue}
/>
),
}}
/>