@webling/sform
v1.0.0
Published
A React component library for securing web forms against bots, scrapers, spammers, and programmatic submissions.
Maintainers
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
- Installation
- Quick start
- API reference
- Examples
- Known limitations
- Troubleshooting
- Contributions
- License
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
valueattributes 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
onSpamClickfor UX telemetry and double-submission is blocked via the handler, whilearia-disabledkeeps 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
FormDataon submit) - Opt-in controlled state via
useFormState(tracks values,isDirty, and supportsresetandcommit)
Layout & UX
- Multi-page form support with
FormPage,FormPageList, andPageNavButton - Visual progress bar (
ProgressBar) and step indicator (ProgressState) - Field grouping with
FormSection, multi-column layout withFormColumns, and conditional rendering withConditionalSection - Full TypeScript support with exported prop types
Installation
npm install @webling/sform
# or
pnpm add @webling/sformReact 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 resetNote:
commitreplaces the entire form state. Any key not present in the new baseline is removed. UsesetValuesinstead if you only want to merge new keys without affectingisDirtyorresetbehaviour.
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
requiredon individual checkboxes in a group. Usearia-requiredon the fieldset (added automatically) and enforce the constraint via aValidationFnwithrequireAtLeastOne.
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 unlessformStateis passed to<Form>. Passvalue={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
useFormStatepersist after unmount. PassonUnmountto clear them if needed. For example, to avoid stale data being submitted orisDirtyremaining 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.
- Fork the repository
- Create a feature branch (
git checkout -b feature/my-feature) - Commit your changes with a descriptive message
- 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 testsRequirements 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 intest/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 bareact(...)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
typeofassertions (TypeScript enforces these statically; they add no runtime coverage).
- "Should not throw" tests must use
- Security invariants must be explicitly covered:
decryptTrusted(token, false)must returnnull. No exceptions.isSubmissionSafemust returnfalseon honeypot fill, suspicious activity, or any valued field without a completed interaction.- No real option value may appear as a DOM
valueattribute in any encrypted-option test.
- Mocking rules:
isTrusted: use plain object mocks{ nativeEvent: { isTrusted } }, notObject.definePropertyon real Event instances (jsdom marks it non-configurable).crypto.randomUUID:vi.spyOninbeforeEach;vi.restoreAllMocks()inafterEach.- Timers:
vi.useFakeTimers()withvi.restoreAllMocks()after.
License
MIT
