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:
| Mode | Max range | Notes |
|---|---|---|
| Short | ~1.3 m | Best immunity to ambient light; most robust indoors near a window |
| Medium | ~3 m | The sensible default |
| Long | ~4 m | Most 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.