# Style Fillo forms

Last updated: 2026-07-03

Canonical human page: https://fillo.so/docs/styling
Install docs: https://fillo.so/docs/embed.md
Troubleshooting: https://fillo.so/docs/troubleshooting.md

## Pick your rung

Three levels, lowest effort first. They stack — most apps use the first two together.

1. **Theme tokens** — a 5-minute brand match: `{ colorScheme, primary, background, text, radius, fontFamily }`. The only styling that also applies to the hosted /f page.
2. **The `appearance` prop + data-* variants** — the Tailwind path. Attach class strings to named slots; state is exposed as data attributes for `data-[invalid]:` / `data-[selected]:` variants.
3. **Stable `fillo-*` classes + CSS variables** — plain CSS. The stylesheet is single-class specificity throughout, so one consumer class always wins.

```tsx
<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

Classes are appended after the built-in `fillo-*` class on each slot and win by cascade order — the default stylesheet lives in a lower cascade layer, so no `!important` is ever needed. Any slot accepts a string or a `(state) => string` function. `fields` overrides one field by id, appended after the general slot class. Also on `<FilloProvider>`.

```tsx
<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`). Renames are breaking; additions are minors. The "Powered by Fillo" badge is deliberately not a slot.

| Slot | What it is |
| --- | --- |
| 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 — use `group` on the field wrapper to reach inner slots.

| 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

```ts
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

```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

`strings` overrides every visitor-facing renderer string (`Partial<FilloStrings>`; defaults exported as `DEFAULT_STRINGS`). Schema-authored text — labels, descriptions, success copy in settings — always wins over these defaults. Also on `<FilloProvider>`.

```tsx
<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 app 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

localStorage only — no cookies, 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. |
