After making an LED blink, almost everyone wires up a button next. Two wires, one digitalRead(), done.
Then the strange things start. The code reports presses when nobody's touching anything. Waving a hand near the board changes the reading. One press counts as three. Hold the button down and a counter races into the hundreds of thousands.
None of this is a faulty button or a bug in the chip. It's three separate things that every input has to deal with, and once you've seen them you'll handle them without thinking. This article uses the water picture from Electricity, explained with water, so it's worth reading that first.
How a pin reads a voltage
When a pin is set up as an input, the chip is measuring the voltage on it and sorting that into one of two answers. On the ESP32-S3, roughly:
- Above about 2.5 V, it reads HIGH.
- Below about 0.8 V, it reads LOW.
- Anywhere in between, it could read either, and there's no telling which.
An input takes almost no current at all. In water terms it's a pressure gauge on the end of a pipe: it tells you how high the water stands at that point, but nothing flows into it. That's useful, because measuring barely disturbs the circuit. It's also where the first surprise comes from.
The floating pin
The obvious way to wire a button is between the pin and ground. Press it and the pin connects to ground, 0 V, LOW. Release it and... the pin connects to nothing at all.
A pin connected to nothing is called floating. It's a pipe that doesn't lead to any reservoir or to the sea, so the "pressure" in it is set by whatever happens to be nearby: static charge, the wiring next to it, the mains wiring in your walls, your hand. It drifts, and when it drifts into the middle band, every read is a coin toss.
A pin wired to a button, and a live trace of the voltage the chip sees. Start with no resistor, leave the button alone, and watch what the code thinks is happening. Then touch the wire.
Circuit
Voltage on the pin
digitalRead() returns HIGH.
What else is on the pin
A pipe connected to nothing at all. With no reservoir and no sea attached, its pressure drifts with whatever happens to be nearby.
- Pin voltage
- 3.30 V
- pressure at the pin
- Reading flips
- 0
- in the trace above
- Ghost presses
- 0
- presses nobody made
- Current when pressed
- 0 A
- no resistor to feed
With no resistor, the code racks up ghost presses while you're not touching anything, and holding a finger near the wire makes things far worse. Your body picks up the hum from the mains wiring around you and passes it straight into the pin.
You can see this on real hardware with nothing but a jumper wire on a pin:
void setup() {
Serial.begin(115200);
pinMode(5, INPUT); // plain input, nothing holding it
}
void loop() {
Serial.println(digitalRead(5));
delay(50);
}Open the Serial Plotter and touch the loose end of the wire. The readings go haywire.
Pull-up and pull-down resistors
The fix is to give the pin a default: a level it sits at whenever the button isn't deciding.
A pull-up resistor connects the pin to 3.3 V through a resistor, typically 10 kΩ. In water terms it's a thin pipe from the pin to the high reservoir. With the button released, nothing else is connected, so the pin fills to the reservoir's level: 3.3 V, HIGH.
Why doesn't the resistor drop any voltage? Because no current flows through it. The pin is only a gauge, so nothing is drawn, and V = I × R with a current of zero means a drop of zero. The pin sees the full 3.3 V.
Press the button and you open a wide pipe from the pin straight to the sea. Water now flows from the reservoir, through the thin pipe, and out through the wide one. The wide pipe wins easily, and the pin drops to 0 V. On the river from Electricity, explained with water, the whole 3.3 V of height now falls across the resistor, and a little current flows:
I = V ÷ R = 3.3 V ÷ 10 kΩ = 0.33 mAThat's the whole trick. The resistor is weak enough that the button always wins, and strong enough to hold the pin steady when the button isn't pressed.
A pull-down resistor is the mirror image: a thin pipe to ground holds the pin LOW, and the button connects it to 3.3 V to make it HIGH.
Choosing the value
The resistor value is a trade-off, and you can explore it with the slider in the widget above.
| Resistor | While pressed | While released |
|---|---|---|
| 1 kΩ | 3.3 mA wasted, which adds up on a battery | Held very firmly |
| 10 kΩ | 0.33 mA | Held firmly: the usual choice |
| 100 kΩ | 33 µA | Fine on short wires, a bit more easily disturbed |
| 1 MΩ | 3.3 µA | Weak: noise and fingers can drag it into the dead zone |
For almost every button, 10 kΩ is right. Go smaller for long wires in electrically noisy places, and larger only when every microamp of battery counts.
The pull-up that's already in the chip
You often don't need to add a resistor at all. The ESP32-S3 has a pull-up built into almost every GPIO pin, and you switch it on in code:
const int BUTTON_PIN = 5;
void setup() {
pinMode(BUTTON_PIN, INPUT_PULLUP); // switch on the internal pull-up
}
void loop() {
bool pressed = digitalRead(BUTTON_PIN) == LOW; // LOW means pressed
// ...
}Two things to notice. The logic is inverted: with a pull-up, pressed reads LOW and released reads HIGH. And the internal pull-up is weaker than a 10 kΩ resistor, roughly 45 kΩ. That's fine for a button on short wires, but for long cable runs to a panel, add an external 10 kΩ.
There's an INPUT_PULLDOWN as well, but pull-ups with the button going to ground are the usual convention. Ground is everywhere on a board and on a project, so the button's second wire is easy to find, and a loose wire touching the case does no harm.
Your Vector S3 already has a button you can read. The BOOT button is on GPIO0, with a 10 kΩ pull-up on the board. After the board has started, digitalRead(0) == LOW means it's pressed. Just don't hold it down while the board resets, or the ESP32-S3 starts in its firmware-download mode instead of running your sketch.
Bounce
Pull-ups solve the ghost presses. The next surprise is that one real press can count as several.
Inside a button, two pieces of metal slam together. Metal is springy, so they don't just touch and stay touching: they hit, bounce apart, hit again, and chatter for somewhere around 1 to 10 milliseconds before settling. To you it's one press. To a chip reading the pin hundreds of thousands of times a second, it's a burst of separate presses.
Press the button 10 times and see how many presses the code counts. The scope zooms in on the first 16 ms of your latest press, where the contacts bounce.
Scope: pin voltage, latest press
Pink dots are falling edges: each one looks like a fresh press to code that just watches for HIGH turning into LOW.
How long the reading must stay put before the code believes it
Press the button 10 times, one press at a time.
- You pressed
- 0 / 10
- real presses
- Naive code
- 0
- counts every falling edge
- Debounced code
- 0
- debounce is off
- Latest press
- 0 edges
- falling edges on the scope
Try pressing ten times with the debounce time at zero, and watch the naive count run ahead of you. Then raise the debounce time until the counts match. Push it much further and use the quick taps, and you'll see the opposite problem: if the code waits longer than a press actually lasts, real presses get swallowed.
Levels and edges
The last surprise isn't about electricity at all. It's about what your code is actually asking.
"Is the button pressed?" is a question about the level: what the pin reads right now. "Was the button just pressed?" is a question about an edge: the moment the reading changes. For turning an LED on while a button is held, the level is exactly what you want. For counting presses, or toggling something on each press, you need the edge.
Two sketches, both trying to count presses. Press and hold the button for a second, then let go, and compare their counts.
Checks the level
Is it pressed right now?
void loop() {
if (digitalRead(PIN) == LOW) {
presses++;
}
}presses counted
0
Checks for an edge
Did it just become pressed?
void loop() {
int now = digitalRead(PIN);
if (now == LOW && before == HIGH) {
presses++;
}
before = now;
}presses counted
0
loop() runs about a quarter of a million times a second here.
loop() runs over and over, extremely fast. Code that adds one "while the button is pressed" adds one on every trip round the loop, for as long as your finger stays down.
Edge detection fixes the counting, but on its own it still sees every bounce as a new edge. You need both.
Debouncing in code
Here's a sketch that does both properly. It only believes a change once the reading has stayed the same for 20 ms, then acts on the edge:
const int BUTTON_PIN = 5;
const unsigned long DEBOUNCE_MS = 20;
int stableState = HIGH; // what we've decided the button is doing
int lastReading = HIGH; // the raw reading last time round
unsigned long lastChange = 0; // when the raw reading last changed
int presses = 0;
void setup() {
Serial.begin(115200);
pinMode(BUTTON_PIN, INPUT_PULLUP);
}
void loop() {
int reading = digitalRead(BUTTON_PIN);
// Any change at all restarts the clock. Bounces keep restarting it.
if (reading != lastReading) {
lastChange = millis();
lastReading = reading;
}
// Steady for long enough to trust, and different from before: a real edge.
if (millis() - lastChange >= DEBOUNCE_MS && reading != stableState) {
stableState = reading;
if (stableState == LOW) { // LOW means pressed, thanks to the pull-up
presses++;
Serial.println(presses);
}
}
// The rest of your program keeps running. Nothing here waits.
}This is exactly the logic the bounce widget runs, so the debounce time you found there is the one to use here. Most buttons are happy with 10 to 20 ms. Much beyond 50 ms, fast presses start going missing, and the button feels sluggish, because every press is recognised that much later.
It also never calls delay(). You'll see debounce code that detects a press, then waits 50 ms with delay(). It works, but the whole program freezes while it waits, and in a sketch that's also driving LEDs or reading sensors, those freezes show.
Libraries such as Bounce2 wrap all of this up, with methods like fell() and rose() for edges. They're worth using once you understand what they do for you.
Debouncing in hardware
You can also smooth out the bounce before it reaches the pin. A resistor and a capacitor, say 10 kΩ and 100 nF, make a small RC filter: the capacitor, the stretchy membrane from Electricity, explained with water, can't change voltage instantly, so it smooths the chattering into one slow ramp.
A slow ramp spends time in the dead zone between LOW and HIGH, though, so hardware debouncing usually adds a Schmitt trigger, a buffer chip such as the 74HC14 with two thresholds instead of one. It switches cleanly once the voltage passes the upper or lower threshold, and ignores the wobble in between.
Hardware debouncing earns its place when a button triggers an interrupt or wakes the board from sleep, because the chip reacts to every edge immediately, before your code can filter anything. For a button read in loop(), software debouncing is simpler and costs nothing.
Which pin to use
On the Vector S3, a few pins are best left alone for buttons. The pinout has the full details.
- GPIO0, 45 and 46 are strapping pins. The ESP32-S3 reads them at reset to decide how to start up. A button holding one of them in the wrong state during a reset can stop the board booting normally. GPIO0 already has the BOOT button on it anyway.
- GPIO43 and 44 are the main serial port, where the boot log comes out.
- GPIO13 and 14 are the I2C bus for the onboard sensors and the Qwiic port.
That still leaves plenty. GPIO5 to 12 on the left header are good choices. And if you want a button that wakes the board from deep sleep, pick a pin between GPIO0 and GPIO21, because only those can wake it.
The short version
- Never leave an input floating. Use a pull-up or pull-down, or switch on
INPUT_PULLUP. - With a pull-up, pressed reads LOW. Say so in a comment, because everyone forgets.
- 10 kΩ is the right resistor almost every time.
- Buttons bounce for a few milliseconds. Debounce with a timer, never with
delay(). - Count edges, not levels. Ask "did it just change?", not "is it down?".
With those five habits, buttons become the boring, reliable part of your project, which is exactly what they should be.
