TL;DR:
analogWrite()and PWM don't work with an AC TRIAC dimmer. A TRIAC is not a resistor or a DC transistor. It regulates power using phase-cut control: it opens at a precisely calculated moment each half-cycle, synchronized with the zero-cross signal. Without zero-cross and the correct library — the load will only be fully on or fully off, with no intermediate values.
Problem Description
You connected an AC dimmer, found the DIM pin — and tried to control it with
analogWrite() like a regular PWM output. Or used ledc on ESP32.
Result: the load is either at 100% or completely off. No intermediate brightness.
Sometimes the behavior is even more unexpected:
- At low PWM values the load doesn't turn on at all
- At high values — it jumps straight to full power
- A value of 50% (128 out of 255) doesn't give 50% brightness
This is not a code bug or a faulty module. It's a fundamental mismatch between the control method and the load type.
Typical forum situations:
- "I connected dimmer DIM pin to PWM, analogWrite(128) but lamp is fully on"
- "Tried PWM on TRIAC dimmer but no dimming effect"
- "ESP32 ledc on dimmer pin — light just turns on and off"
- "Why doesn't analogWrite work with AC dimmer?"
- ZC pin not connected at all — only DIM on a PWM output
Root Cause
PWM — for direct current (DC)
PWM (Pulse Width Modulation) controls power by rapid switching: a transistor opens and closes thousands of times per second, and the load receives an average voltage proportional to the duty cycle.
PWM 50% duty cycle (DC load — LED strip, motor, DC heater):
Voltage:
┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐
─┘ └──┘ └──┘ └──┘ └──┘ └─
│←ON→│←OFF→│ ← frequency 1–20 kHz
Average power = 50% ✅This works because a DC load responds to the average voltage. A heater warms at average power; an LED flickers fast enough that the eye doesn't notice.
TRIAC — for alternating current (AC): works differently
A TRIAC is a silicon-controlled rectifier for AC. It behaves fundamentally differently:
- Latches — once current starts flowing (and exceeds the holding current), the TRIAC stays open until the end of the half-cycle, even if the gate signal is removed.
- Self-turns off at the sine wave zero-crossing — when current drops below the holding current.
- Doesn't respond to rapid PWM: a single gate pulse of any length opens the TRIAC until end of half-cycle. 10 µs or 5 ms — the result is the same.
AC sine wave (50 Hz):
╭──────╮ ╭──────╮
│ │ │ │
────╯ ╰────────╯ ╰────
↑ ↑ ↑ ↑
ZC ZC(-) ZC ZC(-)
(100 pulses per second)TRIAC opens at ZC — load gets the FULL half-cycle (100%):
╭──────╮ ╭──────╮
│//////│ │//////│
────╯ ╰────────╯ ╰────
↑ opens ↑ opens at ZCTRIAC opens with 5 ms delay — load gets HALF the half-cycle (~50%):
──────╮ ──────╮
│ │
──────╯──────── ─────╯──────
↑ ↑ ↑ ↑
ZC opens ZC opens
(after 5 ms) (after 5 ms)Why PWM "can't see" the TRIAC
When you apply 50% PWM (e.g., 500 Hz) to the TRIAC's DIM pin:
- PWM switches 500 times per second
- The first HIGH pulse opens the TRIAC
- Subsequent LOW pulses mean nothing — the TRIAC has already latched
- It stays open until the end of the half-cycle (10 ms at 50 Hz)
- The load ends up at full power
No zero-cross — no timing — no dimming.
Table: PWM vs Phase-Cut
| Parameter | PWM (DC) | Phase-cut (AC TRIAC) |
|---|---|---|
| Signal type | Continuous switching | Single gate pulse |
| Needs ZC? | No | Yes — mandatory |
| Needs library? | No (analogWrite) |
Yes |
| Load type | DC (LED strip, motor, heater) | AC (lamp, halogen, heater) |
| Control method | Pulse duty cycle | Gate delay relative to ZC |
| Switching frequency | 1–20 kHz | 100–120 Hz (once per half-cycle) |
| Working range | 0–100% | ~10–95% (TRIAC limitation) |
Solutions
🟢 For beginners: DimmerLink — plug and play
Don't want to deal with zero-cross, phase angle, and ISR? DimmerLink handles all the logic internally — just set a level.
DimmerLink is a controller with its own zero-cross detector and TRIAC phase control. Your microcontroller only sends a brightness level (0–100%) via I2C or UART. No PWM, no zero-cross on the MCU side.
When to choose:
Wiring:
DimmerLink → Arduino/ESP32
VCC → 3.3V (ESP32) / 5V (Arduino)
GND → GND
SDA → SDA (GPIO 21 on ESP32, A4 on Uno)
SCL → SCL (GPIO 22 on ESP32, A5 on Uno)Code:
// DimmerLink — set brightness as a simple 0–100 value
// No PWM, zero-cross, or ISR — all handled inside DimmerLink
// Docs: https://www.rbdimmer.com/docs/dimmerlink-I2CCommunication
#include <Wire.h>
#define DIMMER_ADDR 0x50
#define REG_LEVEL 0x10
void setLevel(uint8_t level) { // level: 0–100%
Wire.beginTransmission(DIMMER_ADDR);
Wire.write(REG_LEVEL);
Wire.write(level);
Wire.endTransmission();
}
void setup() {
Wire.begin();
setLevel(0); // off
delay(2000);
setLevel(50); // 50% brightness
delay(2000);
setLevel(100); // full brightness
}
void loop() {}🔵 For advanced users: proper phase-cut control
Want to work with the dimmer directly without DimmerLink? You need to connect zero-cross and use a library.
What you need to connect:
| Dimmer pin | Connect to |
|---|---|
| VCC | 5V (Arduino) / 3.3V (ESP32) |
| GND | GND |
| ZC | Interrupt-capable pin (2 or 3 on Arduino Uno; any GPIO on ESP32) |
| DIM | Any digital output |
Key difference from PWM: the DIM pin receives a single short pulse (~100 µs) at the right moment, calculated relative to ZC. No continuous PWM signal.
Option A: ESP32 with rbdimmerESP32 ✅ Recommended
// Platform: dual-core ESP32
// Library: rbdimmerESP32 — phase-cut, not PWM
// https://github.com/robotdyn-dimmer/rbdimmerESP32
#include "rbdimmerESP32.h"
#define ZC_PIN 18 // zero-cross — REQUIRED
#define DIM_PIN 19 // TRIAC control
rbdimmer dimmer;
void setup() {
dimmer.begin(ZC_PIN, DIM_PIN, 50); // 50 Hz mains
dimmer.setPower(50); // 50% — works via phase-cut
}
void loop() {
// Smooth brightness sweep
for (int p = 10; p <= 95; p++) {
dimmer.setPower(p);
delay(30);
}
for (int p = 95; p >= 10; p--) {
dimmer.setPower(p);
delay(30);
}
}What happens inside the library:
1. ZC interrupt fires (t=0)
↓
2. Calculate gate delay (leading edge, 50 Hz):
delay_us = (100 - power%) × 78
Example: 50% → delay = 50 × 78 = 3 900 µs
↓
3. Hardware timer: fires after 3 900 µs
↓
4. Send short pulse to DIM (~100 µs)
↓
5. TRIAC opens and stays open until next ZC
⚠️ Working range — not 0–100%, but ~10–95%:
100% → delay = 0 µs (opens at ZC, maximum power) ✅
0% → delay = 7 800 µs (78% of half-cycle) → load gets 22%,
not 0%. To turn off — don't open the TRIAC at all.
< 10% — TRIAC opens so late current can't exceed holding current
→ unstable flickering.
Libraries therefore limit the range: setPower(0) = off,
setPower(1–9) ≈ 10%, setPower(95–100) ≈ 95%.Option B: Arduino Uno/Mega with RBDdimmer
// Platform: Arduino Uno / Mega / Nano (AVR)
// Library: RBDdimmer — https://github.com/robotdyn/dimmer
// ZC IS REQUIRED! Arduino Uno: only pins 2 or 3
#include <RBDdimmer.h>
#define ZC_PIN 2 // zero-cross — pin 2 (interrupt-capable on Uno)
#define DIM_PIN 11 // TRIAC control
dimmerLamp dimmer(DIM_PIN, ZC_PIN);
void setup() {
dimmer.begin(NORMAL_MODE, ON);
dimmer.setPower(50); // 50%
}
void loop() {}⚠️ Common pitfalls
-
"Connected DIM to PWM, didn't connect ZC at all": Without ZC, phase-cut is physically impossible. The ZC pin is a required part of the circuit, not optional.
-
"
analogWrite(DIM_PIN, 128)— lamp doesn't flicker, just stays on": That's the expected behavior.analogWriteopens the TRIAC with the very first HIGH pulse and it stays latched. Use a library with ZC. -
"Set PWM frequency to 100 Hz — nothing changed": Matching the PWM frequency to the ZC rate doesn't help. The TRIAC still latches on the first pulse.
-
"PWM worked with DC load — why not with AC?": DC loads (12V LED strips, DC motors) use transistor control and respond to average voltage. AC TRIAC is different physics. See the table above.
-
"Can I leave DIM on PWM just for on/off switching?": Technically yes — you can turn the load on/off with a single HIGH/LOW. But that's not dimming. And without ZC the TRIAC may fire at a random point in the sine wave, causing interference and stress on the TRIAC.
Quick Check
Related Issues
- Zero-cross not detected →
troubleshooting/zero-cross-detection-errors.md - ESP32 + TRIAC: Guru Meditation Error →
troubleshooting/esp32-iram-attr.md - Trailing vs Leading Edge →
load-types/trailing-vs-leading-edge.md
Still have questions?
Ask on forum.rbdimmer.com or open a GitHub Issue.