Human mouse and keyboard input for Playwright, with every timing constant measured from real human sessions instead of guessed.
npm install github:heydryft/human-inputimport { chromium } from 'playwright';
import { createHumanInput } from 'human-input';
const browser = await chromium.launch({ headless: false });
const page = await browser.newPage();
const human = createHumanInput(page);
await page.goto('https://example.com');
await human.click('#login');
await human.fill('#email', 'someone@example.com');
await human.press('Enter');Most input-simulation libraries model the shape of a mouse path — Bézier curves, overshoot, easing — and then use invented constants for everything else. The shape is the part detectors care least about.
This library was written the other way round. A probe page recorded real human sessions and the same page recorded a bot, on the same machine, in the same browser. The constants come from that comparison. Some of them are the opposite of what the intuition says:
| Measurement | Human | Naive bot | Note |
|---|---|---|---|
| Click landing offset | mean 8.1px, sd 15.0 | 0.0, sd 0.0 | Playwright clicks bbox centre, every time |
| Click dwell | med 100ms, p90 170 | mean 69ms, sd 39.8 | Right-skewed: tight centre, long tail |
| Key dwell | mean 83.7ms, sd 28.3 | — | |
| Key flight | mean 127.4ms, sd 89.4 | sd 34.3 | Typing needs more variance, not less |
| Pointer rate | 59–80 Hz | varies | |
| Fractional coordinates | ~100% | 0% in some stacks | Rounding coordinates is a tell |
| Sub-movements per stroke | med 3 | 30 with per-sample tremor | Noise added "to look organic" is the loudest tell |
| Peak-velocity position | 0.40 | 0.50 for any eased curve | Human velocity is asymmetric, not symmetric |
| Path / straight line | 1.07, p90 2.13 | 1.12, p90 1.14 | Synthetic strokes are all equally direct |
getCoalescedEvents() |
mean 1.01, max 7 | hard 1, no variance | Emergent from dispatch rate, not an API to spoof |
PointerEvent.pressure |
0 / 0.5 | 0 / 0.5 | Already correct — not a signal |
wheelDeltaY vs deltaY |
locked ratio (-3 macOS, -1.2 Windows) | any ratio | Naive scroll emits pairs no device can produce |
Scroll deltaY |
mean 7.1, sd 5.6, integers | 225.15, 288.24, … | Trackpads report many small integer deltas |
Two findings worth calling out because they are counter-intuitive:
- Click duration is right-skewed, not tight. An early 25-click sample put the standard deviation at 14ms, and this README used to say so. Two public datasets — 148,860 clicks from Balabit and 2,901 from an in-browser search study — agree that the real spread runs from roughly 70ms at p10 to 170ms at p90, with a tail past 300ms. The centre was right; the width was wrong, because 25 clicks from one person in one context cannot show a tail.
- Keystroke flight should be wide, not tight. Real typing has bursts and thinking pauses — a standard deviation near 90ms, with occasional gaps beyond half a second. Constant-rate typing is one of the oldest automation signals.
- Movement is ballistic-plus-corrections, not a curve. A stroke is a large opening thrust followed by a few homing corrections, each with an asymmetric velocity profile. That is why real velocity peaks at 40% of the way through rather than the midpoint, and why a stroke contains about three velocity peaks. Per-sample tremor produced thirty.
- Coalesced pointer samples are a rate artefact, not an API. Browsers merge
samples that arrive faster than they can paint, so real input occasionally
produces a
pointermovecarrying several. Injected input never does, because each dispatch gets its own frame. This library reproduces it by modelling the cause — a rare missed frame — so the coalescing is genuinely produced by the browser. Overcorrecting is its own tell: pipelining every move yields a mean near 1.8 against a human 1.01. - Scroll distance is not a free parameter.
wheelDeltaYis derived from the wheel tick count, not fromdeltaY, and a driver can only ever send one tick. So the only self-consistent scroll event is exactly one tick of pixels (40 on macOS); anything else reports adeltaY/wheelDeltaYpair that no mouse or trackpad can generate. Distance is built from whole ticks instead.
Where a large public dataset exists, the profile carries its empirical quantile curve rather than a fitted mean and standard deviation, because human timing is right-skewed — a floor near the motor limit, then a long tail of hesitation — and a gaussian cannot represent that shape.
| Source | What it provides | Sample |
|---|---|---|
| Balabit Mouse Dynamics | click dwell, wheel bursts | 148,860 clicks / 34,965 bursts |
| SERP mouse & eye movements | in-browser move, wheel, click | 564,356 moves / 17,851 bursts |
| KeyRecs | key dwell and flight | 553,680 free / 197,718 fixed |
| SERP, reconstructed | wheel deltas per device type | 14,890 wheel-mouse / 2,963 trackpad |
| Local browser capture | wheel deltas, coalescing, pressure | 174 wheel events |
Two of these are independent of each other and of the local capture, and they agree — which is what makes the corrections above trustworthy rather than one person's habits.
See CALIBRATION.md for the full method, the raw numbers, and how to re-measure on your own hardware.
A gradient-boosted classifier, 30 features per stroke, human folds split by participant so it scores on people it never saw. Two arbitrary halves of the real participants score 0.532 against each other - that is the floor.
| AUC | floor | |
|---|---|---|
corpus replay |
0.525 | 0.528 |
| Parametric model | 0.90 | 0.53 |
The parametric model matches every measured distribution individually - turn angle AUC 0.511, peak-velocity position 0.535 - and a classifier still separates it, because matching each marginal is not the same as matching the joint distribution. Replaying measured strokes does match it.
import corpus from 'human-input/data/strokes.json' with { type: 'json' };
import keystrokes from 'human-input/data/keystrokes.json' with { type: 'json' };
// Pass the same fingerprint id every session and the identity keeps its habits.
const human = new HumanInput(page, { corpus, keystrokes, persona: fingerprintId });Latency is not one distribution. Measured over 460,812 real transitions,
alternating hands runs 160ms, the same hand 180ms, the same finger on a
different key 186ms, and row distance adds monotonically on top - so the same
person types th in 131ms and <space>t in 222ms. Typing replays a real
person's recorded stream, matched on the transition being typed, with hold and
latency taken from the same keystroke.
That pairing matters for a reason that is easy to miss: 23% of real keystrokes overlap, meaning the next key goes down before the current one comes up. A loop that presses and releases each key in turn produces zero. Typing is scheduled on a timeline and emitted in time order, so overlap happens the way it does in real typing.
Real typing pace varies 3.6x between people - median latency runs from 86ms to 309ms. An identity that returns typing at a different speed every visit is describing someone who is not the same person, so drawing fresh from the population each session is itself a signal. A persona pins one recorded individual, their pointer pace and their click character to a seed; the same fingerprint always resolves to the same habits.
Strokes are drawn without replacement: a finite corpus repeats if you exhaust it, and repetition is itself detectable. The player binary-searches for the stroke recorded closest to the distance you need, because warping one away from its own scale is the only step that measurably degrades it - at 3,953 strokes the median warp is 0.1%. Where the corpus has nothing close enough, the parametric model fills in.
page is anything exposing Playwright's mouse, keyboard and locator
surface. Playwright is an optional peer dependency — the library depends on
structural types, not on the package.
interface HumanInputOptions {
profile?: InputProfile; // defaults to HUMAN
start?: { x: number; y: number };
seed?: number; // reproducible sessions
}| Method | Description |
|---|---|
moveTo(target) |
Move to a selector or {x, y} along a bowed path at 60–80Hz |
click(target, opts?) |
Move, settle, press, hold, release |
clickAndVerify(sel, isFocused, attempts?) |
Click and confirm focus landed — see below |
type(text) |
Type with measured dwell and flight timing |
fill(selector, text) |
Click a field, then type into it |
press(key) |
Single key with human hold time |
scroll(deltaY, steps?) |
Wheel increments with pauses |
position() |
Current tracked cursor position |
Some elements swallow the first click. An <iframe> takes focus itself, so the
first click focuses the frame and the second reaches the input. A
visually-hidden checkbox sits off-canvas at x: -9999 behind a styled label, so
coordinate clicks land nowhere at all.
When that happens, subsequent typing goes into whatever did have focus. In a checkout form that means an email address silently typed into a postcode field — the automation reports success and the data is corrupt.
const ok = await human.clickAndVerify(
'#card-number',
() => page.evaluate(() => document.activeElement?.id === 'card-number')
);
if (!ok) throw new Error('focus never landed');Verify focus before typing anything that matters.
HUMAN is the measured default. HUMAN_FAST keeps the same distribution shapes
with compressed timings — still inside human range, at the quick end.
Supply your own by implementing InputProfile. Offsets are stored as a
fraction of element size, not pixels, so the spread scales correctly on both
a 20px checkbox and a 350px button.
import { HUMAN, createHumanInput } from 'human-input';
const deliberate = { ...HUMAN, name: 'deliberate',
clickSettle: { mean: 260, sd: 90, min: 80, max: 600 } };
const human = createHumanInput(page, { profile: deliberate, seed: 42 });Pass a seed and the session replays identically — same path, same jitter, same
timings. Useful for debugging a failing flow without the input changing
underneath you.
Honest limitations, because they affect whether this is the right tool:
getCoalescedEvents()returns 1. A physical mouse polls faster than the browser's frame rate, so several samples get merged into onepointermoveand the coalesced list holds them all. Injected events produce exactly one, always. Measured human sessions reached 7. This is a property of where the event enters the stack, and no amount of path or timing work changes it — only OS-level injection (CGEventPost,uinput,SendInput) does.PointerEvent.pressureis always 0. Measured human sessions showed non-zero pressure on ~1% of moves.- Some browser builds quantise coordinates to integers regardless of what you pass in. Measure before assuming yours does not.
If you need those, this library is not sufficient on its own.
The measured profile came from one person on one machine. Yours will differ.
harness/probe.html records both a human and a bot session and prints
comparable statistics — open it, interact, and read the report.
See CALIBRATION.md.
The bundled stroke corpus (data/strokes.json) is derived from A Versatile
Dataset of Mouse and Eye Movements on Search Engine Results Pages by Kayhan
Latifzadeh, Jacek Gwizdka and Luis A. Leiva, used under
CC BY 4.0 and reshaped into
normalised stroke geometry. Original: https://zenodo.org/records/15236546
Calibration also draws on the Balabit Mouse Dynamics Challenge and KeyRecs.
MIT