Phase 3: GPIO & Hardware 13 min read

Reading Buttons on ESP32: Wiring, Code, and Projects

Learn how to read button inputs on ESP32 — wiring normally-open switches, active-LOW vs active-HIGH logic, debouncing, toggle buttons, and complete Arduino code.

Updated June 19, 2026

Button Types and Terminology

Before writing a single line of code, understanding button mechanics prevents many common mistakes. The push buttons used on breadboards are typically momentary tactile switches: they connect the circuit only while you hold them. Two subtypes exist:

  • Normally Open (NO): Circuit is open (disconnected) at rest, closes when pressed. This is the standard breadboard button.
  • Normally Closed (NC): Circuit is closed at rest, opens when pressed. Used in safety applications (detecting a door opening breaks the circuit).

Most of this guide uses normally-open buttons, which are by far the most common in ESP32 projects.

Wiring a Button to ESP32 (Active-LOW)

The simplest and most robust approach uses INPUT_PULLUP — no extra resistor needed:

Button Terminal Connect To
Terminal A ESP32 GPIO16
Terminal B ESP32 GND

With INPUT_PULLUP the pin reads HIGH at rest (3.3V via internal resistor). Pressing the button shorts the pin to GND → reads LOW. This is active-LOW logic: LOW means pressed.

Basic Button Read — Complete Code

Arduino (C++)
#define BUTTON_PIN 16
#define LED_PIN     2

void setup() {
  Serial.begin(115200);
  pinMode(BUTTON_PIN, INPUT_PULLUP);   // HIGH at rest, LOW when pressed
  pinMode(LED_PIN,    OUTPUT);
  Serial.println("Button demo — press the button!");
}

void loop() {
  int state = digitalRead(BUTTON_PIN);

  if (state == LOW) {              // Active-LOW: LOW = pressed
    digitalWrite(LED_PIN, HIGH);
    Serial.println("PRESSED");
  } else {
    digitalWrite(LED_PIN, LOW);
    // Serial.println("released");  // Comment out to reduce Serial spam
  }
  delay(20);   // Small delay reduces Serial flood
}

Active-HIGH Wiring (Alternative)

If your circuit naturally drives the GPIO HIGH when triggered (like some sensors), use INPUT_PULLDOWN:

Button Terminal Connect To
Terminal A ESP32 3.3V
Terminal B ESP32 GPIO17
Arduino (C++)
#define BUTTON_PIN 17

void setup() {
  Serial.begin(115200);
  pinMode(BUTTON_PIN, INPUT_PULLDOWN);  // LOW at rest, HIGH when pressed
}

void loop() {
  if (digitalRead(BUTTON_PIN) == HIGH) {  // HIGH = pressed (active-HIGH)
    Serial.println("PRESSED (active-HIGH)");
  }
  delay(20);
}

Toggle Button: On/Off Control

Many projects need a button that toggles a state rather than holding it active. The key is detecting only the falling edge (button going from HIGH to LOW):

Arduino (C++)
#define BUTTON_PIN 16
#define LED_PIN     2

bool ledState    = false;
bool lastState   = HIGH;

void setup() {
  Serial.begin(115200);
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  pinMode(LED_PIN,    OUTPUT);
}

void loop() {
  bool current = digitalRead(BUTTON_PIN);

  // Detect falling edge: was HIGH, now LOW
  if (lastState == HIGH && current == LOW) {
    ledState = !ledState;                       // Toggle
    digitalWrite(LED_PIN, ledState);
    Serial.printf("LED toggled %sn", ledState ? "ON" : "OFF");
    delay(50);                                  // Simple debounce
  }

  lastState = current;
  delay(10);
}

Detecting Short vs Long Press

Arduino (C++)
#define BUTTON_PIN  16
#define LONG_MS   1000   // 1 second threshold

unsigned long pressStart = 0;
bool pressing = false;

void setup() {
  Serial.begin(115200);
  pinMode(BUTTON_PIN, INPUT_PULLUP);
}

void loop() {
  bool current = (digitalRead(BUTTON_PIN) == LOW);

  if (current && !pressing) {          // Button just pressed
    pressing   = true;
    pressStart = millis();
  }

  if (!current && pressing) {          // Button just released
    pressing = false;
    unsigned long held = millis() - pressStart;
    if (held >= LONG_MS) {
      Serial.printf("LONG PRESS (%lu ms)n", held);
    } else {
      Serial.printf("SHORT PRESS (%lu ms)n", held);
    }
  }
  delay(10);
}

Multiple Buttons with Array Management

Arduino (C++)
const int BTNS[]    = {16, 17, 18, 19};
const int LEDS[]    = { 2, 13, 14, 27};
const int N         = 4;
bool prevState[N];

void setup() {
  Serial.begin(115200);
  for (int i = 0; i < N; i++) {
    pinMode(BTNS[i], INPUT_PULLUP);
    pinMode(LEDS[i], OUTPUT);
    prevState[i] = HIGH;
  }
}

void loop() {
  for (int i = 0; i < N; i++) {
    bool cur = digitalRead(BTNS[i]);
    if (prevState[i] == HIGH && cur == LOW) {   // Falling edge
      digitalWrite(LEDS[i], !digitalRead(LEDS[i]));  // Toggle LED
      Serial.printf("Button %d toggled LED %dn", i+1, i+1);
      delay(50);
    }
    prevState[i] = cur;
  }
  delay(5);
}

Reading a Normally-Closed Button

NC buttons invert the logic. With INPUT_PULLUP and an NC button wired between GPIO and GND:

  • Button at rest (NC = connected): Pin is shorted to GND → reads LOW (pressed appearance)
  • Button pressed (NC opens): Pin pulled HIGH by resistor → reads HIGH (released appearance)

Simply invert your comparison: if (digitalRead(pin) == HIGH) to detect the NC button being pressed (opened).

Project: Morse Code Tapper

Arduino (C++)
#define BUTTON_PIN 16
#define BUZZ_PIN   25
#define DOT_MS    100
#define DASH_MS   300

void setup() {
  Serial.begin(115200);
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  pinMode(BUZZ_PIN,   OUTPUT);
}

void loop() {
  if (digitalRead(BUTTON_PIN) == LOW) {
    unsigned long start = millis();
    tone(BUZZ_PIN, 800);            // Start tone
    while (digitalRead(BUTTON_PIN) == LOW);   // Wait for release
    tone(BUZZ_PIN, 0);              // Stop tone
    unsigned long held = millis() - start;
    Serial.print(held >= DASH_MS ? "-" : ".");
    delay(50);
  }
}

Summary

Reading buttons on ESP32 boils down to: use INPUT_PULLUP with button wired to GND for the most reliable active-LOW setup, detect falling edges for clean toggle logic, measure press duration for short/long press differentiation, and add 50 ms of debounce delay to eliminate contact bounce. For more advanced debounce techniques, see the next guide on debouncing buttons.

Frequently Asked Questions

The simplest wiring: connect one button terminal to a GPIO pin and the other to GND. In code, use pinMode(pin, INPUT_PULLUP). The pin reads HIGH by default and LOW when the button is pressed (active-LOW logic). No external resistor is needed.
Active-LOW means the button press drives the pin to LOW (GND). With INPUT_PULLUP and button wired to GND, pressing the button gives LOW. Active-HIGH is the opposite — pin goes HIGH when pressed, using INPUT_PULLDOWN with button wired to 3.3V.
A normally-open (NO) button is open (disconnected) by default and closed (connected) when pressed. A normally-closed (NC) button is connected by default and opens when pressed. Most breadboard push buttons are normally-open.
This is button bounce — the mechanical contacts bounce open and closed several times within the first 5–50 ms of a press. The ESP32 is fast enough to read each bounce as a separate press. Fix this with debouncing: either a 10–100 ms delay or the millis() timing method.
Yes — use the millis() debouncing method. Record the time of the last state change, and only register a new press if at least 50 ms have passed since the last change. This keeps the loop() running freely while still detecting clean button presses.
Use a state variable. When a button press is detected (and debounced), flip a boolean: toggleState = !toggleState. Then control your output based on toggleState, not the raw button reading.
Yes, but GPIO34 (and GPIO35, GPIO36, GPIO39) have no internal pull resistors. You must add an external 10 kΩ pull-up resistor between GPIO34 and 3.3V, then use pinMode(34, INPUT) in code.
Record millis() when the button goes LOW. When the button is released (goes HIGH), compare the elapsed time to a threshold (e.g. 1000 ms). If elapsed >= 1000 ms it is a long press; otherwise short press.
Up to 34 buttons (one per GPIO), though practically you would use GPIO4, GPIO13–GPIO14, GPIO16–GPIO19, GPIO21–GPIO23, GPIO25–GPIO27, GPIO32–GPIO35 for reliable input. For more buttons, use a shift register (74HC165) or I2C expander (PCF8574).
For buttons in a simple loop, polling with millis() debouncing is simpler and reliable. Use interrupts only when the press must be detected even while the main loop is blocked (e.g. during a long computation or HTTP request). Most button projects work fine with polling.

Projects to Build

Put this knowledge to work — try one of these hands-on projects.