Skip to main content

Custom UI

Replace individual fields or build the full form UI yourself.

Replace one field, build the whole layout in React, or use Fillo's form engine in another framework.

Buttonless forms

For a thumbs up/down rating, a one-tap CSAT score, or another small feedback widget, set settings.submitMode to "auto". The form submits after a discrete answer such as a rating or checkbox. If the answer reveals a text field, upload, or custom component, the submit button returns. Add responseLimit: { by: "browser", onRepeat: "keep" } when each browser should only answer once.

instant feedback
"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
    responseLimit: { by: "browser", onRepeat: "keep" }, // one answer per browser
    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} />;
}

Live — try it

Swap any field

Every field kind accepts a components override in @usefillo/react and @usefillo/dom. In React, use useFillo() to build the full layout. In other frameworks, use the headless form engine.

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

Your own layout (React)

<FilloForm> renders a stacked form with pages and a submit button. Use <FilloProvider> when you want to build the layout yourself. It handles validation, conditional logic, uploads, and submission without rendering a layout. Place fields with <FormField>, build a field with useField(), and access the form with 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");
  const textValue = typeof value === "string" ? value : "";
  return (
    <>
      <input type="email" value={textValue} onChange={(e) => setValue(e.target.value)} />
      {error ? <p role="alert">{error}</p> : null}
    </>
  );
}

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

Outside React, use createFormController() from @usefillo/dom or @usefillo/core. It handles validation, conditional logic, pages, spam checks, and submission without rendering a layout. In Vue or Svelte, copy getState() into reactive state.

ts
import { createClient, createFormController } from "@usefillo/dom";

const client = createClient({ key: "pk_…" });

// The engine runs validation, conditional logic, pages, spam checks, and submit.
// It renders nothing. form is required; formId is the published submission target.
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 change a built-in field, pass a components override. To add a new field kind, use a custom block and provide its renderer in customComponents. Fillo stores the value and enforces required. Add any other validation in your component.

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}
      />
    ),
  }}
/>
  • SDK reference: Look up hooks, controller methods, and renderer props.
  • Examples: Try a live custom React layout.

This page for agents: /docs/custom-ui.md · index at /llms.txt

Updated

Was this page helpful?