---
title: Headless
description: Behavior and accessibility with no markup: useKibbleForm, FormProvider, useForm, useField, usePage, useFormStatus, Honeypot, Turnstile.
---
# Headless

`kibbleforms/headless` is what the styled components are built on. Use it to render a form with your own elements and classes while keeping Kibble's validation, focus management, conditional logic, partial saves, retries and spam signals.

## Preview
No styled preview: the output is whatever markup you write. The example site's `/custom` page is a complete headless form.

## Usage
```tsx
import { useKibbleForm, FormProvider, useField, useForm, Honeypot } from 'kibbleforms/headless';
import { kibble } from '@/kibble/client';
import { feedback } from '@/kibble/forms/feedback';

function TextField({ name }: { name: string }) {
  const { field, visible, value, setValue, error, inputProps, errorId } = useField(name);
  if (!visible) return null;
  return (
    <div>
      <label htmlFor={inputProps.id}>{field.label}</label>
      <input {...inputProps} value={(value as string) ?? ''} onChange={(e) => setValue(e.target.value)} />
      {error ? <p id={errorId}>{error}</p> : null}
    </div>
  );
}

function SubmitButton() {
  const { status } = useForm();
  return <button type="submit" disabled={status === 'submitting'}>Send</button>;
}

export function Feedback() {
  const form = useKibbleForm(feedback, { client: kibble });
  if (form.status === 'success') return <p>Thanks! Submission {form.result?.id} is in.</p>;
  return (
    <FormProvider form={form}>
      <form {...form.formProps}>
        <Honeypot />
        <TextField name="comments" />
        <TextField name="email" />
        <SubmitButton />
      </form>
    </FormProvider>
  );
}
```

Spread `form.formProps` on a real `<form>`: it wires `onSubmit` (Enter submits a single-page form or advances a multi-step one) and `noValidate`.

## Props

### `useKibbleForm(schema, options)`
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `client` | `KibbleClient` | required | From `createClient`. |
| `onSuccess` | `(result: SubmissionResponse) => void` | — | After a successful submit. |
| `onError` | `(error: KibbleError) => void` | — | After a failed submit (`code`, `message`, `hint`, `fields`, `status`). |
| `initialValues` | `Record<string, unknown>` | — | Programmatic prefill; wins over `prefill.query` and `defaultValue`. |
| `sessionId` | `string` | generated, kept in `sessionStorage` | Fixed session id for partial saves. |
| `actor` | `Actor` | the client's `actor` | `{ type: 'human' \| 'agent', name?, onBehalfOf? }`, sent as `meta.actor`. |
| `navigate` | `(url: string) => void` | `window.location.assign` | How to follow a redirect/schedule success. |

Everything the hook returns:

| Key | Type | Description |
| --- | --- | --- |
| `schema`, `client` | | What you passed in. |
| `formId` | `string` | Stable, SSR-safe prefix for element ids. |
| `values` | `Record<string, unknown>` | Current values of visible fields (hidden fields are pruned). |
| `errors` | `Record<string, string>` | Message per invalid field key. |
| `touched` | `Record<string, boolean>` | Fields that were blurred or validated. |
| `pageIndex` | `number` | Index into `schema.pages` of the page being shown. |
| `pageCount` | `number` | Number of visible pages. |
| `stepIndex` | `number` | 0-based position of the current page among visible pages ("Step `stepIndex + 1` of `pageCount`"). |
| `visiblePages` | `number[]` | Indexes of the pages that are visible right now. |
| `visibleKeys` | `Set<string>` | Keys of the fields that are visible right now. |
| `status` | `'idle' \| 'validating' \| 'submitting' \| 'success' \| 'error'` | |
| `result` | `SubmissionResponse \| null` | `{ id, status, success?, flags? }` after success. |
| `submitError` | `KibbleError \| null` | The last submit failure. |
| `startedAt` | `string` | ISO time the form mounted (time-to-submit signal). |
| `sessionId` | `string \| null` | Partial-save session id; null until a multi-step form needs one. |
| `fields` | `Field[]` | All fields, flattened. |
| `currentPage` | `Page` | `schema.pages[pageIndex]`. |
| `isMultiStep` | `boolean` | More than one page in the schema. |
| `isFirstPage`, `isLastPage` | `boolean` | Relative to visible pages. |
| `turnstileToken` | `string \| null` | Set by `Turnstile`. |
| `success` | `SuccessBehavior \| undefined` | The schema's `settings.success` (the code the visitor runs); the server's echo only when the schema has none. |
| `successUrl` | `string \| null` | Resolved redirect/schedule URL after success (prefill + `utm_*`), for rendering a link instead of navigating. |
| `fieldId(key)` | `(key) => string` | The control id for a field. |
| `pageHeadingId(i)` | `(i) => string` | The heading id focus moves to on page change. |
| `isFieldVisible(key)`, `isPageVisible(i)` | `=> boolean` | Conditional logic results. |
| `setValue(key, value)` | | Sets a value and clears that field's error. |
| `setTouched(key)` | | Marks blurred; shows format errors for non-empty values. |
| `validatePage(i)` | `=> boolean` | Validates one page's visible fields, sets errors, focuses the first invalid control. |
| `nextPage()` | `=> Promise<boolean>` | Validates the current page, moves to the next visible page, fires a partial save. |
| `prevPage()` | | Moves to the previous visible page. |
| `goToPage(i)` | | Jumps to a page (clamped). |
| `submit()` | `=> Promise<void>` | Validates every visible field, jumps to the first failing page, otherwise posts. Ignored while submitting or after success. |
| `reset()` | | Back to initial values and page 0; rotates the session id. |
| `focusField(key)` | | Focuses a control by key (after render). |
| `setHoneypot(value)`, `setTurnstileToken(token)` | | Used by `Honeypot` and `Turnstile`. |
| `resolveSuccessUrl(success?, values?)` | `=> string \| null` | Same resolution as the automatic redirect, for any success/values. |
| `formProps` | `{ onSubmit, noValidate: true }` | Spread on your `<form>`. |

### `FormProvider` and `useForm()`
`<FormProvider form={form}>` makes the hook result available; `useForm()` returns it (throws outside a provider; `useOptionalForm()` returns `null` instead). The styled `Form` is a `FormProvider` too, so the hooks below work inside it.

### `useField(key)`
| Key | Type | Description |
| --- | --- | --- |
| `field` | `Field` | The schema field (throws if `key` is unknown). |
| `visible` | `boolean` | False when a `showWhen` hides it; render nothing. |
| `value`, `setValue(v)` | | |
| `onBlur()` | | Calls `setTouched`. |
| `error` | `string \| undefined` | |
| `touched` | `boolean` | |
| `id`, `labelId`, `descriptionId`, `errorId` | `string` | Ids to use on the control, label, help and error elements. |
| `inputProps` | `{ id, name, 'aria-invalid'?, 'aria-describedby'?, 'aria-required'?, onBlur }` | Spread on the control. `aria-required` instead of `required`, so Kibble's messages show, not the browser's. For a group (radios, rating), put `id`, `aria-labelledby={labelId}` and the `aria-*` props on the group element with `tabIndex={-1}`. |

### `usePage(index?)`
Returns `{ page, index, step, count, fields (visible), allFields, headingId, isVisible, isFirst, isLast, next, prev }` for the given or current page.

### `useFormStatus()`
Returns `{ status, isSubmitting, isSuccess, result, error }`.

### `Honeypot`
`<Honeypot name="_kb_website" label="Website" />` renders a visually hidden text input (`data-kb="honeypot"`, `aria-hidden`, `tabIndex={-1}`, `autoComplete="off"`, positioned off-screen with inline styles) whose value is sent as `meta.honeypot`. Both props are optional; `label` is the hidden label text. Needs no CSS. Put it inside your `<form>`.

### `Turnstile`
`<Turnstile siteKey="…" className? />` loads Cloudflare Turnstile once, renders the widget in `<div data-kb="turnstile">` and feeds the token into the form. The styled `Form` renders it when `settings.turnstileSiteKey` or `createClient({ turnstileSiteKey })` is set. Kibble requires and checks the token only on forms whose schema sets the site key, once it has that widget's secret key (see [Turnstile](/docs/spam.md#turnstile)).

### Also exported
`prefillFromQuery(fields, search?)`, `evaluateCondition`, `visibleFieldKeys`, `visiblePageIndexes`, `pruneHiddenValues`, `resolveSuccessUrl`, `getOrCreateSessionId`, `clearSessionId`, `KibbleError`, and the types `KibbleFormState`, `UseKibbleFormOptions`, `UseFieldResult`, `Condition`, `Actor`.

## Variants and states
- **Multi-step**: render `currentPage.fields` (or `usePage().fields`) and buttons calling `prevPage`/`nextPage`; make Next `type="submit"` so Enter advances. Show `stepIndex + 1` of `pageCount`.
- **Errors**: `errors[key]` per field; `submitError` for form-level failures (network, `rate_limited`, `agents_blocked`…). Server field errors arrive in `errors` too.
- **Success**: branch on `status === 'success'`; for redirect/schedule either let the hook navigate or pass `navigate` and use `successUrl`.

## Customization
Everything. The headless layer has no classes, no tokens and no `data-kb` except on `Honeypot` and `Turnstile`. The styled components in `kibbleforms` are a worked example of building on these hooks.

## Accessibility
The hooks give you the pieces; your markup decides. Minimums: spread `inputProps` on each control, put `id={labelId}` on the label and `htmlFor={id}` (or `aria-labelledby` on a group), give help and error elements the `descriptionId`/`errorId`, give page headings `id={headingId} tabIndex={-1}` so focus can land there, and keep the `Honeypot` inside the form.

## Related
[Form](/docs/components/form.md), [Field](/docs/components/field.md), [Conditional logic](/docs/conditional-logic.md), [Agents](/docs/agents.md), [API](/docs/api.md)
