Skip to main content

DocsCustom UI

Custom UI

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.

View as Markdownllms.txt for agents

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 such as a select, dropdown, checkbox, rating, or linear scale. If that answer opens a text field, upload, or custom component, the renderer brings the submit action back for that follow-up.

Add submissionLimit: "once_per_visitor" when each browser should only answer once. Try the live one on the right — one tap submits.

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
    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} />;
}

Live — try it

Powered by Fillo

Swap any field

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

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 — the hook exposes data, errors, page state, navigation and submit; outside React, the same escape hatch is headless in any framework below.

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, reach for <FilloProvider>: it runs the engine — validation, conditional logic, uploads, submit — but renders no layout at all.

You place fields with <FormField>, render any field from scratch with useField(), and drive everything through useFillo().

custom layout provider
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)} />;
}

See a live, working version in the examples gallery.

Multi-page wizards

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

wizard.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, and submit — exposed as a subscribable store that renders nothing.

You lay out the fields and add your own markup around them: call setValue, next()/submit(), and re-read getState() inside subscribe(). In Vue or Svelte, mirror getState() into reactive state; the React <FilloProvider> is a thin wrapper over this same engine.

any framework — your layout
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 kind Fillo already ships — say radios instead of the default — 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, shows it in the grid, and only enforces required; your component does any richer validation.

custom field kind
// 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}
      />
    ),
  }}
/>

Was this page useful?

Was this page helpful?
Powered by Fillo