Digital pins can only be HIGH (3.3 V) or LOW (0 V) -- there is no in-between. But what if you want to dim an LED to half brightness, or run a motor at 30% speed? The answer is Pulse Width Modulation (PWM): a technique that rapidly switches a pin on and off to simulate an intermediate voltage. In this article, you will learn how PWM works on the ESP32 and use it to control LED brightness with code and a potentiometer.
🔗What is PWM?
PWM works by toggling a digital pin between HIGH and LOW at a fixed frequency -- too fast for the human eye to see individual flashes. What matters is the ratio of time the signal spends HIGH versus LOW in each cycle. This ratio is called the duty cycle, expressed as a percentage:
| Duty cycle | Time HIGH | Average voltage (3.3 V pin) | Perceived effect |
|---|---|---|---|
| 0% | Never on | 0 V | LED off |
| 25% | 1/4 of cycle | ~0.83 V | LED dim |
| 50% | 1/2 of cycle | ~1.65 V | LED medium |
| 75% | 3/4 of cycle | ~2.48 V | LED bright |
| 100% | Always on | 3.3 V | LED full brightness |
The key insight is that components like LEDs and motors respond to the average power delivered. A 50% duty cycle delivers half the average power, so an LED appears half as bright and a motor runs at roughly half speed. The switching happens so fast (thousands of times per second) that the LED does not visibly flicker and the motor spins smoothly.
🔗ESP32 PWM: the LEDC peripheral
The ESP32 has a dedicated hardware peripheral for PWM called LEDC (LED Control). Despite the name, it works for any PWM application -- LEDs, motors, buzzers, and more.
Key features:
- 16 PWM channels -- 8 high-speed and 8 low-speed, so you can independently control up to 16 outputs
- Configurable frequency -- From a few Hz to several MHz
- Configurable resolution -- 1 to 16 bits (controls how many brightness steps you get)
🔗API Functions
The ESP32 Arduino core (v3.x and later) uses two main functions:
ledcAttach(pin, frequency, resolution); // Configure a pin for PWM
ledcWrite(pin, dutyCycle); // Set the duty cycleWarning: If you find older tutorials or code using
ledcSetup(),ledcAttachPin(), andledcWriteTone(), be aware that these functions were deprecated in ESP32 Arduino core v3.0. The new API usesledcAttach()andledcWrite()and handles channel assignment automatically. If your code does not compile, check which core version you are using and update the function calls accordingly.
🔗Common Frequency and Resolution Combinations
| Use case | Frequency | Resolution | Duty cycle range | Notes |
|---|---|---|---|---|
| LED dimming | 5000 Hz | 8-bit | 0 -- 255 | Smooth, no visible flicker |
| DC motor speed | 25000 Hz | 8-bit | 0 -- 255 | Above audible range (no whine) |
| Servo position | 50 Hz | 16-bit | 0 -- 65535 | Standard servo PWM frequency |
| Buzzer tone | 500 -- 5000 Hz | 8-bit | 0 -- 255 | Frequency determines pitch |
The resolution determines how many discrete steps you get between fully off and fully on. With 8-bit resolution, you have $2^8 = 256$ steps (0 to 255). With 10-bit resolution, you have $2^{10} = 1024$ steps (0 to 1023). Higher resolution means finer control, but 8-bit is sufficient for most LED and motor applications.
🔗What You'll Need
| Component | Qty | Notes | Buy |
|---|---|---|---|
| ESP32 dev board | 1 | AliExpress | Amazon.de .co.uk .com | |
| LED (red) | 1 | AliExpress | Amazon.de .co.uk .com | |
| 220 ohm resistor | 1 | For LED | AliExpress | Amazon.de .co.uk .com |
| Potentiometer (10k ohm) | 1 | For brightness control example | Amazon.de .co.uk .com |
| Breadboard | 1 | AliExpress | Amazon.de .co.uk .com | |
| Jumper wires | ~5 | Male-to-male | AliExpress | Amazon.de .co.uk .com |
Links marked Amazon/AliExpress are affiliate links. We may earn a small commission at no extra cost to you.
🔗Wiring
🔗LED circuit
| Component | Connects to |
|---|---|
| GPIO 13 | 220 ohm resistor |
| Resistor other end | LED anode (long leg) |
| LED cathode (short leg) | GND |
🔗Potentiometer (for the second example)
| Potentiometer pin | Connects to |
|---|---|
| Left pin | 3.3V |
| Middle pin (wiper) | GPIO 34 |
| Right pin | GND |
Tip: We use GPIO 34 for the potentiometer because it is on ADC1, which works even when WiFi is active. See the Digital vs Analog article for more on ADC pin selection.
🔗Code Example: LED Dimming
This sketch smoothly fades an LED up and down in a continuous loop using PWM.View complete sketch
#define LED_PIN 13
#define PWM_FREQ 5000 // 5 kHz -- no visible flicker
#define PWM_RESOLUTION 8 // 8-bit: duty cycle 0-255
void setup() {
ledcAttach(LED_PIN, PWM_FREQ, PWM_RESOLUTION);
Serial.begin(115200);
delay(1000);
Serial.println("PWM LED fade");
}
void loop() {
// Fade in
for (int duty = 0; duty <= 255; duty++) {
ledcWrite(LED_PIN, duty);
delay(5);
}
// Fade out
for (int duty = 255; duty >= 0; duty--) {
ledcWrite(LED_PIN, duty);
delay(5);
}
}
🔗How It Works
| Code | Purpose |
|---|---|
ledcAttach(LED_PIN, PWM_FREQ, PWM_RESOLUTION) | Configures GPIO 13 for PWM at 5 kHz with 8-bit resolution. The core automatically assigns a free LEDC channel |
ledcWrite(LED_PIN, duty) | Sets the duty cycle. With 8-bit resolution: 0 = fully off, 255 = fully on |
delay(5) | Pauses 5 ms between steps for a visible fade. 256 steps x 5 ms = ~1.3 seconds per fade direction |
Upload this sketch and watch the LED smoothly breathe in and out. Each full cycle (fade up + fade down) takes about $256 \times 5 \, \text{ms} \times 2 \approx 2.6 \, \text{s}$.
Changing the resolution changes the number of steps available. If you switch to 10-bit resolution, the duty cycle range becomes 0 to 1023:
#define PWM_RESOLUTION 10 // 10-bit: duty cycle 0-1023
// In loop:
for (int duty = 0; duty <= 1023; duty++) {
ledcWrite(LED_PIN, duty);
delay(1); // Shorter delay since there are more steps
}More steps means smoother fading, but 8-bit (256 steps) is smooth enough for most visible LED applications.
🔗Code Example: Controlling Brightness with a Potentiometer
This sketch reads a potentiometer value and maps it to the LED brightness, giving you real-time analog control over PWM output.View complete sketch
#define POT_PIN 34 // ADC1 pin for potentiometer
#define LED_PIN 13
#define PWM_FREQ 5000
#define PWM_RESOLUTION 8 // 0-255
void setup() {
ledcAttach(LED_PIN, PWM_FREQ, PWM_RESOLUTION);
Serial.begin(115200);
delay(1000);
Serial.println("Potentiometer-controlled LED brightness");
}
void loop() {
int potValue = analogRead(POT_PIN); // 0-4095 (12-bit ADC)
int brightness = map(potValue, 0, 4095, 0, 255); // Scale to 0-255
ledcWrite(LED_PIN, brightness);
Serial.printf("Pot: %4d Brightness: %3d\n", potValue, brightness);
delay(50);
}
🔗How It Works
This sketch combines two concepts:
- Analog input --
analogRead(POT_PIN)reads the potentiometer position as a value from 0 to 4095 (the ESP32's 12-bit ADC range). - PWM output --
ledcWrite(LED_PIN, brightness)sets the LED duty cycle.
The map() function scales the ADC range (0--4095) down to the PWM range (0--255). Turn the potentiometer and the LED brightness follows in real time.
Note: You could also scale the value with integer division:
int brightness = potValue / 16;works the same asmap(potValue, 0, 4095, 0, 255)for this case, since $4095 / 16 \approx 255$. Themap()function is more readable and flexible when the ranges are not so neatly related.
🔗PWM for Motor Speed
The same PWM principle controls motor speed, but you cannot drive a motor directly from an ESP32 GPIO pin -- the pin can only source about $12 \, \text{mA}$, while even a small DC motor draws hundreds of milliamps.
You need a motor driver (like the L298N module) or a MOSFET between the ESP32 and the motor. The ESP32 sends a PWM signal to the driver, and the driver switches the motor's power supply at the same duty cycle.
One important difference for motors: use a higher PWM frequency ($20$ to $25 \, \text{kHz}$) to avoid audible whine. At lower frequencies like 1 kHz, you can hear the switching as an annoying buzzing sound. Frequencies above $20 \, \text{kHz}$ are ultrasonic and silent to human ears.
#define MOTOR_PIN 13
#define PWM_FREQ 25000 // 25 kHz -- above audible range
#define PWM_RESOLUTION 8
void setup() {
ledcAttach(MOTOR_PIN, PWM_FREQ, PWM_RESOLUTION);
}For a complete motor control guide with wiring and full code, see the DC Motor with L298N article.
🔗PWM for Servo Motors
Servo motors use PWM differently from LEDs and DC motors. Instead of duty cycle controlling speed or brightness, the pulse width determines the shaft angle:
| Pulse width | Servo angle |
|---|---|
| $1.0 \, \text{ms}$ | 0 degrees |
| $1.5 \, \text{ms}$ | 90 degrees (center) |
| $2.0 \, \text{ms}$ | 180 degrees |
Servos expect a PWM frequency of 50 Hz (a 20 ms period). The pulse width within each period sets the position. For example, at 50 Hz with 16-bit resolution:
- $1.0 \, \text{ms}$ pulse = duty value of $\frac{1.0}{20.0} \times 65535 \approx 3277$
- $1.5 \, \text{ms}$ pulse = duty value of $\frac{1.5}{20.0} \times 65535 \approx 4915$
- $2.0 \, \text{ms}$ pulse = duty value of $\frac{2.0}{20.0} \times 65535 \approx 6553$
You can calculate these values manually, but the ESP32Servo library handles the math for you with a familiar servo.write(angle) interface. Install it from the Library Manager (search for "ESP32Servo").
Tip: Using the ESP32Servo library is recommended over raw
ledcWrite()for servos. It abstracts the pulse timing and provides a clean API:servo.attach(pin)andservo.write(90). See the Servo Motor Guide for a complete walkthrough.
🔗Multiple PWM Outputs
Each GPIO pin can have its own PWM configuration. This is useful for driving an RGB LED, where you control three colors independently with three separate PWM channels:
#define RED_PIN 13
#define GREEN_PIN 12
#define BLUE_PIN 14
#define PWM_FREQ 5000
#define PWM_RES 8
void setup() {
ledcAttach(RED_PIN, PWM_FREQ, PWM_RES);
ledcAttach(GREEN_PIN, PWM_FREQ, PWM_RES);
ledcAttach(BLUE_PIN, PWM_FREQ, PWM_RES);
}
void setColor(int red, int green, int blue) {
ledcWrite(RED_PIN, red); // 0-255
ledcWrite(GREEN_PIN, green);
ledcWrite(BLUE_PIN, blue);
}
void loop() {
setColor(255, 0, 0); // Red
delay(1000);
setColor(0, 255, 0); // Green
delay(1000);
setColor(0, 0, 255); // Blue
delay(1000);
setColor(255, 165, 0); // Orange
delay(1000);
setColor(128, 0, 128); // Purple
delay(1000);
}The ESP32 has 16 LEDC channels, so you can control up to 16 independent PWM outputs. The core assigns channels automatically when you call ledcAttach().
For a full RGB LED guide with wiring and color mixing, see the RGB LED article.
🔗Troubleshooting
| Problem | Likely cause | Solution |
|---|---|---|
| LED does not dim (only fully on or off) | Wrong pin or PWM not configured | Verify ledcAttach() is called before ledcWrite(). Double-check the GPIO number |
| LED flickers visibly | PWM frequency too low | Increase frequency to at least 5000 Hz. Below ~100 Hz, flicker is visible |
| Servo jitters or vibrates | Noisy PWM signal or insufficient power | Power the servo from the 5V pin or external supply, not from a GPIO pin. Add a $100 \, \text{nF}$ capacitor across the servo power pins |
Compilation error: ledcSetup not found | Using old API with new Arduino core (v3.x) | Replace ledcSetup() + ledcAttachPin() with ledcAttach(). See the API section above |
Compilation error: ledcAttach not found | Using new API with old Arduino core (v2.x) | Either update to core v3.x, or use the old API: ledcSetup(channel, freq, resolution) then ledcAttachPin(pin, channel) |
| Motor makes audible whine | PWM frequency in audible range | Use $20{,}000$ to $25{,}000 \, \text{Hz}$ for motors to eliminate audible noise |
| PWM works on one pin but not another | GPIO is input-only | GPIOs 34, 35, 36, 39 are input-only and cannot output PWM. Use a different pin |
| Brightness not linear (dim at low values, jumps to bright) | Human eye perceives brightness logarithmically | Apply gamma correction: duty = pow(input / 255.0, 2.2) * 255 for perceptually linear dimming |
🔗What's Next?
PWM is one of the most versatile tools in your ESP32 toolkit. Here are some natural next steps:
- RGB LED -- Mix colors with three PWM channels to create any color you want.
- Servo Motor -- Control servo position with PWM using the ESP32Servo library.
- DC Motor with L298N -- Drive motors at variable speeds with PWM through a motor driver.