Emerging Interfaces

Time-of-Flight Tutorial: Millimetre Distance Sensing for Hover, Proximity and Mid-Air Gesture

The VL53L1X measures how long a laser pulse takes to come back, works in the dark, ignores surface colour, and gives you a usable interaction zone out to four metres — if you set the timing budget and the ROI properly.

If you want an installation to notice someone approaching, a surface to respond before it is touched, or a hand waved in mid-air to control something, the cheap options are mostly bad. Ultrasonic sensors are slow, wide and confused by soft materials. PIR detects motion and not distance. A camera is overkill, needs light, and raises questions you may not want to answer in a gallery.

A time-of-flight sensor sidesteps all of that. The VL53L1X emits an invisible 940 nm laser pulse and measures how long the photons take to return, giving a distance in millimetres out to about four metres.

Robojax on wiring and reading the VL53L1X from an Arduino.

Why time-of-flight rather than the alternatives

Worth being precise, because the differences decide whether a piece works.

It measures time, not intensity. This is the key property. An infrared proximity sensor measures how much light came back, which depends on the target’s colour and reflectivity — a black jumper reads as further away than a white shirt at the same distance. A ToF sensor times the pulse, so a dark surface and a light surface at the same distance read the same distance. For anything involving people and clothing, that distinction is the whole game.

It works in complete darkness and is robust in ordinary room lighting. There is ambient-light interference in bright sunlight, which the sensor reports rather than hides.

It is narrow. The default field of view is around 27°, and — crucially — programmable. An ultrasonic sensor sprays a wide cone and returns whatever is closest, including the wall.

It is fast. Up to 50 Hz, which is comfortably enough for gesture.

Step 1 — Wiring

I²C, 2.8V logic on the sensor with level shifting and regulation on every reasonable breakout, so 3.3V or 5V microcontrollers are both fine.

VIN  → 3V3 (or 5V on a regulated breakout)
GND  → GND
SDA  → SDA
SCL  → SCL
XSHUT → any GPIO   (optional, needed for multiple sensors)
GPIO1 → any GPIO   (optional interrupt output)

Default I²C address is 0x29, and it is the same on every VL53L1X — which is the one fact that shapes multi-sensor designs. See step 6.

Step 2 — The simplest working read

Using Pololu’s library, which is the most commonly used and the thinnest wrapper over ST’s API:

#include <Wire.h>
#include <VL53L1X.h>

VL53L1X sensor;

void setup() {
  Serial.begin(115200);
  Wire.begin();
  Wire.setClock(400000);          // 400 kHz; the sensor supports it

  if (!sensor.init()) {
    Serial.println("VL53L1X not found");
    while (1);
  }

  sensor.setDistanceMode(VL53L1X::Medium);
  sensor.setMeasurementTimingBudget(33000);   // microseconds
  sensor.startContinuous(33);                 // milliseconds between reads
}

void loop() {
  uint16_t mm = sensor.read();
  Serial.println(mm);
}

That is a working sensor. Everything below is the difference between a working sensor and a usable interface.

Step 3 — Distance mode and timing budget are the two real settings

Distance mode trades range against ambient-light immunity:

ModeMax rangeNotes
Short~1.3 mBest immunity to ambient light; most robust indoors near a window
Medium~3 mThe sensible default
Long~4 mMost range, most sensitive to bright ambient light

Timing budget is how long the sensor integrates per measurement, and it is the setting people leave alone and should not. More time means less noise; less time means more readings per second. The usable span runs from about 20 ms to 1000 ms per measurement.

The way to choose it is to think about what the number is for:

  • Proximity trigger — “is someone within 1.5 m” — use a long budget, 100–200 ms. You do not need 30 Hz to notice a person, and the extra integration buys you a stable reading that will not false-trigger.
  • Hover control — a hand modulating a parameter — 33 ms. You need responsiveness, and a bit of jitter you can smooth.
  • Gesture — a hand moving through the beam — 20–33 ms, as fast as it goes, because you are measuring motion and a slow sensor turns a swipe into two samples.

Set startContinuous() to at least the timing budget. Asking for readings faster than the sensor can produce them gets you repeated stale values, which looks like a sensor that has frozen.

Step 4 — Read the status, not just the number

This is the step that separates reliable installations from ones that behave strangely at 2am.

uint16_t mm = sensor.read();
if (sensor.ranging_data.range_status != VL53L1X::RangeValid) {
  // Do not use this reading.
  return;
}

The sensor tells you when it does not trust its own answer. The statuses that matter in practice:

  • RangeValid — use it.
  • SignalFail — not enough returned light. Target too far, too dark, or absent. Typically returns a large nonsense value.
  • WrapTargetFail — the target is beyond the unambiguous range and has aliased to a short distance. This is the dangerous one: a distant object reported as very close, which in a proximity trigger means a spurious activation from nothing.
  • OutOfBoundsFail — outside configured limits.
  • Ambient-light failures when sunlight swamps the return.

Treating every reading as valid is the single most common cause of “it worked in the studio and misbehaves in the space.” The light changes, nothing else does, and now the piece has a ghost.

Step 5 — Narrow the region of interest to make it directional

The VL53L1X has a 16×16 SPAD array, and you can restrict which part of it is used. This is the feature that makes the sensor genuinely useful for interaction and almost nobody uses it.

// A 4x4 window of SPADs, centred — much narrower field of view.
sensor.setROISize(4, 4);
sensor.setROICenter(199);   // see ST's SPAD numbering

Why it matters: with the full array you get a ~27° cone, and the reported distance is the closest significant thing anywhere in that cone. Mount the sensor in a plinth and the plinth edge is in the cone. Point it along a wall and you are measuring the wall.

Narrowing the ROI to 4×4 gives roughly a 7° beam, which you can aim. The cost is range and signal — fewer SPADs collect less light — so you will want a longer timing budget to compensate.

The other use is faking multiple zones from one sensor. Scan the ROI centre across the array between readings and you get several directional measurements from a single device: left / centre / right, at a third of the frame rate. That is enough for a swipe.

Step 6 — Several sensors on one bus

Every VL53L1X powers up at 0x29, so you cannot simply wire three to the same bus. Two solutions:

XSHUT sequencing — the standard approach. Hold all sensors in shutdown, bring one up, change its address, bring up the next:

void bringUp(VL53L1X &s, uint8_t xshutPin, uint8_t newAddr) {
  pinMode(xshutPin, OUTPUT);
  digitalWrite(xshutPin, HIGH);    // release from shutdown
  delay(10);
  s.init();
  s.setAddress(newAddr);
}

Addresses are not persistent — this runs on every boot, in a fixed order.

An I²C multiplexer (TCA9548A) — more parts, no address juggling, and the sensors stay electrically isolated from each other, which matters on long cable runs.

Step 7 — From distance to gesture

Three sensors in a row, 10–15 cm apart, pointing the same way, is the classic arrangement and it gets you a surprising amount:

Position — a hand over the array produces a distance profile across the three. Interpolate and you have a continuous horizontal position, not three discrete zones.

Swipe — the order in which the three sensors see the hand arrive gives direction; the time between gives speed.

Push and pull — the common-mode distance, averaged across all three, is depth. So you get a 2D control surface from three one-dimensional sensors.

The processing details that make it feel good rather than twitchy:

  • Filter, lightly. An exponential moving average with α around 0.3 removes the jitter without adding noticeable lag. Heavier filtering feels like latency.
  • Hysteresis on every threshold. Enter at 400 mm, leave at 500 mm. Without the gap, a hand hovering at the boundary generates a stream of on/off events.
  • Require persistence. Two or three consecutive valid readings in a zone before acting. Combined with the status check, this removes nearly all false triggers.
  • Have a defined “nothing there” state. Decide what the system does when every sensor reports SignalFail, and make it a deliberate idle rather than whatever the last value happened to be.

Where to go next

  • The VL53L5CX and VL53L8CX return an 8×8 grid of distances rather than one number — a very low-resolution depth camera, with none of a camera’s privacy implications. For hand pose and multi-target tracking this is the obvious step up.
  • Interrupt mode via GPIO1 lets the sensor wake a sleeping microcontroller when something enters a configured window, which is how you build a battery-powered proximity piece.
  • Calibration. ST’s API supports offset and crosstalk calibration, and crosstalk calibration matters if there is a cover glass over the sensor — internal reflections off the glass otherwise bias every reading. If your sensor sits behind an acrylic panel, calibrate.
  • Combine with capacitive touch. ToF for approach, capacitive for contact, is a complete interaction from across the room to the fingertip.