Your very own fruit flies that wander over a web page. They walk in runs and pauses, turn, and can be drawn toward the cursor or pushed away from it. The fly is a sprite rendered from NeuroMechFly, a biomechanical model of Drosophila melanogaster, replaying a recorded real walking stride, so the legs move in a true tripod gait without sliding.
Live demo: https://sungsushi.github.io/browserfly/.
One script tag, no dependencies, about 8 KB gzipped (plus a 28 KB sprite sheet, or one 37 KB file
with the sheet inlined). The overlay never blocks clicks, never scrolls or shifts the layout, and
respects prefers-reduced-motion.
Copy two files from dist/ next to your page, fly.min.js and fly-sheet.webp (add
fly-sheet.png as a fallback for old browsers), then add one tag:
<script src="fly.min.js" data-fly data-count="2" data-attraction="0.4" defer></script>The sprite sheet is found relative to the script's own URL, so this works on GitHub Pages project
sites served under /<repo>/ too.
fly.inline.min.js has the sprite sheet embedded, so no image file is needed:
<script src="fly.inline.min.js" data-fly defer></script><script src="fly.min.js" defer></script>
<script defer>
const swarm = Fly.create({ count: 3, attraction: -0.5 });
// swarm.set({ attraction: 0.8 }); swarm.pause(); swarm.resume(); swarm.destroy();
</script>Instead of the whole viewport, the flies can live inside one element. The canvas is added to the
element (which is made position: relative if it is static):
Fly.create({ container: document.querySelector('#hero'), count: 1 });By default (anchor: 'page') the flies live on the page: they keep their place on the content and
scroll away with it, and the ones you've scrolled past keep walking out of sight. New flies appear in
the part of the page that's visible, then roam the whole page. With anchor: 'viewport' they stay on
screen instead, while the page scrolls under them:
<script src="fly.inline.min.js" data-fly data-anchor="viewport" defer></script>Only ordinary vertical scrolling of the document is supported (not horizontal scrolling or
scrolling inside other elements). The canvas stays the size of the window, so long pages cost
nothing extra. With a container, anchor is ignored: a container already scrolls with its content.
fly.esm.js is an ES module build exporting create, version, instances and schema. A
module has no currentScript, so pass sprite as a URL (e.g.
new URL('fly-sheet.webp', import.meta.url).href), or the flies are drawn procedurally.
| File | Size (gzip) | Use |
|---|---|---|
fly.min.js |
7.95 KB | the library, minified; loads the sprite sheet next to it |
fly-sheet.webp |
27.6 KB (not gzipped) | the sprite sheet |
fly-sheet.png |
32.1 KB (not gzipped) | fallback if WebP can't be decoded |
fly.inline.min.js |
37.2 KB | the library with the sheet inlined: the only file you need |
fly.js |
12.2 KB | readable debug build (extra checks, debug-overlay labels; used by the demo) |
fly.esm.js |
10.8 KB | ES module build |
fly-sheet.json |
0.6 KB | sheet metadata (already embedded in every bundle) |
Options can be passed to Fly.create({...}), changed live with swarm.set({...}), or given as
data attributes on the auto-init script tag (kebab-case: data-run-mean="3"). Out-of-range values
are clamped and unknown options are ignored, each with one console warning. BL is the fly's body
length (size).
| Option | Data attribute | Default | Values | Description |
|---|---|---|---|---|
count |
data-count |
1 |
integer, 1 – 50 | number of flies |
size |
data-size |
25 |
8 – 200 | rendered body length (px) |
speed |
data-speed |
1.2 |
0.1 – 20 | mean run speed (BL/s); real flies walk ~6, so the default is slow motion |
speedJitter |
data-speed-jitter |
0.15 |
0 – 0.5 | SD of the per-run speed multiplier |
runMean |
data-run-mean |
2.5 |
0.1 – 60 | mean run duration with no cursor influence (s) |
stopProbability |
data-stop-probability |
0.09 |
0 – 1 | chance that a run ends in a stop rather than an immediate reorientation |
stopMean |
data-stop-mean |
1.2 |
0.05 – 30 | mean stop duration (s) |
stopDistribution |
data-stop-distribution |
'lognormal' |
'exp', 'lognormal' |
stop duration distribution |
rotDiffusion |
data-rot-diffusion |
0.5 |
0 – 10 | long-run heading diffusion during runs (rad²/s) |
headingTau |
data-heading-tau |
0.3 |
0.02 – 2 | correlation time of the heading noise (s) |
turnRate |
data-turn-rate |
5 |
0.5 – 30 | max angular speed while reorienting (rad/s) |
attraction |
data-attraction |
0.1 |
-1 – 1 | cursor attraction; negative means repulsion |
attractionRange |
data-attraction-range |
219 |
10 – 5000 | cursor falloff length scale (px) |
steeringGain |
data-steering-gain |
3 |
0 – 20 | strength of continuous steering during runs (rad/s) |
kappaMax |
data-kappa-max |
6 |
0 – 50 | von Mises concentration of reorientation at full attraction |
klinokinesis |
data-klinokinesis |
0.5 |
0 – 0.9 | modulation of the run-ending hazard by heading |
arrivalRadius |
data-arrival-radius |
1.5 |
0 – 10 | attracted flies stop within this distance of the cursor (BL; 0 disables) |
lingerMax |
data-linger-max |
6 |
0 – 60 | max time to linger at the cursor (s) |
wallMargin |
data-wall-margin |
1.5 |
0 – 5 | soft wall-avoidance zone (BL) |
wallGain |
data-wall-gain |
6 |
0 – 30 | wall steering strength (rad/s) |
container |
— | null |
Element, CSS selector or null |
Element or CSS selector; null means full-viewport overlay |
zIndex |
data-z-index |
2147483000 |
integer, -2147483648 – 2147483647 | overlay z-index |
seed |
data-seed |
null |
integer, 0 – 4294967295, or null |
uint32 seed; null means random |
reducedMotion |
data-reduced-motion |
'static' |
'static', 'hide', 'ignore' |
behaviour when prefers-reduced-motion: reduce |
sprite |
data-sprite |
'auto' |
'auto', 'procedural' or a URL |
'auto', 'procedural' or a sprite sheet URL |
touchTargetTimeout |
data-touch-target-timeout |
5 |
0 – 30 | how long a tap acts as the cursor on touch devices (s) |
debug |
data-debug |
false |
true, false |
draw the debug overlay |
anchor |
data-anchor |
'page' |
'viewport', 'page' |
'page': flies live on the page and scroll with it; 'viewport': flies stay on screen while the page scrolls (ignored with a container) |
The default walking speed is slow motion on purpose: a real fly walks about 6 body lengths per second with its legs stepping at about 11 Hz, which is too fast to see individual legs on a 60 Hz screen.
Fly.create(options?) → Swarm
Fly.version: string
Fly.instances: Swarm[] // the auto-init swarm is Fly.instances[0]
Fly.schema // the option schema (defaults, ranges)
swarm.set(partialOptions) // live update; count changes add or remove flies; a new seed restarts
swarm.pause() / swarm.resume()
swarm.destroy() // removes the canvas, listeners and observers; safe to call twice
swarm.debug() → { flies, cursor, bounds, anchor, scroll, fps, renderer, sheetUrl, params, running } // read-only snapshotEach fly alternates runs, stops and reorientations. During a run its heading drifts smoothly and
is steered toward an attractive cursor (or away from a repulsive one); runs heading toward an
attractive cursor last longer, and new headings are biased toward it. Attracted flies stop at the
cursor, linger for up to lingerMax seconds, then wander off. Soft walls keep them on screen. The
behaviour engine is pure and deterministic given seed, and is verified by statistical unit tests
(PLAN.md §4).
The animation frame follows the distance walked (one sprite stride per strideBL body lengths), so
the feet don't slide at any speed. The animation runs on one requestAnimationFrame loop, only
while the page is visible (and, in container mode, while the container is on screen).
- The canvas is decorative:
aria-hidden="true"androle="presentation",pointer-events: none, never focusable, and it never captures or prevents pointer events. prefers-reduced-motion: reduceis respected, and live changes to it are followed.reducedMotion: 'static'(default) shows the flies standing still,'hide'shows nothing, and'ignore'animates anyway.- On touch screens, a tap acts as the cursor for
touchTargetTimeoutseconds.
Current Chrome, Edge, Firefox and Safari. The code targets ES2020 (Chrome 80+, Firefox 74+,
Safari 13.1+). WebP needs Safari 14+; older browsers use fly-sheet.png, and if no sheet can be
loaded the flies are drawn procedurally (with one console warning). Tested with Playwright on
Chromium, Firefox and WebKit, including touch emulation, 2× pixel density and reduced motion.
npm install
npx playwright install # browsers for the e2e tests
npm run build # dist/ bundles; prints gzip sizes against the budgets
npm test # unit tests (vitest), including the statistical engine tests
npm run test:sweep # statistical tests under 20 different base seeds
npm run test:e2e # end-to-end tests in Chromium, Firefox and WebKit
npm run lint
npm run docs # regenerate the option table above from src/params.js
npm run serve # http://localhost:8080/ (forwards to demo/) — demo with a live parameter panelThe demo page (demo/index.html) has sliders for every option, renderer and anchor toggles, a seed
box, a debug overlay and a box listing the options you've changed from the defaults. The debug
overlay's per-fly text labels are only drawn by the debug build (fly.js); the minified builds draw
the overlay without them. In the demo the quotation's text wraps around the flies
(demo/textwrap.js, demo-only, not part of the library): it re-wraps as each fly moves, settled so
a paragraph is only re-laid out after a fly has moved a few pixels. ?wrap=off (or the panel)
leaves layout to the browser.
PLAN.md is the full specification and PROGRESS.md the project log.
The sheet is built offline by a Python pipeline in pipeline/ (see its
README for details and FlyGym API notes). It needs Python 3.12 and about
1 GB of disk for the virtual environment. CI does not run it; its outputs in dist/ are committed.
uv venv -p python3.12 pipeline/.venv
uv pip install -p pipeline/.venv/bin/python -r pipeline/requirements.txt
pipeline/.venv/bin/python pipeline/build_all.py pipeline/out/build --dist # ~30 s
pipeline/.venv/bin/python pipeline/contact_sheet.py final dist/fly-sheet.json pipeline/out/qa
pipeline/.venv/bin/python -m pytest pipeline/tests
npm run build # re-embed the new sheet in the bundlesThe pipeline replays a recorded walking stride in NeuroMechFly (FlyGym 2.1.0, MuJoCo 3.9.0),
renders it from directly overhead with an orthographic camera, picks one seamlessly looping stride,
aligns the frames head-up around the thorax, and packs a 16-frame walk and a 2-frame idle clip
into a 64-colour sheet. The wings are a translucent grey-blue so they show on white pages
(--wing-rgba changes it). The build is deterministic.
The sprite is rendered from the NeuroMechFly body model through FlyGym, developed at the
Neuroengineering Laboratory (Ramdya lab), EPFL, and released under the
Apache License 2.0. The walking
kinematics are the recorded clip that ships with FlyGym (flygym_demo.spotlight_data), captured with
the Spotlight system, with 3D pose from PoseForge and joint angles from SeqIKPy. The sprite sheet
(dist/fly-sheet.*) is a rendering of that model and data; keep this attribution when you
redistribute it.
If you use this in research, please cite the NeuroMechFly papers, as FlyGym asks:
- V. Lobato-Rios, S. T. Ramalingasetty, P. G. Özdil, J. Arreguit, A. J. Ijspeert and P. Ramdya. NeuroMechFly, a neuromechanical model of adult Drosophila melanogaster. Nature Methods 19, 620–627 (2022). doi:10.1038/s41592-022-01466-7
- S. Wang-Chen, V. A. Stimpfling, T. K. C. Lam, P. G. Özdil, L. Genoud, F. Hurtak and P. Ramdya. NeuroMechFly v2: simulating embodied sensorimotor control in adult Drosophila. Nature Methods 21, 2353–2362 (2024). doi:10.1038/s41592-024-02497-y
and, for the walking data, Wang-Chen et al. (2026), bioRxiv 10.64898/2026.03.11.711180; for the inverse kinematics, Özdil et al. (2026), JOSS, 10.21105/joss.08557.
browserfly is licensed under the Apache License 2.0, the same licence as FlyGym and NeuroMechFly. See NOTICE for the attribution that must travel with copies of the sprite sheet and the bundles that embed it.