Author forms in JSX.
One inert component per question. The elements are compiled — never rendered — into the exact form defineForm() emits, then rendered through the same framed FilloForm: same sync, same draft-by-default, same responses.
Rule 1, before you paste anything: define forms in a client module — "use client" at the top — and pass the compiled value across server/client boundaries, never the JSX.
Installing for the first time? Start at /docs/embed. Something broken? See /docs/troubleshooting.
On this page
The whole form
"use client";
import { Fillo, createClient } from "@usefillo/react";
import { when } from "@usefillo/react";
import "@usefillo/react/styles.css";
const client = createClient({ key: process.env.NEXT_PUBLIC_FILLO_KEY! });
export function ContactForm() {
return (
<Fillo.Form id="contact" title="Talk to us" client={client}>
<Fillo.Text id="name" label="Your name" required />
<Fillo.Email id="email" label="Work email" required />
<Fillo.Select id="topic" label="What is this about?" required>
<Fillo.Option id="support" label="Support" />
<Fillo.Option id="sales" label="Sales" />
</Fillo.Select>
<Fillo.Text id="company" label="Company" visibleIf={when("topic").eq("sales")} />
<Fillo.LongText id="message" label="How can we help?" />
</Fillo.Form>
);
}when is exported by @usefillo/core (a dependency of @usefillo/react). Everything else — the frame, styling, and renderer props like appearance and onSubmitted — works exactly as documented in /docs/embed.
The five hard rules
- Client module. Define forms in a client module (
"use client"). JSX elements don’t survive a server/client boundary — pass the compiled value across it, never the JSX. - Ids are permanent. Ids key responses, logic, piping, and sync. Never rename casually, never derive from position or a loop counter.
- Conditional questions are
visibleIf={when(…)…}, never{cond && <Fillo.…/>}. Branching the JSX makes a per-visitor schema, which churns drafts in your workspace — a dev warning fires when a form’s structure changes between renders. - Reuse is a plain function returning elements, called inline as
{contactFields()}. Wrapper components are invisible to the compiler (children are never rendered) and throw. defineForm()params stay first-class and canonical. JSX is sugar that compiles to it — use whichever reads best; nothing is deprecated.
Multi-page forms
Wrap blocks in <Fillo.Page> elements. Children are either all pages or all blocks — mixing throws jsx-page_mix. Blocks without pages become one implicit page.
<Fillo.Form id="onboarding" title="Get set up" client={client}>
<Fillo.Page id="you" title="About you">
<Fillo.Text id="name" label="Full name" required />
<Fillo.Email id="email" label="Work email" required />
</Fillo.Page>
<Fillo.Page id="team" title="Your team">
<Fillo.Number id="seats" label="How many seats?" min={1} />
<Fillo.Checkbox id="invite" label="Email my team an invite" />
</Fillo.Page>
</Fillo.Form>Conditional questions: when()
when("fieldId") builds visibleIf conditions as plain data. Pass one condition or an array — arrays AND together (there is deliberately no OR).
| Builder | Shows the field when |
|---|---|
| when("id").eq(value) | The answer equals value (any selected option in a multi-select). |
| when("id").neq(value) | The answer differs from value. Unanswered never satisfies neq. |
| when("id").contains(value) | Text contains value (case-insensitive), or a multi-select includes it. |
| when("id").gt(n) | A numeric answer is greater than n. |
| when("id").lt(n) | A numeric answer is less than n. |
| when("id").answered() | The field has any answer. |
| when("id").notAnswered() | The field has no answer yet. |
import { when } from "@usefillo/react";
// ✅ conditional questions are schema data:
<Fillo.LongText
id="unclear"
label="What was unclear?"
visibleIf={when("vote").eq("down")}
/>
// Multiple conditions AND together:
<Fillo.Text
id="phone"
label="Phone"
visibleIf={[when("topic").eq("sales"), when("seats").gt(50)]}
/>
// ❌ never branch the JSX — a per-visitor schema churns drafts
// in your workspace (a dev warning fires):
{topic === "sales" && <Fillo.Text id="company" label="Company" />}Options: children or prop
Choice fields (Select, MultiSelect, Dropdown, Ranking) take options as <Fillo.Option> children or as an options prop. Both compile identically; both at once throws jsx-option_prop_conflict. The option id is the stored answer value — permanent, like field ids.
// As children — reads best inline:
<Fillo.Select id="plan" label="Plan" required>
<Fillo.Option id="hobby" label="Hobby" />
<Fillo.Option id="team" label="Team" />
</Fillo.Select>
// As a prop — best when the list already exists as data:
const PLANS = [
{ id: "hobby", label: "Hobby" },
{ id: "team", label: "Team" },
];
<Fillo.Select id="plan" label="Plan" required options={PLANS} />Reuse without wrappers
// ✅ reuse is a plain function returning elements, called inline:
function contactFields() {
return (
<>
<Fillo.Text id="name" label="Your name" required />
<Fillo.Email id="email" label="Work email" required />
</>
);
}
<Fillo.Form id="contact" client={client}>
{contactFields()}
<Fillo.LongText id="message" label="Message" />
</Fillo.Form>;
// ❌ a wrapper component — <Fillo.Form> never renders children,
// so <ContactFields /> is invisible to the compiler and throws:
<Fillo.Form id="contact" client={client}>
<ContactFields />
</Fillo.Form>;Compile at module scope + headless
Fillo.defineForm(<Fillo.Form …/>) compiles the element to a CodeForm — the same value defineForm() returns. Use it to share one form across surfaces, or to feed <FilloProvider> for a fully headless layout.
"use client";
import { Fillo, FilloProvider, FormField } from "@usefillo/react";
// Compile once at module scope. The result is a plain CodeForm —
// the same value defineForm() returns.
const feedback = Fillo.defineForm(
<Fillo.Form id="feedback" title="Feedback">
<Fillo.Rating id="score" label="How was it?" max={5} required />
<Fillo.LongText id="notes" label="Tell us more" />
</Fillo.Form>,
);
// Render it framed…
<FilloForm form={feedback} client={client} />;
// …or headless, in your own layout:
<FilloProvider form={feedback} client={client}>
<FormField id="score" />
<FormField id="notes" />
</FilloProvider>;Component reference
Generated from JSX_BLOCK_SPECS (exported by @usefillo/core) — the same manifest the compiler walks, so this table can’t drift. Every component also takes id (required) and visibleIf. Unknown props throw jsx-unknown_prop.
| Component | kind | Props | Children |
|---|---|---|---|
| Fillo.Text | short_text | label, description, required, placeholder, maxLength | — |
| Fillo.LongText | long_text | label, description, required, placeholder, maxLength | — |
| Fillo.Email | label, description, required, placeholder, maxLength | — | |
| Fillo.Url | url | label, description, required, placeholder, maxLength | — |
| Fillo.Phone | phone | label, description, required, placeholder, defaultCountry | — |
| Fillo.Number | number | label, description, required, placeholder, min, max | — |
| Fillo.Select | select | label, description, required, placeholder, options, allowOther, shuffleOptions | <Fillo.Option> elements |
| Fillo.MultiSelect | multi_select | label, description, required, placeholder, options, allowOther, shuffleOptions | <Fillo.Option> elements |
| Fillo.Dropdown | dropdown | label, description, required, placeholder, options, allowOther, shuffleOptions | <Fillo.Option> elements |
| Fillo.Checkbox | checkbox | label, description, required, placeholder, appearance | — |
| Fillo.Rating | rating | label, description, required, placeholder, max | — |
| Fillo.Scale | linear_scale | label, description, required, placeholder, min, max, minLabel, maxLabel | — |
| Fillo.Ranking | ranking | label, description, required, placeholder, options | <Fillo.Option> elements |
| Fillo.Matrix | matrix | label, description, required, placeholder, rows, columns | — |
| Fillo.Signature | signature | label, description, required, placeholder | — |
| Fillo.Date | date | label, description, required, placeholder | — |
| Fillo.FileUpload | file_upload | label, description, required, placeholder, maxFiles, maxFileSizeMb, accept | — |
| Fillo.Hidden | hidden | label, description, required, placeholder, paramName, defaultValue | — |
| Fillo.Custom | custom | label, description, required, placeholder, component, config | — |
| Fillo.Heading | heading | text | the text (plain string) |
| Fillo.Paragraph | paragraph | text | the text (plain string) |
| Fillo.Divider | divider | — | — |
| Fillo.Page | (page) | title | the page's blocks |
| Fillo.Option | (option) | label, icon | — |
Migrating from defineForm
Keep every id exactly as it is and translate blocks 1:1 — the JSX compiles to the same schema, so responses, logic, and piping carry over untouched.
// Before — params:
const contact = defineForm({
id: "contact",
pages: [{ id: "main", blocks: [
{ id: "email", kind: "email", label: "Work email", required: true },
]}],
});
// After — JSX, same ids, compiles to the same form:
const contact = Fillo.defineForm(
<Fillo.Form id="contact">
<Fillo.Email id="email" label="Work email" required />
</Fillo.Form>,
);Content is identical after normalization. If your original params literal used a different key order, the pre-normalization content hash can differ once, staging ONE draft — publish it and you’re aligned; subsequent deploys are hash-stable.
Authoring mistakes throw FilloJsxError with a stable code that links to its fix — the full catalog lives at /docs/troubleshooting#jsx-errors.