# Custom UI

Last updated: 2026-07-07

Canonical human page: https://fillo.so/docs/custom-ui
Quickstart: https://fillo.so/docs/embed.md
Code-defined forms: https://fillo.so/docs/authoring.md
Styling: https://fillo.so/docs/styling.md
Prefill: https://fillo.so/docs/prefill.md
Webhooks: https://fillo.so/docs/webhooks.md
API reference: https://fillo.so/docs/reference.md
Troubleshooting: https://fillo.so/docs/troubleshooting.md

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 (select, dropdown, checkbox, rating, linear scale). If that answer opens a text field, upload, or custom component, the renderer brings the submit action back for the follow-up. Add `submissionLimit: "once_per_visitor"` when each browser should only answer once.

```tsx
"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} />;
}
```

## Swap any field

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; outside React, the same escape hatch is [headless in any framework](#headless-in-any-framework) below.

```tsx
<FilloForm
  client={client}
  formId="cust-feedback"
  components={{
    rating: ({ field, value, setValue, error }) => (
      <MyEmojiRating
        label={field.label}
        value={value}
        onChange={setValue}
        error={error}
      />
    ),
  }}
/>
```

## 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, use `<FilloProvider>`: it runs the engine (validation, conditional logic, uploads, submit) but renders no layout at all. Place fields with `<FormField>`, render any field from scratch with `useField()`, and drive everything through `useFillo()`.

```tsx
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)} />;
}
```

## Multi-page wizards

`<FilloForm>` handles pages automatically. In a custom layout, `useFillo()` gives you page state and navigation — `next()` validates the current page before advancing, and conditional logic is already applied to `blocks`.

```tsx
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, submit — exposed as a subscribable store that renders nothing. In Vue or Svelte, mirror `getState()` into reactive state; the React `<FilloProvider>` is a thin wrapper over this same engine.

```ts
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 Fillo ships, 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 and shows it in the grid, and only enforces `required`; your component does any richer validation.

```tsx
// 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}
      />
    ),
  }}
/>
```
