docs
    site-kit: Forms
    v7.2.0.md

    Forms — @sonordev/site-kit/forms

    Sonor-managed forms with multi-step support, conditional logic, validation, anti-bot protection, and automatic CRM routing.

    Usage

    Option 1: Managed (simplest)

    import { ManagedForm } from '@sonordev/site-kit/forms'
    
    export default function ContactPage() {
      return <ManagedForm formId="contact-form" />
    }

    Fetches form config from Sonor, renders fields, handles submission, routes to CRM.

    Experiences (4.0)

    Since 4.0 that one line gets you the spotlight experience by default: the familiar layout, alive. A glowing ring visits the field you're in, completed fields earn a check, and each finished row collapses into a sentence the form says back — "Nice to meet you, Jordan." / "We'll follow up at jordan@…" — with an edit control to reopen it.

    ExperienceWhat it isHow to get it
    spotlightDefault. Fields perform in place; answered rows become sentencesnothing to do
    stageAccordion: one beat at a time on a single surface, answered beats settle into filled rowsexperience="stage"
    classicThe plain grid form, no choreographyexperience="classic"
    <ManagedForm formId="contact-form" />                      // spotlight
    <ManagedForm formId="contact-form" experience="stage" />   // accordion
    <ManagedForm formId="contact-form" experience="classic" /> // opt out

    Sonor can decide instead of the site, with no deploy on either side — set the form's layout to classic, stage, or spotlight. An explicit experience prop always wins over the config.

    Three things hold for every experience:

    • The lead is always capturable. SSR and no-JS render the plain classic form; the experience layers on after hydration. If its chunk never arrives, the form still works and still submits.
    • Nobody pays for what they don't use. Experience code is loaded on demand, so a classic form downloads none of it.
    • Same engine. Identical validation, spam protection and CRM routing. An experience is a rendering, not a fork.

    Editing what the form says back

    The sentence for a row comes from the kit's built-in wording unless you write your own in Sonor: set completion_message on a field (the first answered field in the row that has one wins). Tokens:

    TokenResolves to
    {value}this field's answer
    {label}this field's label
    {first_name}the form's first-name answer, if any
    {any_field_slug}that field's answer
    Welcome aboard, {first_name}. We'll reach you at {email}.
    

    Leave it empty to keep the built-in sentence. Unknown or unanswered tokens render as nothing — never raw braces.

    Option 2: Headless Hook (full UI control)

    'use client'
    import { useForm } from '@sonordev/site-kit/forms'
    
    export function ContactForm() {
      const {
        fields, values, errors, setFieldValue, handleSubmit, toolAttributes, isSubmitting,
        step, totalSteps, isMultiStep, nextStep, prevStep, isLastStep,
      } = useForm('contact-form')
    
      return (
        <form {...toolAttributes} onSubmit={handleSubmit}>
          {fields.map(field => (
            <div key={field.slug}>
              <label>{field.label}</label>
              <input
                value={String(values[field.slug] || '')}
                onChange={(e) => setFieldValue(field.slug, e.target.value)}
              />
              {errors[field.slug] && <span className="error">{errors[field.slug]}</span>}
            </div>
          ))}
          {isMultiStep && !isLastStep && <button type="button" onClick={nextStep}>Next</button>}
          {isLastStep && <button type="submit" disabled={isSubmitting}>Submit</button>}
        </form>
      )
    }

    Option 3: Render Prop

    <ManagedForm formId="contact-form">
      {({ fields, values, setFieldValue, submit }) => (
        <MyCustomFormUI fields={fields} values={values} onChange={setFieldValue} onSubmit={submit} />
      )}
    </ManagedForm>

    Option 4: Programmatic API

    import { formsApi, field, configureFormsApi } from '@sonordev/site-kit/forms'
    
    configureFormsApi({ baseUrl: 'https://api.sonor.io', apiKey: 'sonor_...' })
    
    const form = await formsApi.create({
      projectId: 'xxx',
      slug: 'newsletter',
      name: 'Newsletter Signup',
      formType: 'newsletter',
      fields: [
        field.email('email', 'Email', { isRequired: true }),
        field.text('name', 'Name'),
      ],
    })

    ManagedForm Props

    interface ManagedFormProps {
      formId: string                              // Form slug or ID
      className?: string
      onSuccess?: (submission: FormSubmitResult) => void
      onError?: (error: Error) => void
      children?: (renderProps: UseFormReturn) => ReactNode  // Render prop override
    }

    useForm Return

    interface UseFormReturn {
      form: ManagedFormConfig | null
      isLoading: boolean
      fetchError: Error | null
      allFields: FormField[]
      fields: FormField[]               // Current step's fields
      visibleFields: FormField[]         // After conditional logic
      values: Record<string, unknown>
      errors: Record<string, string>
      setFieldValue: (key: string, value: unknown) => void
      step: number
      totalSteps: number
      isMultiStep: boolean
      progress: number                   // 0-100
      nextStep: () => void
      prevStep: () => void
      goToStep: (n: number) => void
      canGoNext: boolean
      canGoPrev: boolean
      isLastStep: boolean
      validate: () => boolean
      // Resolves with what happened: { status: 'sent' | 'invalid' | 'failed' | 'busy' | 'next_step', ... }
      submit: (options?: FormSubmitOptions) => Promise<FormSubmitOutcome>
      // A ready-made <form onSubmit>: steps or submits, and answers an AI agent (see below)
      handleSubmit: (event: FormEvent<HTMLFormElement>) => void
      // WebMCP attributes for your <form>; null until the config loads
      toolAttributes: { toolname: string; tooldescription: string } | null
      isSubmitting: boolean
      isComplete: boolean
      reset: () => void
    }

    Field Types

    text, email, phone, tel, number, textarea, select, multi-select, checkbox, radio, date, time, datetime, file, signature, rating, slider, hidden, heading, section_header, paragraph

    Field Builder

    field.text(slug, label, options?)
    field.email(slug, label, options?)
    field.phone(slug, label, options?)
    field.textarea(slug, label, options?)
    field.select(slug, label, { options: [{ value, label }] })
    field.date(slug, label, options?)
    field.checkbox(slug, label, options?)
    field.rating(slug, label, options?)

    Form Routing

    Submissions auto-route based on form_type:

    Form TypeRoutes ToUse Case
    prospect / lead-capture / contactCRM ContactsSales inquiries, quotes
    supportSupport TicketsHelp requests
    feedbackFeedback entriesUser feedback
    newsletterEmail SubscribersNewsletter signups
    customForm Submissions onlyCustom handling

    Spam protection

    Sonor screens every submission on its side, and there's nothing to configure. It only accepts a submission with evidence that a real browser rendered the page, which <ManagedForm> and useForm send automatically. A hand-rolled fetch to the forms API, or a proxy through your own API route, can't send it, so Sonor refuses those before anything is written: the visitor sees an error and you get no lead. Submit through site-kit, and define the form's fields in Sonor so both can render and validate them.

    Agent-ready forms (7.2.0)

    People increasingly ask an AI assistant to fill out a form for them: a quote request, a booking, a newsletter signup. Assistants act on what the page's markup says each control is, so managed forms now say it clearly. There's nothing to configure.

    • Autocomplete tokens. Name, email, phone, company, website and address fields carry the right autocomplete token (given-name, email, tel, organization, url, postal-code, ...), read from the field's CRM destination in Sonor, then its type, slug and label. Browser autofill and password managers use the same tokens.
    • Accessible wiring. Error and help text are tied to their control (aria-describedby), an errored control says so (aria-invalid), radio and checkbox groups are named by their question, and rating stars by their value.
    • WebMCP. Every interactive managed form carries the declarative WebMCP attributes (toolname, tooldescription), so a browser agent that supports WebMCP can treat it as a tool. It fills the same fields a person would, and the submit runs the same validation. There's no toolautosubmit: the assistant fills the form and the person it's helping presses Send. Browsers without WebMCP ignore the attributes.
    • Agent-sent leads are tagged. When the browser reports that an agent pressed submit (SubmitEvent.agentInvoked), the submission carries that flag and the lead is tagged in Sonor, so you can see how those leads compare over time. It's the browser's word, so it never changes how a submission is checked. The agent is handed the outcome of its submit (respondWith).
    • Nothing typed before the form loads is lost. ServerForm renders the form in the page HTML and loads the interactive version at idle. Since 7.2.0, typing into the server-rendered form starts that upgrade at once and what was typed carries over, and a Send pressed before the upgrade is held and sent the moment it lands. The server-rendered Send button stays disabled until the page can catch the click.

    With useForm, spread toolAttributes on your <form> and use handleSubmit as its onSubmit to get the same behavior.

    ServerForm's enhance prop is ignored since 7.2.0. Only the interactive form can send a managed form, so enhance={false} only ever produced a form that silently went nowhere.

    Styles

    import '@sonordev/site-kit/forms/styles.css'  // Optional default styles

    All components use .sk-form__* class names. Override with className prop or CSS variables.