Skip to main content

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.

rung 1 — theme tokens
<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>.

rung 2 — appearance + tailwind
<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.

rootThe form element itself. Carries data-state, data-page, and data-last-page.
headerTitle and description wrapper (rendered when showTitle is on).
titleThe form title.
descriptionThe form description under the title.
pageTitleThe current page's heading in multi-page forms.
progressProgress track in multi-page forms.
progressFillThe filled part of the progress track.
blocksContainer of the current page's fields.
fieldOne field's wrapper. Carries data-field, data-kind, data-invalid, data-required.
labelA field label.
fieldDescriptionHelp text under a field label.
controlThe interactive element — inputs, textareas, dropdowns, dropzones.
optionsList wrapper on choice fields (select, multi-select, rating, scale).
optionOne choice row, star, scale step, or toggle. Carries data-selected / data-checked.
optionLabelThe text inside an option row.
errorA field's validation message.
footerThe Back / Next / Submit row.
buttonNavigation and submit buttons. classNames functions get state.variant: "primary" | "ghost".
successThe 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.

AttributeWhereMeaning
data-filloevery slot elementThe slot name, e.g. data-fillo="control".
data-kindfield wrapperThe field kind, e.g. data-kind="email".
data-fieldfield wrapperThe field id — target one question in CSS.
data-invalidfield wrapperPresent while the field has a validation error. Controls also carry aria-invalid.
data-requiredfield wrapperPresent on required fields.
data-selectedoption / star / scale stepPresent on the chosen option row, active star, or active scale step.
data-checkedcheckbox / toggle optionPresent while checked.
data-drag-overupload dropzonePresent while a file is dragged over it.
data-staterootEngine status: idle | submitting | submitted | error.
data-pagerootCurrent page number, 1-based.
data-last-pagerootPresent on the final page.

Which stylesheet

two artifacts
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 setupImportWhy
Tailwind v4, or modern layered CSS@usefillo/react/styles.cssDefaults 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.cssSame rules without cascade layers. Use it when layered defaults lose to your unlayered reset and the form renders bare.
Fully customno stylesheetUnstyled 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.

rung 3 — plain css
/* 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.

localized renderer
<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.

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

Was this page useful?

Was this page helpful?
Powered by Fillo