formatir

Privacy-first behaviour analytics for web forms: rage clicks, dead clicks, hesitation, field effort, drop-off and client errors in ~5 KB, with React, Vue and Svelte bindings and a collector.
206
Install
npm install formatir
Latest Version:0.2.0
Vue:3
License:MIT
Last Updated:Oct 21, 2026
Links: GitHub  ·  npm
Maintainer: yanisfer

Formatir

npm bundle size dependencies license

Privacy-first behaviour analytics for web forms and application processes. Zero dependencies, ~5.5 KB gzipped, works with plain HTML, React, Vue, Svelte and SPAs.

Formatir answers the question analytics funnels cannot: why people struggle in your form. It watches interaction behaviour - not content - and turns it into actionable metrics plus a frustration score.

npm i formatir      # pnpm add formatir / yarn add formatir

Contents

Quickstart

import { Formatir } from 'formatir';

const formatir = Formatir.init({
  endpoint: '/api/formatir',      // omit to stay fully client-side
  appId: 'careers-portal',
  root: '#application-form',      // observe this subtree only
});

formatir.on('rage_click', (event) => {
  console.warn('rage click', event.data.selector, event.data.count);
});

formatir.on('*', (event) => queueMicrotask(() => render(event)));

Without a bundler:

<script src="https://unpkg.com/formatir/dist/index.global.js"></script>
<script src="https://unpkg.com/formatir/dist/extras.global.js"></script>
<script>
  var formatir = Formatir.init({
    endpoint: '/api/formatir',
    plugins: [FormatirExtras.paste(), FormatirExtras.thrash()],
  });
</script>

Detectors

Metric Fires when Payload highlights
rage_click >= 3 clicks within 500 ms on the same element or within a 32 px radius count, withinMs, selector, x, y
dead_click Click on a static element with no DOM mutation, navigation or focus change selector, waitedMs
hesitation Focus -> first keystroke takes > 3000 ms, or the field is left untouched ms, threshold, abandoned
field_effort A field is left after real work happened keystrokes, corrections, refills, ...
validation_error Constraint validation fails (or reportValidationError() is called) reason (valueMissing, typeMismatch)
form_dropoff The page is hidden or unloaded without a submit lastField, progress, durationMs
js_error Uncaught error or unhandled rejection during the form session message (redacted), source, line

Lifecycle events round out the stream: session_start, field_focus, field_blur, form_submit, score and custom.

By default js_error reports every uncaught error during the session. With errorScope: 'root' it only reports errors that happen while focus is inside root or within 2 s of a click, input, key press, focus or submit there, so a broken chat widget elsewhere on the page does not count against your form.

Field references carry key, type, step and - when the field sits in a <form> - form (data-formatir-form, id or name), so several forms on one page stay apart. Fields added after init() and fields inside open shadow roots are tracked as well.

Extras (formatir/extras)

Opt-in detectors that live outside the core bundle (1.4 KB gzipped together, tree-shakeable):

import { autofill, funnel, paste, revisits, thrash } from 'formatir/extras';

Formatir.init({ plugins: [paste(), autofill(), revisits(), funnel(), thrash()] });
Plugin Metric Fires when Payload
paste() paste Content is pasted into a field; flags pages that block pasting pastes, delta (length), blocked
autofill() autofill The browser autofills fields (one event per burst) count, fields
revisits() field_revisit The user returns to a field after moving on to another one visits, length, changed
funnel() step_change setStep() changes the step; detects going back from, to, durationMs, back, visits
thrash() thrash Rapid back-and-forth pointer movement (tunable) reversals, withinMs

The clipboard is never read: paste only reports how much the value length changed.

Adding the plugins

Each export is a factory: call it (paste(), not paste) and pass the result in plugins. Pick any subset - unused plugins are dropped from your bundle. Plugins attach and detach together with the instance (start(), stop(), destroy()), and their events behave like the core ones: they are typed in on(), count towards the score and go out in the same batches.

The same plugins option works everywhere options are accepted:

// React
<FormatirProvider endpoint="/api/formatir" plugins={[paste(), funnel()]}>

// Vue
app.use(FormatirPlugin, { endpoint: '/api/formatir', plugins: [paste(), funnel()] });

// Svelte
<form use:formatir={{ endpoint: '/api/formatir', plugins: [paste(), funnel()] }}>
<!-- Without a bundler: the second script exposes window.FormatirExtras -->
<script src="https://unpkg.com/formatir/dist/index.global.js"></script>
<script src="https://unpkg.com/formatir/dist/extras.global.js"></script>
<script>
  Formatir.init({ plugins: [FormatirExtras.paste(), FormatirExtras.thrash()] });
</script>
funnel() needs setStep()

funnel() only reacts to step changes, so it emits nothing until your app reports them. Call setStep() whenever the user moves between steps of a multi-step form:

const formatir = Formatir.init({ plugins: [funnel()] });

formatir.setStep('personal');     // first step
// ... user clicks "Next"
formatir.setStep('experience');   // -> step_change { from: 'personal', to: 'experience', durationMs, back: false }
// ... user clicks "Back"
formatir.setStep('personal');     // -> step_change { ..., back: true } and a step_back score signal

Calling setStep() with the current step again does nothing.

thrash() options

thrash() works without options; tune it when your UI legitimately involves a lot of pointer movement (drag and drop, sliders):

thrash({
  reversals: 8,     // direction changes needed inside the window (default 6)
  windowMs: 1500,   // window in ms (default 1000)
  minMovePx: 6,     // shorter moves are jitter and ignored (default 4)
});

After a thrash event the detector stays quiet for 2 s, so one frantic burst is reported once.

paste(), autofill() and revisits() take no options.

Frustration score

Every weighted signal feeds a saturating score, 100 * (1 - e^(-raw/saturation)), so it approaches but never exceeds 100. A score event is emitted whenever the value changes; level is one of calm, mild, elevated, severe.

Signal Weight Signal Weight
form_dropoff 20 refill 8
rage_click 18 thrash 8
js_error 14 hesitation 6
dead_click 10 step_back 5
paste_blocked 10 field_revisit 3
validation_error 9 correction 0.4

Everything is tunable:

Formatir.init({
  score: {
    weights: { dead_click: 4, custom: 10 }, // merged over the defaults; 0 disables a signal
    saturation: 80,                         // default 55 - higher is more forgiving
    levels: [25, 50, 75],                   // mild / elevated / severe lower bounds
  },
});

Privacy by design

Formatir never reads what a user typed.

  • No values, no innerText, no clipboard, no keystroke content - only value.length, counters and time deltas.
  • Fields are identified by data-formatir-field, name or id; otherwise a structural fallback key is generated.
  • Click targets are described with structural selectors (tag / id / class / position), never with text content.
  • data-formatir-ignore on any element opts its whole subtree out (including shadow roots below it).
  • Browser validation messages are deliberately ignored (they can echo the entered value); only the ValidityState flag name is reported.
  • js_error messages are scrubbed of e-mail addresses and long digit runs (phone, IBAN, card numbers), and query strings are stripped from script URLs. Disable with redactErrors: false.
  • beforeSend is the last stop before the network - rewrite or drop anything:
Formatir.init({
  beforeSend(event) {
    if (event.type === 'custom' && event.data.name === 'debug') return null; // drop
    if (event.type === 'session_start') {
      return { ...event, data: { ...event.data, url: location.pathname } };   // rewrite
    }
    return event;
  },
});

Local listeners (on(), onEvent) still receive the original events.

Consent

const formatir = Formatir.init({ endpoint: '/api/formatir', autoStart: false });

consentBanner.onAccept(() => formatir.optIn());   // start + forget an earlier opt-out
consentBanner.onReject(() => formatir.optOut());  // stop, drop queued data, remember it
  • autoStart: false creates the instance without attaching any listener.
  • start() / stop() attach and detach the detectors; the session continues.
  • optOut() stores formatir:optout in localStorage. While it is set, start() and autoStart do nothing until optIn() is called.

API

const formatir = Formatir.init(options);  // the page-wide instance (replaces a previous one)
const other = Formatir.create(options);   // an independent instance you destroy yourself
Formatir.getInstance();                   // the instance from the last init()

formatir.on(metric, listener);      // returns an unsubscribe function
formatir.off(metric, listener);
formatir.track('cv_uploaded', { sizeKb: 812 });
formatir.setStep('experience');     // tag events with a wizard step
formatir.reportValidationError('email', 'typeMismatch');
formatir.markSubmitted();
formatir.reportDropoff();           // e.g. on an SPA route change
formatir.getSnapshot();             // score, per-field stats, queue size
formatir.start(); formatir.stop();  // attach / detach the detectors
formatir.optIn(); formatir.optOut();
formatir.active;                    // true while collecting
formatir.flush();
formatir.destroy();

reportValidationError('email') looks the name up as data-formatir-field, name or id inside the root, so the event carries the real field type and form when the field is rendered.

getSnapshot() returns sessionId, startedAt, durationMs, step, score, level, signals, fields (per-field FieldStats), events (emitted so far), queued (waiting for the next flush) and submitted.

Also exported: VERSION, OPT_OUT_KEY ('formatir:optout') and OUTBOX_KEY ('formatir:outbox') - the localStorage keys, e.g. for a privacy page that lists stored data - plus all public types (FormatirEvent, MetricName, MetricPayloads, FormatirOptions, ScoreOptions, Plugin, ...).

Options

Option Default Description
endpoint - Collector URL. Without it Formatir stays local.
appId - Sent with every batch.
root document Selector or element to observe.
sessionId generated Reuse an existing id.
autoStart true false waits for start() / optIn().
batchSize 20 Events per batch before an automatic flush.
flushIntervalMs 5000 Periodic flush; 0 disables it.
sampleRate 1 Fraction of sessions recorded.
transport 'beacon' beacon | fetch | none.
offline true Keep undelivered batches in localStorage and retry them.
ignoreSelector '[data-formatir-ignore]' Opt-out selector.
thresholds see detectors rageClicks, rageWindowMs, rageRadiusPx, deadClickMs, hesitationMs.
score see above weights, saturation, levels.
plugins [] Extra detectors, e.g. from formatir/extras.
errorScope 'page' 'root' reports only errors that happen while the user works in root.
redactErrors true Scrub PII from js_error payloads.
beforeSend - Rewrite (return the event) or drop (null / false) before sending.
onEvent - Convenience hook for every event.
debug false Log every event to the console.

Transport

Batches are sent with navigator.sendBeacon and fall back to fetch(..., { keepalive: true }). Flushes happen on batchSize, on the flush interval, on visibilitychange (hidden) and on pagehide, so the final drop-off event survives the unload.

With offline (default), a batch that cannot be delivered - the browser is offline, the request fails, or the collector answers 429 / 5xx - is kept in localStorage (formatir:outbox, at most 20 batches) and resent on the next start() or when the browser fires online. optOut() wipes the outbox. Batches rejected with 4xx are dropped. Beacons cannot report failures, so retries apply to the fetch path and to batches parked while offline.

// POST body
{
  "sdk": "formatir",
  "v": "0.2.0",
  "appId": "careers-portal",
  "sessionId": "fmt_1a2b3c4d0",
  "sentAt": 1737050400000,
  "reason": "pagehide",
  "events": [{ "id": "…", "type": "hesitation", "t": 8421, "field": { "key": "salary", "form": "apply" }, "data": { "ms": 5310 } }]
}

Sending to another origin? Prefer a same-origin endpoint (a reverse proxy route) or transport: 'fetch': a beacon with a JSON body triggers a CORS preflight with credentials, which formatir/server supports via cors.

Framework bindings

Each binding is a separate entry point with an optional peer dependency.

React - formatir/react

import { FormatirProvider, useFormatir, useFormatirEvent, useFormatirSnapshot } from 'formatir/react';

<FormatirProvider endpoint="/api/formatir" root="#application-form">
  <ApplicationForm />
</FormatirProvider>;

function ApplicationForm() {
  const formatir = useFormatir();                       // null until mounted
  const snapshot = useFormatirSnapshot(1000);           // live score and field stats
  useFormatirEvent('form_dropoff', (e) => console.warn(e.data.lastField));
  // ...
}

useFormatirInstance(options) creates a component-scoped instance without a provider; root also accepts a ref. Instances are created after mount and destroyed on unmount, so StrictMode double-mounts never double-count.

Vue - formatir/vue

import { FormatirPlugin, useFormatir, useFormatirEvent, useFormatirInstance } from 'formatir/vue';

createApp(App).use(FormatirPlugin, { endpoint: '/api/formatir' }).mount('#app');

// in setup():
const formatir = useFormatir();                         // ShallowRef<FormatirInstance | null>
useFormatirEvent('rage_click', (e) => console.warn(e.data.selector));

// or a component-scoped instance on a template ref:
const form = ref<HTMLFormElement>();
const scoped = useFormatirInstance({ root: form });

Svelte - formatir/svelte

<script>
  import { formatir } from 'formatir/svelte';
  let tracker;
</script>

<form use:formatir={{ endpoint: '/api/formatir', onInstance: (f) => (tracker = f) }}>

The action has no dependency on Svelte and works as a SolidJS use: directive too.

SPA routing

Formatir cannot see a client-side route change, so tell it about one:

router.beforeEach(() => formatir.reportDropoff());

Validation adapters

Framework validation never reaches the browser's constraint validation, so report it explicitly. Both adapters send only the error type or code, never the message.

React Hook Form - formatir/react-hook-form

import { createErrorReporter, formatirOnInvalid } from 'formatir/react-hook-form';

// On submit:
<form onSubmit={handleSubmit(onValid, formatirOnInvalid(formatir))}>

// Or continuously (mode: 'onBlur' / 'onChange') - reports new or changed errors only:
const report = useMemo(() => createErrorReporter(formatir), [formatir]);
useEffect(() => report(formState.errors), [report, formState.errors, formState.submitCount]);

Nested objects and field arrays use RHF's dot paths (jobs.1.title), which match the name attributes RHF renders.

Zod - formatir/zod

import { reportZodError, withFormatir } from 'formatir/zod';

const schema = withFormatir(formatir, ApplicationSchema);
const result = schema.safeParse(values);   // failures are reported automatically

// or by hand:
if (!result.success) reportZodError(formatir, result.error);

Collector - formatir/server

A runtime-agnostic receiving end built on the Fetch API (Node >= 18, Deno, Bun, Cloudflare Workers, Vercel, Netlify, Hono).

import { createHandler } from 'formatir/server';

const handler = createHandler({
  onBatch: async (batch) => db.insert('events', batch.events),
  appIds: ['careers-portal'],          // optional allowlist
  cors: ['https://careers.example'],   // optional; default same-origin only
  maxBytes: 256 * 1024,                // default
});

// Cloudflare Workers / Deno / Bun
export default { fetch: handler };

// Hono
app.post('/api/formatir', (c) => handler(c.req.raw));

// Express / node:http - mount before any body parser
import { toNodeListener } from 'formatir/server';
app.post('/api/formatir', toNodeListener(handler));

Responses: 204 stored, 400 invalid batch, 403 origin or app not allowed, 405 wrong method, 413 too large, 500 when onBatch throws - which makes the SDK keep the batch and retry it later.

parseBatch(body) validates a batch on its own, and the JSON schema ships with the package as formatir/schema.json for other languages and pipelines.

Writing plugins

A plugin is a function that receives the same context the built-in detectors use. Listeners registered through ctx.on() and timers from ctx.timer() are cleaned up by stop() and destroy().

import type { Plugin } from 'formatir';

const tooltipOpened: Plugin = (ctx) => {
  ctx.on(document, 'toggle', (e) => {
    const el = e.target as Element;
    if (ctx.watches(el) && el.matches('[data-help]')) ctx.emit('custom', { name: 'help_opened' });
  }, true);
};

Formatir.init({ plugins: [tooltipOpened] });

The context also offers ctx.state (session state), ctx.stats(field) (per field counters), ctx.signal(name) (feed the score without an event) and ctx.onStep(fn).

Requirements

  • Browsers: any evergreen browser. The bundles target ES2019; storage access is wrapped, so private mode or blocked localStorage only disables the opt-out memory and the offline outbox. autofill() relies on the :autofill / :-webkit-autofill pseudo-classes.
  • SSR: importing is safe on the server; init() without a document returns a disabled instance (enabled: false). The React and Vue bindings create instances only after mount.
  • formatir/server: any runtime with the Fetch API - Node >= 18, Deno, Bun, Cloudflare Workers.
  • Bindings: React >= 18, Vue >= 3.3 (app-level cleanup needs 3.5). The Svelte action and the validation adapters have no peer dependency.

Bundle size

Entry gzipped
formatir 5.5 KB
formatir/extras 1.4 KB
formatir/react 0.5 KB
formatir/vue 0.5 KB
formatir/svelte 0.2 KB
formatir/react-hook-form 0.5 KB
formatir/zod 0.3 KB
formatir/server 1.7 KB

pnpm build prints the sizes and fails above the budgets in scripts/size.mjs (6 KB for the core, 2 KB for the extras).

Changelog

See CHANGELOG.md. Unreleased changes are collected as changesets in .changeset until the next release.

License

MIT

Related Packages

@permitojs/core

Privacy-first, framework-agnostic consent engine. No network calls, no tracking.

0 0
consentlayer

Consent-first analytics for the browser and server: one API in front of GA4, Met...

0 1
pagepal

A mascot that reacts to what is happening on your page, plus first-party UX anal...

766 0
@ciphera-net/pulse-nuxt

Add Pulse Analytics — privacy-first web analytics — to a Nuxt site with one modu...

505 0
@websideproject/nuxt-cookie-consent

GDPR cookie and cookieless-tracking consent for Nuxt 4: banner, floating button,...

451 1

Version History

Version Released Vue License
0.2.0 >=3.3 MIT
0.1.0 MIT