npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@webling/sform

v1.0.0

Published

A React component library for securing web forms against bots, scrapers, spammers, and programmatic submissions.

Readme

sform (Secure Form)

A React component library for securing web forms against bots, scrapers, spammers, and programmatic submissions. It blocks those attempts before they reach your server and handles validation and error display for real users.

Components are drop-in replacements for standard HTML inputs. The security checks run in the background and do not affect how the form behaves for a real user.

sform is one layer of defense. Combine it with server-side validation, rate limiting, and other backend safeguards. You do not need to be totally secure, just more secure than the last target.


Contents


Features

Security

  • Honeypot fields that silently reject bot submissions
  • User interaction verification (ensures a human initiated each field interaction)
  • Keystroke timing detection to flag machine-speed input
  • Encrypted value attributes on select, radio, and checkbox options to prevent option scraping
  • Options withheld from the DOM until triggered by a physical focus event (SelectInput)
  • Native browser autofill detection. Autofilled fields are trusted automatically
  • CSRF token delivery via request headers on submission
  • Spam-click counting and prevention on submit buttons. Repeat clicks are counted via onSpamClick for UX telemetry and double-submission is blocked via the handler, while aria-disabled keeps the element interactive so every click is captured

Validation

  • Per-field validation rules via FormErrorsConfig
  • Cross-field validation (rules receive the full current FormValues)
  • Three inline-error display modes: 'blur' (default), 'submit', 'page'
  • Inline errors beneath each field, always present in the DOM for screen readers
  • Aggregated error list with scroll-to-field anchor links
  • Built-in validators: oneOf, subsetOf, requireAtLeastOne

State management

  • Uncontrolled by default (falls back to FormData on submit)
  • Opt-in controlled state via useFormState (tracks values, isDirty, and supports reset and commit)

Layout & UX

  • Multi-page form support with FormPage, FormPageList, and PageNavButton
  • Visual progress bar (ProgressBar) and step indicator (ProgressState)
  • Field grouping with FormSection, multi-column layout with FormColumns, and conditional rendering with ConditionalSection
  • Full TypeScript support with exported prop types

Installation

npm install @webling/sform
# or
pnpm add @webling/sform

React 19 is a peer dependency and must be installed separately.

Import the stylesheet once at your application root. It provides the CSS required for autofill detection and serves as the base for sform field styles:

import '@webling/sform/styles'

Quick start

import { Form, Honeypot, TextInput, Button } from '@webling/sform'

export function ContactForm() {
  return (
    <Form onSubmit={(e, { csrfHeaders }) => {
      // csrfHeaders: { 'X-CSRF-Token': '...' } when csrfToken prop is set
      fetch('/api/contact', { method: 'POST', headers: csrfHeaders, body: new FormData(e.currentTarget) })
    }}>
      <Honeypot />
      <TextInput id="name"  label="Full name"  required />
      <TextInput id="email" label="Email"      type="email" required />
      <TextInput id="message" label="Message"  required />
      <Button type="submit">Send</Button>
    </Form>
  )
}

Submissions are silently blocked if any security check fails (honeypot fill, untrusted interaction, suspicious keystroke timing). Your onSubmit handler is never called in those cases.


API reference

Form

Root provider. Must wrap all other sform components.

<Form
  errorsConfig={errorsConfig}   // validation rules; see FormErrorsConfig below
  formState={formState}         // controlled state from useFormState (optional)
  inlineMode="blur"             // 'blur' | 'submit' | 'page'  (default: 'blur')
  csrfToken="tok-abc"           // included as X-CSRF-Token header in onSubmit security arg
  onSubmit={(e, security) => {
    // security.csrfHeaders: pass to fetch()
    // security.spamClickCount: for telemetry
  }}
>

| Prop | Type | Default | Description | |------|------|---------|-------------| | errorsConfig | FormErrorsConfig | — | Validation rules keyed by field id. | | formState | FormStateResult | — | Controlled state from useFormState. Required for isDirty and cross-field validation on blur. | | inlineMode | 'blur' \| 'submit' \| 'page' | 'blur' | When to show inline errors. 'submit' delays all errors until the first submit attempt. | | csrfToken | string | — | CSRF token forwarded to onSubmit as security.csrfHeaders['X-CSRF-Token']. | | onSubmit | (e, security: FormSubmitSecurity) => void | — | Called only when all security checks and validations pass. |

onSubmit receives the standard submit event plus FormSubmitSecurity:

interface FormSubmitSecurity {
  csrfHeaders: Record<string, string>  // { 'X-CSRF-Token': token } or {}
  spamClickCount: number               // repeat clicks counted by the spam gate. UX telemetry
  hasUntrustedEvents: boolean          // true if any field received an isTrusted:false event
                                       // (autofill, password manager). Telemetry only, not a gate
}

useFormState

Opt-in controlled state hook. Pass the result to <Form formState={...}>.

const formState = useFormState({ name: '', email: '' })

// formState.values        -- current field values
// formState.setValue      -- update a single field
// formState.setValues     -- batch-load (e.g. from server)
// formState.reset()       -- reset to initial values (or last commit)
// formState.reset(vals)   -- reset to specific values
// formState.commit(vals)  -- set new values and redefine the dirty baseline
// formState.isDirty       -- true if any value differs from initial (or last commit)

initialValues is captured once at mount. Passing a new object reference on re-render does not change the dirty baseline.

Async-loaded data and commit

When form values are fetched after mount, call commit once the data arrives. It both populates the form and redefines what isDirty and reset() compare against So the user sees a clean form, and cancelling returns to the fetched values rather than the empty initial state.

const formState = useFormState()

useEffect(() => {
  fetchProfile().then((data) => {
    formState.commit({ name: data.name, email: data.email })
  })
}, [])

// After commit:
// formState.isDirty        → false (fetched values are the new baseline)
// formState.reset()        → restores fetched values, not {}
// formState.isDirty        → false after reset

Note: commit replaces the entire form state. Any key not present in the new baseline is removed. Use setValues instead if you only want to merge new keys without affecting isDirty or reset behaviour.


Input components

All input components require an id prop. It is used as the form-state key, the <label for> target, and the error anchor.

TextInput

<TextInput id="email" label="Email" type="email" required />

Extends InputHTMLAttributes<HTMLInputElement>. Additional props:

| Prop | Type | Description | |------|------|-------------| | id | string | Required. Field key and label target. | | label | string | Visible label text. | | error | string \| null | Inline error override (ignored when inside a <Form>). |

DateInput

<DateInput id="dob" label="Date of birth" required />

Same props as TextInput. Renders <input type="date">.

CurrencyInput

<CurrencyInput id="amount" label="Amount" currencySymbol="$" required />

| Prop | Type | Default | Description | |------|------|---------|-------------| | id | string | — | Required. | | label | string | — | Visible label. | | currencySymbol | string | '$' | Symbol displayed before the input. | | currencyCode | string | — | Optional code (e.g. 'USD') for screen readers. | | error | string \| null | — | Inline error override. |

SelectInput

Options are withheld from the DOM until the user physically focuses the select (prevents cold scraping). Values are replaced with session-unique UUID tokens at render time.

<SelectInput
  id="country"
  label="Country"
  placeholder="Select a country"
  options={[
    { label: 'United States', value: 'us' },
    { label: 'Canada',        value: 'ca' },
  ]}
  required
/>

| Prop | Type | Description | |------|------|-------------| | id | string | Required. | | label | string | Required. | | options | OptionItem[] | { label, value, disabled? }[] | | placeholder | string | First disabled option shown before selection. | | error | string \| null | Inline error override. |

RadioInput

<RadioInput
  id="plan"
  legend="Plan"
  options={[
    { label: 'Monthly', value: 'monthly' },
    { label: 'Annual',  value: 'annual'  },
  ]}
  required
/>

| Prop | Type | Description | |------|------|-------------| | id | string | Required. | | legend | string | Fieldset legend (required for accessibility). | | options | OptionItem[] | Option list. | | value | string | Controlled selected value (real value, not a token). | | onChange | ChangeEventHandler<HTMLInputElement> | Change handler. | | required | boolean | Adds aria-required to fieldset; required on inner inputs. | | error | string \| null | Inline error override. |

CheckboxInput (single)

<CheckboxInput id="terms" label={<>I agree to the <a href="/terms">terms</a></>} required />

label accepts ReactNode to support inline links. Stores 'true' when checked, '' when unchecked.

| Prop | Type | Description | |------|------|-------------| | id | string | Required. | | label | ReactNode | Label text or element. | | error | string \| null | Inline error override. |

CheckboxInput (group)

Pass options to switch to the group variant. Stores string[] of real selected values.

<CheckboxInput
  id="interests"
  legend="Interests"
  options={[
    { label: 'Music',   value: 'music'   },
    { label: 'Sports',  value: 'sports'  },
    { label: 'Travel',  value: 'travel'  },
  ]}
  onChange={(selected) => console.log(selected)} // real values, never tokens
/>

| Prop | Type | Description | |------|------|-------------| | id | string | Required. | | legend | string | Fieldset legend. | | options | OptionItem[] | Option list. | | values | string[] | Controlled selected values (real values). | | onChange | (selected: string[]) => void | Receives real values. | | error | string \| null | Inline error override. |

Do not put required on individual checkboxes in a group. Use aria-required on the fieldset (added automatically) and enforce the constraint via a ValidationFn with requireAtLeastOne.

AddressInput

Composite input rendering street, street2, city, state, postal, and country sub-fields. Each sub-field is keyed as ${id}-street, ${id}-city, etc. in form state and errorsConfig.

<AddressInput id="billing" legend="Billing address" required />

| Prop | Type | Description | |------|------|-------------| | id | string | Required. Sub-fields use ${id}-street, ${id}-city, etc. | | legend | string | Fieldset legend. Defaults to 'Address'. | | errors | Partial<Record<'street'\|'street2'\|'city'\|'state'\|'postal'\|'country', string\|null>> | Error overrides for standalone use outside a <Form>. |

Button

<Button type="submit" variant="primary" isLoading={isSubmitting}>Submit</Button>
<Button variant="secondary" onClick={handleCancel}>Cancel</Button>

Two independent states:

Spam gate (type="submit" only). Locks after the first click that passes validation, preventing double-submission. Uses aria-disabled + the sform-btn--locked CSS modifier rather than the native disabled attribute, so repeat clicks still fire and can be counted. Resets automatically when isLoading transitions true → false, when validation errors appear, or after a 500 ms fallback when isLoading is not provided.

Loading state (isLoading prop). Shows a spinner and aria-busy. Uses the native disabled attribute, blocking all interaction until cleared.

| Prop | Type | Default | Description | |------|------|---------|-------------| | variant | 'primary' \| 'secondary' \| 'ghost' | 'primary' | Visual style. | | isLoading | boolean | — | Shows spinner and natively disables the button. When it transitions true → false, the spam gate resets so the user can resubmit. | | onSpamClick | (clickCount: number) => void | — | Called on every repeat click (count ≥ 2) within a single submission attempt. UX telemetry only. Not a security signal. |

Honeypot

Hidden field invisible to real users. Bot-filled submissions are silently blocked.

<Honeypot />
<Honeypot fieldName="company" />  {/* custom name when using multiple honeypots */}

| Prop | Type | Default | Description | |------|------|---------|-------------| | fieldName | string | 'website' | Name attribute. Avoid obvious names like 'honeypot' or 'hp'. Must be unique per page if used multiple times. |


Validation utilities

Factory functions that return ValidationFn. Use inside errorsConfig.

import { oneOf, subsetOf, requireAtLeastOne } from '@webling/sform'

| Utility | Use with | Checks | |---------|----------|--------| | oneOf(options, message?) | SelectInput, RadioInput | Value is a known real option value | | subsetOf(options, message?) | CheckboxInput (group) | Every selected value is a known option. An empty selection passes; combine with requireAtLeastOne to require at least one. | | requireAtLeastOne(message?) | CheckboxInput (group or single), any required field | string[].length > 0 or non-empty string |

Rules always receive real option values (never UUID tokens) because form state is always populated with decrypted values.


Error components

ErrorList

Aggregated error list. Uses role="alert" so screen readers announce it on insertion.

When placed inside a <Form>, self-populates automatically in two modes:

  • After a next-click (<PageNavButton direction="next">): shows errors for fields on the current page only. Resets when the user navigates to a new page.
  • After a submit attempt: shows all errors across all pages.
<ErrorList heading="Please fix the following:" />

Pass errors explicitly only when onNavigate callbacks are needed (e.g. an external router):

<ErrorList
  errors={[
    { fieldId: 'email', label: 'Email is required', onNavigate: () => goToPage(0) },
  ]}
/>

InlineError

Per-field error, always in the DOM for aria-live. Input components wire it automatically via useFormField. Use this component directly only for custom inputs.

<InlineError fieldId="email" />

ErrorRefLink

Anchor link that scrolls to and focuses a field. Used inside ErrorList entries.

<ErrorRefLink fieldId="email" onNavigate={() => goToPage(0)}>Email is required</ErrorRefLink>

Layout components

FormPage

Wraps one step of a multi-page form. Must be inside <Form>. Pages register themselves with FormProgressContext on mount.

<FormPage pageId="contact" label="Contact info">
  ...fields...
</FormPage>

| Prop | Type | Description | |------|------|-------------| | pageId | string | Unique identifier for this page. | | label | string | Step label shown in progress indicators. |

Multi-page forms require useFormState. Pages are unmounted when not active, so input values are lost on navigation unless formState is passed to <Form>. Pass value={formState.values.fieldId} to each input so its value is restored when the user returns to a completed page. Without controlled values, submitted data is still captured, but fields appear blank on revisit. See the multi-page form example.

FormPageList

Reads registered pages from FormProgressContext and renders a list via a render prop. Use it to build tab bars, sidebar navigation, step indicators, or any other page-reference UI.

Renders null when there are no registered pages or when used outside a <Form>.

<FormPageList
  renderItem={({ pageId, label, pageIndex, progressState, hasErrors, goToPage }) => (
    <button key={pageId} onClick={goToPage} disabled={progressState === 'disabled'}>
      {pageIndex + 1}. {label}{hasErrors && ' ⚠'}
    </button>
  )}
/>

The render prop receives a FormPageListItemProps object for each registered page in registration order. Navigation position and validation errors are exposed as separate fields so every combination can be styled independently. For example, a current page with errors, or a completed page that still has errors.

type PageProgressState = 'current' | 'visited' | 'enabled' | 'disabled'

interface FormPageListItemProps {
  pageId: string              // matches the pageId prop of the corresponding <FormPage>
  label: string               // matches the label prop of the corresponding <FormPage>
  pageIndex: number           // zero-based registration order
  progressState: PageProgressState
  hasErrors: boolean          // true if any field registered on this page has an active error
  goToPage: () => void        // navigates to this page; pass directly as onClick or call conditionally
}

PageProgressState values:

| Value | Condition | |-------|-----------| | 'current' | This page is the currently active page | | 'visited' | Page has been navigated to before (the user has moved past it) | | 'enabled' | Page is reachable (the next unvisited step) but not yet current | | 'disabled' | Page cannot be navigated to yet |

hasErrors is independent of progressState and can be true on any visited page, including the current one. Read useFormProgressContext() inside your item component to access goToPage and other navigation helpers.

| Prop | Type | Default | Description | |------|------|---------|-------------| | renderItem | (props: FormPageListItemProps) => ReactNode | — | Called once per registered page. Must return the item element. | | allowForwardNavigation | boolean | false | When true, all pages ahead of the current position receive progressState: 'enabled' instead of 'disabled', allowing users to jump to any page freely. | | className | string | — | Appended to sform-page-list on the wrapper <div>. |

PageNavButton

Pre-styled next/previous button wired to FormProgressContext. On a next-click, validation runs for every field on the current page before navigating. Making inline errors visible regardless of inlineMode. Navigation is blocked by default when errors are present; set allowNavigationWithErrors to permit it.

Place <ErrorList> anywhere in the page layout. It self-populates with current-page errors after the first next-click. For full placement control, use onValidate to receive errors in state instead.

<PageNavButton direction="next" />
<PageNavButton direction="previous" />
<PageNavButton direction="next" variant="ghost">Continue →</PageNavButton>
<PageNavButton direction="next" allowNavigationWithErrors />
// Self-populating page error list. Place anywhere in the layout
<ErrorList heading="Fix these errors before continuing:" />
<PageNavButton direction="next" />
// Manual placement via onValidate. Stores errors in state, render ErrorList anywhere
const [pageErrors, setPageErrors] = useState<FieldErrorEntry[]>([])

<ErrorList errors={pageErrors} heading="Fix these errors before continuing:" />
<PageNavButton direction="next" onValidate={setPageErrors} />

| Prop | Type | Default | Description | |------|------|---------|-------------| | direction | 'next' \| 'previous' | — | Required. Drives FormProgressContext navigation and default label/variant. | | variant | 'primary' \| 'secondary' \| 'ghost' | 'primary' for next, 'secondary' for previous | Visual style. | | allowNavigationWithErrors | boolean | false | When false (default), a next-click that finds errors surfaces them but blocks navigation. When true, validation still runs and errors are shown, but navigation proceeds regardless. Has no effect on previous-direction buttons. | | onValidate | (pageErrors: FieldErrorEntry[]) => void | — | Called on every next-click with the errors for the current page. pageErrors is empty when all fields pass. Use this to store errors in state and render <ErrorList> at any position in your layout. |

FormSection

Groups related fields under an optional visible heading. Renders a <section> element (not a <fieldset>) so it is a layout grouping rather than a control grouping.

<FormSection heading="Personal details" description="As they appear on your ID.">
  <TextInput id="firstname" label="First name" required />
  <TextInput id="lastname"  label="Last name"  required />
</FormSection>

| Prop | Type | Default | Description | |------|------|---------|-------------| | heading | string | — | Optional visible heading. | | description | string | — | Optional paragraph beneath the heading. | | headingLevel | 2 \| 3 \| 4 | 2 | HTML heading tag level (h2, h3, or h4). |

FormColumns

Lays out its children in a CSS grid with equal-width columns. Stack multiple FormColumns inside a FormSection to create multiple rows.

<FormSection heading="Address">
  <FormColumns columns={3}>
    <TextInput id="city"    label="City"  required />
    <SelectInput id="state" label="State" options={stateOptions} required />
    <TextInput id="zip"     label="ZIP"   required />
  </FormColumns>
  <FormColumns>
    <TextInput id="country" label="Country" required />
    <TextInput id="region"  label="Region" />
  </FormColumns>
</FormSection>

| Prop | Type | Default | Description | |------|------|---------|-------------| | columns | 2 \| 3 \| 4 | 2 | Number of equal-width columns. |

ConditionalSection

Mounts or unmounts its children based on a condition. When hidden, children are removed from the DOM entirely. Hidden fields do not participate in validation or submission.

// Boolean prop
<ConditionalSection visible={formState.values.hasSpouse === 'true'}>
  <TextInput id="spouse-name" label="Spouse name" required />
</ConditionalSection>

// Predicate function (evaluated at render time)
<ConditionalSection visible={() => formState.values.country === 'us'}>
  <TextInput id="ssn" label="Social security number" required />
</ConditionalSection>

// Clear form state when the section hides
<ConditionalSection
  visible={formState.values.hasSpouse === 'true'}
  onUnmount={() => formState.setValues({ ...formState.values, 'spouse-name': '' })}
>
  <TextInput id="spouse-name" label="Spouse name" required />
</ConditionalSection>

| Prop | Type | Default | Description | |------|------|---------|-------------| | visible | boolean \| (() => boolean) | — | Required. Controls whether children are mounted. Accepts a boolean or a zero-argument predicate. | | onUnmount | () => void | — | Called when the section transitions from visible to hidden. Use this to clear field values from useFormState. |

Validation is excluded automatically. When a field component unmounts, it deregisters itself from the validation system. validateAll on submit skips unmounted fields, and any active errors are cleared. Fields inside a hidden ConditionalSection can be included in errorsConfig without needing to be removed dynamically.

Field values in useFormState persist after unmount. Pass onUnmount to clear them if needed. For example, to avoid stale data being submitted or isDirty remaining true after the section hides.


Progress components

All progress components render null when outside a <Form> or when the form has only one page.

ProgressBar

Horizontal progress bar driven by FormProgressContext.

<ProgressBar />

ProgressState

Full step-indicator nav listing all pages with their states (current, visited, upcoming).

<ProgressState />

ProgressNode

Single step indicator node. Use when building a custom progress layout.

<ProgressNode pageIndex={0} />
<ProgressNode pageIndex={1} />

| Prop | Type | Description | |------|------|-------------| | pageIndex | number | Zero-based index into the registered pages array. |


Examples

Controlled form state

import { Form, Honeypot, TextInput, Button, useFormState } from '@webling/sform'

export function SignupForm() {
  const formState = useFormState({ name: '', email: '' })

  return (
    <Form formState={formState} onSubmit={(e, { csrfHeaders }) => {
      if (!formState.isDirty) return
      // formState.values contains the current field values
      fetch('/api/signup', { method: 'POST', headers: csrfHeaders,
        body: JSON.stringify(formState.values) })
    }}>
      <Honeypot />
      <TextInput id="name"  label="Name"  required />
      <TextInput id="email" label="Email" type="email" required />
      <Button type="submit">Sign up</Button>
    </Form>
  )
}

Validation with errorsConfig

import { Form, Honeypot, TextInput, Button, ErrorList } from '@webling/sform'
import type { FormErrorsConfig } from '@webling/sform'

const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/

const errorsConfig = {
  name: [
    { label: 'Name is required',        validate: (v) => (v ? null : 'Required') },
    { label: 'Name exceeds 50 chars',   validate: (v) => (typeof v === 'string' && v.length <= 50 ? null : 'Max 50 characters') },
  ],
  email: [
    { label: 'Email is required',       validate: (v) => (v ? null : 'Required') },
    { label: 'Email format is invalid', validate: (v) => (typeof v === 'string' && EMAIL_RE.test(v) ? null : 'Enter a valid email') },
  ],
} satisfies FormErrorsConfig

export function ContactForm() {
  return (
    <Form errorsConfig={errorsConfig} inlineMode="blur" onSubmit={handleSubmit}>
      <ErrorList heading="Fix the following errors:" />
      <Honeypot />
      <TextInput id="name"  label="Name"  required />
      <TextInput id="email" label="Email" type="email" required />
      <Button type="submit">Send</Button>
    </Form>
  )
}

Option-based inputs with validation utilities

import {
  Form, Honeypot, SelectInput, RadioInput, CheckboxInput, Button,
  useFormState, oneOf, subsetOf, requireAtLeastOne,
} from '@webling/sform'
import type { FormErrorsConfig, OptionItem } from '@webling/sform'

const COUNTRY_OPTIONS = [
  { label: 'United States', value: 'us' },
  { label: 'Canada',        value: 'ca' },
  { label: 'Mexico',        value: 'mx' },
] satisfies OptionItem[]

const PLAN_OPTIONS = [
  { label: 'Monthly', value: 'monthly' },
  { label: 'Annual',  value: 'annual'  },
] satisfies OptionItem[]

const INTEREST_OPTIONS = [
  { label: 'News',    value: 'news'    },
  { label: 'Updates', value: 'updates' },
  { label: 'Offers',  value: 'offers'  },
] satisfies OptionItem[]

const errorsConfig = {
  country:   [{ label: 'Country',              validate: oneOf(COUNTRY_OPTIONS) }],
  plan:      [{ label: 'Plan',                 validate: oneOf(PLAN_OPTIONS) }],
  interests: [
    { label: 'Select at least one interest',   validate: requireAtLeastOne() },
    { label: 'Interest contains invalid value', validate: subsetOf(INTEREST_OPTIONS) },
  ],
  terms: [{ label: 'Accept terms to continue', validate: requireAtLeastOne('You must accept the terms') }],
} satisfies FormErrorsConfig

export function PreferencesForm() {
  const formState = useFormState()

  return (
    <Form formState={formState} errorsConfig={errorsConfig} onSubmit={handleSubmit}>
      <Honeypot />
      <SelectInput
        id="country"
        label="Country"
        options={COUNTRY_OPTIONS}
        placeholder="Select a country"
        required
      />
      <RadioInput
        id="plan"
        legend="Billing plan"
        options={PLAN_OPTIONS}
        required
      />
      <CheckboxInput
        id="interests"
        legend="Interests"
        options={INTEREST_OPTIONS}
      />
      <CheckboxInput
        id="terms"
        label={<>I accept the <a href="/terms">terms of service</a></>}
        required
      />
      <Button type="submit">Continue</Button>
    </Form>
  )
}

Multi-page form

import {
  Form, Honeypot, TextInput, DateInput, SelectInput, Button,
  FormPageList, FormPage, PageNavButton,
  ProgressBar, ProgressState,
  ErrorList, useFormState,
} from '@webling/sform'
import type { FormErrorsConfig, OptionItem, FormPageListItemProps } from '@webling/sform'

const CONTACT_OPTIONS: OptionItem[] = [
  { label: 'Email', value: 'email' },
  { label: 'Phone', value: 'phone' },
]

const errorsConfig = {
  firstname: [{ label: 'First name is required', validate: (v) => (v ? null : 'Required') }],
  lastname:  [{ label: 'Last name is required',  validate: (v) => (v ? null : 'Required') }],
  email:     [{ label: 'Email is required',       validate: (v) => (v ? null : 'Required') }],
} satisfies FormErrorsConfig

function TabItem({ label, pageIndex, progressState, hasErrors, goToPage }: FormPageListItemProps) {
  return (
    <button
      onClick={goToPage}
      disabled={progressState === 'disabled'}
      aria-current={progressState === 'current' ? 'step' : undefined}
      data-state={progressState}
      data-errors={hasErrors || undefined}
    >
      {pageIndex + 1}. {label}
    </button>
  )
}

export function ApplicationForm() {
  const formState = useFormState()

  return (
    <Form formState={formState} errorsConfig={errorsConfig} onSubmit={handleSubmit}>
      <ProgressBar />
      <FormPageList renderItem={(props) => <TabItem key={props.pageId} {...props} />} />
      <Honeypot />

      <FormPage pageId="applicant" label="Applicant">
        <ErrorList heading="Fix these errors before continuing:" />
        <TextInput id="firstname" label="First name" value={formState.values.firstname as string} required />
        <TextInput id="lastname"  label="Last name"  value={formState.values.lastname  as string} required />
        <DateInput id="dob"       label="Date of birth" value={formState.values.dob    as string} />
        <PageNavButton direction="next" />
      </FormPage>

      <FormPage pageId="contact" label="Contact">
        <TextInput   id="email"   label="Email"  type="email" value={formState.values.email as string} required />
        <TextInput   id="phone"   label="Phone"  type="tel"   value={formState.values.phone as string} />
        <SelectInput
          id="contact-preference"
          label="Preferred contact"
          options={CONTACT_OPTIONS}
          value={formState.values['contact-preference'] as string}
        />
        <PageNavButton direction="previous" />
        <Button type="submit">Submit application</Button>
      </FormPage>
    </Form>
  )
}

Layout with sections, columns, and conditional content

import {
  Form, Honeypot, TextInput, RadioInput, CheckboxInput, Button,
  FormSection, FormColumns, ConditionalSection,
  useFormState, requireAtLeastOne, oneOf,
} from '@webling/sform'
import type { FormErrorsConfig, OptionItem } from '@webling/sform'

const EMPLOYMENT_OPTIONS: OptionItem[] = [
  { label: 'Employed',   value: 'employed'   },
  { label: 'Self-employed', value: 'self'    },
  { label: 'Unemployed', value: 'unemployed' },
]

const errorsConfig = {
  firstname:   [{ label: 'First name is required', validate: (v) => (v ? null : 'Required') }],
  lastname:    [{ label: 'Last name is required',  validate: (v) => (v ? null : 'Required') }],
  employment:  [{ label: 'Employment status is required', validate: oneOf(EMPLOYMENT_OPTIONS) }],
  'employer':  [{ label: 'Employer name is required',     validate: (v) => (v ? null : 'Required') }],
  terms:       [{ label: 'You must accept the terms',     validate: requireAtLeastOne() }],
} satisfies FormErrorsConfig

export function ApplicationForm() {
  const formState = useFormState()
  const isEmployed = ['employed', 'self'].includes(formState.values.employment as string)

  return (
    <Form formState={formState} errorsConfig={errorsConfig} onSubmit={handleSubmit}>
      <Honeypot />

      <FormSection heading="Personal details">
        <FormColumns columns={2}>
          <TextInput id="firstname" label="First name" required />
          <TextInput id="lastname"  label="Last name"  required />
        </FormColumns>
        <TextInput id="email" label="Email" type="email" required />
      </FormSection>

      <FormSection heading="Employment">
        <RadioInput id="employment" legend="Employment status" options={EMPLOYMENT_OPTIONS} required />
        <ConditionalSection visible={isEmployed}>
          <TextInput id="employer" label="Employer name" required />
        </ConditionalSection>
      </FormSection>

      <CheckboxInput id="terms" label="I accept the terms of service" required />
      <Button type="submit">Submit</Button>
    </Form>
  )
}

Known limitations

Password managers

sform detects native browser autofill via a CSS animationstart technique. The browser applies :-webkit-autofill when it fills a field, which triggers a known animation name that input components listen for. This marks the field as trusted and unblocks submission.

Password managers (1Password, Bitwarden, Dashlane, and others) fill fields through their own mechanisms rather than native browser autofill. :-webkit-autofill is not applied, so the animation signal does not fire. Depending on the password manager and browser combination, the resulting change events may have isTrusted: false.

Concretely: if a user relies exclusively on a password manager and never otherwise interacts with a field (no focus, no manual keystroke), that field will not have a completed interaction audit entry and the submission will be silently blocked.

In practice most users trigger a focus event before or after a password manager fills a field (clicking into the field, tabbing away), which is sufficient to unblock submission. However, this is not guaranteed. The recommended mitigation is to surface a fallback in your onSubmit handler when security.hasUntrustedEvents is true. For example, a secondary verification step or a support prompt. Rather than silently discarding the submission.

HTML5 constraint validation is disabled

<Form> renders with noValidate, which disables the browser's built-in constraint validation API entirely. This means the following HTML attributes provide UX affordances only. They do not validate:

| Attribute | What it still does | What it does NOT do | |---|---|---| | type="email" | Mobile keyboard, autofill hint | Reject "sometext" (no @) | | type="url" | Mobile keyboard, autofill hint | Reject "notaurl" | | required | Autofill hint, accessibility semantics | Block empty submission | | pattern, min, max, minLength, maxLength | None | Enforce any constraint |

All format and presence validation must be declared explicitly in errorsConfig. noValidate is set intentionally. Without it, the browser's validation UI would conflict with sform's own error display system.

Password fields and keystroke timing

PasswordInput intentionally skips the keystroke timing check (useEntryDuration). Password managers fill password fields programmatically at machine speed, which would trip the sub-50ms threshold and silently block submission for legitimate users.

The trade-off: a bot that types a password at machine speed will not be caught by the timing check on that field. The honeypot, interaction completion, and option encryption checks still apply to all other fields. Password field security should rely on server-side rate limiting and account lockout rather than client-side timing heuristics.

Do not use <TextInput type="password">. It includes keystroke timing and will block password manager users.

AddressInput sub-fields

AddressInput sub-fields (${id}-street, ${id}-city, etc.) are covered by the interaction completion check and autofill detection via a shared fieldset-level useInteractionCheck. When the user tabs or clicks out of the address group entirely, all six sub-field IDs are reported as having a completed interaction. Autofill is detected per sub-field via animationstart and all sub-fields are immediately marked as trusted.

useEntryDuration (keystroke timing) is not applied to address sub-fields. They are plain text inputs where machine-speed fill via autofill is expected and legitimate. Apply server-side validation to address data for any constraints beyond presence.

Test environments

jsdom does not implement CSS animations, so animationstart events for :-webkit-autofill are never fired in Vitest or Jest test suites. Autofill detection cannot be covered by unit tests in these environments. Test autofill behaviour in a real browser using integration or end-to-end tests.


Contributions

Bug reports and pull requests are welcome. Please open an issue first to discuss any significant changes.

  1. Fork the repository
  2. Create a feature branch (git checkout -b feature/my-feature)
  3. Commit your changes with a descriptive message
  4. Push and open a pull request

Testing requirements

All pull requests must include tests. The project uses Vitest + React Testing Library.

pnpm test             # run all tests once
pnpm test:watch       # watch mode
pnpm test:coverage    # coverage report
pnpm typecheck        # type-check without running tests

Requirements for new tests:

  • Co-locate test files with source (src/hooks/useMyHook.test.ts, src/ui/control/MyInput.test.tsx).
  • Use real providers. Wrap components in <Form> or <UserActionsProvider> via helpers in test/helpers/renderWithProviders.tsx. Never mock own context modules.
  • No trivially-passing tests. Every test must assert a value that would be wrong if the implementation were broken. Specifically:
    • "Should not throw" tests must use expect(() => { ... }).not.toThrow(), not bare act(...) calls with no assertion.
    • For precedence/fallback tests, include both the positive case (A overrides B) and the inverse (A present but falsy → B must not leak through).
    • Avoid typeof assertions (TypeScript enforces these statically; they add no runtime coverage).
  • Security invariants must be explicitly covered:
    • decryptTrusted(token, false) must return null. No exceptions.
    • isSubmissionSafe must return false on honeypot fill, suspicious activity, or any valued field without a completed interaction.
    • No real option value may appear as a DOM value attribute in any encrypted-option test.
  • Mocking rules:
    • isTrusted: use plain object mocks { nativeEvent: { isTrusted } }, not Object.defineProperty on real Event instances (jsdom marks it non-configurable).
    • crypto.randomUUID: vi.spyOn in beforeEach; vi.restoreAllMocks() in afterEach.
    • Timers: vi.useFakeTimers() with vi.restoreAllMocks() after.

License

MIT