Make Fillo forms look like your product.
Three levels, lowest effort first: theme tokens for a five-minute brand match, the appearance prop for Tailwind, and stable fillo-* classes for plain CSS. They stack — most apps use the first two together.
Installing for the first time? Start at /docs/embed. Something broken? See /docs/troubleshooting.
On this page
Pick your rung
1. Theme tokens — { colorScheme, primary, background, text, radius, fontFamily }, via the theme prop or defineForm(). The only rung that also styles the hosted /f page. 2. The appearance prop — per-slot class strings plus data-* state attributes, made for Tailwind. 3. Plain CSS — stable fillo-* classes and CSS variables; every default rule is single-class specificity, so one class of yours always wins.
<FilloForm
formId="onboarding"
theme={{ primary: "#4f46e5", radius: "10px", fontFamily: "inherit" }}
/>
// Code-defined forms carry the theme in defineForm() — it syncs to your
// workspace, so the hosted /f page renders the same tokens:
const feedback = defineForm({
id: "feedback",
theme: { primary: "#4f46e5", radius: "10px" },
// …pages
});The appearance prop
Attach class strings to named slots. They are appended after the built-in fillo-* class and win by cascade order — the default stylesheet lives in a lower cascade layer, so no !important is ever needed. Any slot takes a string or a (state) => string function; fields overrides one field by id. Works the same on <FilloProvider>.
<FilloForm
formId="onboarding"
appearance={{
classNames: {
root: "mx-auto max-w-xl",
label: "text-sm font-medium text-zinc-900",
// data-invalid lives on the field wrapper — mark it `group` so
// inner slots can react to it:
field: "group",
control:
"rounded-lg border-zinc-300 shadow-xs focus:ring-2 focus:ring-indigo-600 " +
"group-data-[invalid]:border-red-400",
option:
"rounded-lg border-zinc-200 hover:bg-zinc-50 " +
"data-[selected]:border-indigo-600 data-[selected]:bg-indigo-50",
error: "text-xs font-medium text-red-600",
button: (s) =>
s.variant === "primary"
? "rounded-full bg-indigo-600 font-semibold hover:bg-indigo-500"
: "text-zinc-500",
},
fields: {
// One question diverges; everything else keeps the classes above.
score: {
options: "grid grid-cols-11 gap-1.5",
option: "aspect-square justify-center rounded-md p-0 text-sm",
},
},
}}
/>Slot reference
Slot names come from FILLO_SLOTS (exported by @usefillo/core) — this table renders from it. Renames are breaking; additions are minors. The “Powered by Fillo” badge is deliberately not a slot.
| root | The form element itself. Carries data-state, data-page, and data-last-page. |
| header | Title and description wrapper (rendered when showTitle is on). |
| title | The form title. |
| description | The form description under the title. |
| pageTitle | The current page's heading in multi-page forms. |
| progress | Progress track in multi-page forms. |
| progressFill | The filled part of the progress track. |
| blocks | Container of the current page's fields. |
| field | One field's wrapper. Carries data-field, data-kind, data-invalid, data-required. |
| label | A field label. |
| fieldDescription | Help text under a field label. |
| control | The interactive element — inputs, textareas, dropdowns, dropzones. |
| options | List wrapper on choice fields (select, multi-select, rating, scale). |
| option | One choice row, star, scale step, or toggle. Carries data-selected / data-checked. |
| optionLabel | The text inside an option row. |
| error | A field's validation message. |
| footer | The Back / Next / Submit row. |
| button | Navigation and submit buttons. classNames functions get state.variant: "primary" | "ghost". |
| success | The thank-you screen after submit. |
State attributes
From FILLO_DATA_ATTRS (exported by @usefillo/core). Tailwind data-[…]: variants must sit on the slot that carries the attribute — put group on the field wrapper to reach inner slots, as in the example above.
| Attribute | Where | Meaning |
|---|---|---|
| data-fillo | every slot element | The slot name, e.g. data-fillo="control". |
| data-kind | field wrapper | The field kind, e.g. data-kind="email". |
| data-field | field wrapper | The field id — target one question in CSS. |
| data-invalid | field wrapper | Present while the field has a validation error. Controls also carry aria-invalid. |
| data-required | field wrapper | Present on required fields. |
| data-selected | option / star / scale step | Present on the chosen option row, active star, or active scale step. |
| data-checked | checkbox / toggle option | Present while checked. |
| data-drag-over | upload dropzone | Present while a file is dragged over it. |
| data-state | root | Engine status: idle | submitting | submitted | error. |
| data-page | root | Current page number, 1-based. |
| data-last-page | root | Present on the final page. |
Which stylesheet
import "@usefillo/react/styles.css"; // Tailwind v4 / modern CSS
import "@usefillo/react/styles.unlayered.css"; // Tailwind v3 or heavy resets
// (same paths on @usefillo/dom)| Your setup | Import | Why |
|---|---|---|
| Tailwind v4, or modern layered CSS | @usefillo/react/styles.css | Defaults live in @layer components, so any unlayered rule or utility of yours wins automatically. |
| Tailwind v3, or reset-heavy global CSS | @usefillo/react/styles.unlayered.css | Same rules without cascade layers. Use it when layered defaults lose to your unlayered reset and the form renders bare. |
| Fully custom | no stylesheet | Unstyled but functional markup — style the fillo-* classes and slots yourself. |
Plain CSS
No Tailwind? Every element keeps its stable fillo-* class, and theme tokens are ordinary CSS variables. The default stylesheet is :where()-flattened to single-class specificity, so one selector of yours always wins.
/* Stable class names — renames are breaking, additions are minors. */
.fillo-option { border-radius: 0; }
/* One question, via its data-field id: */
[data-field="quality"] .fillo-options {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
/* Theme tokens are plain CSS variables on any ancestor: */
.fillo-form { --fillo-primary: #4f46e5; --fillo-radius: 10px; }Localize the strings
The strings prop (on <FilloForm> and <FilloProvider>) overrides every visitor-facing renderer string — Back/Next/Submit, placeholders, the (optional) suffix, error and success panels — as Partial<FilloStrings>; defaults are exported as DEFAULT_STRINGS. Schema-authored text — labels, descriptions, success copy set in settings — always wins over these defaults.
<FilloForm
formId="kontakt"
strings={{
back: "Zurück",
next: "Weiter",
submit: "Absenden",
submitting: "Wird gesendet…",
optional: " (optional)",
successTitle: "Danke!",
successMessage: "Ihre Antwort wurde gespeichert.",
}}
/>The hosted page
The hosted /f page and the dashboard preview render the default renderer with theme tokens only. appearance.classNames never syncs — by design, so your app’s class names are not smuggled into the shared schema. Put brand parity in theme tokens; keep classNames for embed-only polish.
Storage the SDK uses
For privacy reviews: the SDK writes two localStorage keys, no cookies, and nothing that identifies the visitor.
| Key | Purpose | Lifetime | Contents |
|---|---|---|---|
| fillo:submission:<formId> | Remembers this browser already submitted — powers once_per_visitor and duplicate-submit protection. | Persistent until the visitor clears site data. | Random key, timestamp, response id. No PII. |
| fillo:sync:<baseUrl>|<pk>|<handle> | Code-form sync cache so returning visitors skip the /forms/sync request. | 1 hour for published forms, 60 seconds for drafts. | Form id, slug, status, content hash, timestamp. No PII. |