A rotary encoder is one of the most versatile input devices you can connect to an Arduino. Unlike a potentiometer, it has infinite rotation and generates digital pulses — making it ideal for both arduino rotary encoder tutorial applications like menu navigation, volume controls, and stepper motor positioning. This comprehensive guide covers the fundamentals of how encoders work, proper debouncing, interrupt-driven reading, and three practical projects that demonstrate the full range of encoder capabilities in real embedded systems.
Table of Contents
- How Rotary Encoders Work
- Types of Rotary Encoders
- Wiring KY-040 to Arduino
- Basic Encoder Reading with Polling
- Interrupt-Driven Reading (Better Approach)
- Debouncing Encoders Properly
- Project 1: LCD Menu Navigation
- Project 2: Absolute Position Tracking
- Project 3: Stepper Motor Speed Control
- Frequently Asked Questions
How Rotary Encoders Work
A mechanical rotary encoder contains two internal switches (called channels A and B) connected to a rotary disc with evenly spaced slots. As you turn the shaft, the slots alternately break and complete the circuit for each channel, producing a square wave output.
The key insight is in the phase relationship between channels A and B. When turning clockwise, channel A transitions before channel B — when turning counter-clockwise, B transitions before A. By reading which channel changes first, you determine rotation direction. The number of transitions tells you how far it has rotated.
A typical KY-040 module produces 20 pulses per revolution (called 20 PPR or 20 steps/rev). Each pulse consists of 4 state transitions (A rises, B rises, A falls, B falls), giving 80 total state changes per revolution if you count all edges. Most applications count only one edge per click, so the encoder produces 20 detents per revolution.
Types of Rotary Encoders
Mechanical encoders (KY-040, EC11): Use physical contacts. Inexpensive (₹30–80), but generate contact bounce that must be filtered. Best for human-interface applications where speed is low (<100 RPM). The KY-040 includes a pushbutton by pressing the shaft — this is the select/enter button in most UI applications.
Optical encoders: Use a slotted disc and IR LED/photodetector pair. No contact bounce, very high resolution (up to 10,000 PPR), excellent for motor feedback and CNC positioning. More expensive (₹500+) and require 5V supply rather than the 3.3V some MCUs use.
Magnetic encoders (AS5048, AS5600): Use a rotating magnet and Hall-effect sensor. Zero mechanical wear, immune to contamination, and output absolute position over I2C/SPI. Ideal for permanent installations where the encoder shaft connects to a motor.
This tutorial focuses on the KY-040 mechanical encoder, which is by far the most common in Arduino projects.
Wiring KY-040 to Arduino
The KY-040 module has 5 pins:
| KY-040 Pin | Function | Arduino Pin |
|---|---|---|
| CLK (A) | Channel A output | D2 (INT0) |
| DT (B) | Channel B output | D3 (INT1) |
| SW | Push button | D4 |
| + | VCC | 5V |
| GND | Ground | GND |
Connect CLK to D2 and DT to D3 to use hardware interrupts (INT0 and INT1 on Uno). The KY-040 module includes pull-up resistors on CLK and DT, so no external resistors are needed. For the SW (button) pin, enable Arduino’s internal pull-up in code.
Basic Encoder Reading with Polling
The simplest approach — polling — checks the encoder state in each loop iteration. This works for slow human-speed turning but misses steps if the loop contains other slow operations.
const int CLK = 2;
const int DT = 3;
const int SW = 4;
int lastClkState;
int counter = 0;
void setup() {
pinMode(CLK, INPUT);
pinMode(DT, INPUT);
pinMode(SW, INPUT_PULLUP);
Serial.begin(9600);
lastClkState = digitalRead(CLK);
}
void loop() {
int clkState = digitalRead(CLK);
if (clkState != lastClkState) {
if (digitalRead(DT) != clkState) {
counter++; // Clockwise
} else {
counter--; // Counter-clockwise
}
Serial.print("Position: ");
Serial.println(counter);
}
lastClkState = clkState;
if (digitalRead(SW) == LOW) {
Serial.println("Button pressed!");
delay(200); // Simple debounce
}
}
The direction detection works by comparing whether DT matches CLK on the transition. If they’re different (out of phase), the rotation is clockwise; if the same (in phase due to bouncing), it’s counter-clockwise. This is a simplified state check — more robust implementations use a 4-state lookup table.
Interrupt-Driven Reading (Better Approach)
Hardware interrupts fire immediately when a pin changes state, regardless of what the main loop is doing. This is the correct approach for encoders — you’ll never miss a step, even if the loop is busy with display updates or serial communication.
const int CLK = 2;
const int DT = 3;
volatile int counter = 0;
volatile uint8_t lastAB = 0;
// 4-state transition table: -1, 0, or +1
const int8_t ENC_STATES[] = {0,-1,1,0, 1,0,0,-1, -1,0,0,1, 0,1,-1,0};
void encoderISR() {
uint8_t AB = (digitalRead(CLK) << 1) | digitalRead(DT);
uint8_t idx = (lastAB << 2) | AB;
counter += ENC_STATES[idx];
lastAB = AB;
}
void setup() {
pinMode(CLK, INPUT);
pinMode(DT, INPUT);
attachInterrupt(digitalPinToInterrupt(CLK), encoderISR, CHANGE);
attachInterrupt(digitalPinToInterrupt(DT), encoderISR, CHANGE);
Serial.begin(9600);
}
void loop() {
static int lastCount = 0;
int current = counter; // atomic copy
if (current != lastCount) {
Serial.println(current);
lastCount = current;
}
}
The 16-element ENC_STATES lookup table handles all 16 possible 4-state transitions and returns +1, -1, or 0 depending on valid vs. invalid (bounce) transitions. This is the most robust encoder decoding technique — used in professional motion control systems.
Debouncing Encoders Properly
Mechanical encoder contacts bounce — they make and break contact multiple times in the first few milliseconds of each click. Poor debouncing causes missed steps or counted steps in the wrong direction.
Hardware debouncing: Add a 10nF capacitor between each signal pin (CLK, DT) and GND, plus a 10kΩ resistor in series with the signal. The RC circuit low-passes the bounce, presenting a clean transition to the Arduino.
Software debouncing: The ENC_STATES lookup table approach above handles most bounce automatically by rejecting invalid state transitions. Alternatively, add a minimum time check between counted transitions (10–20ms is typical for mechanical encoders).
Using the Encoder library: Install the Encoder library by Paul Stoffregen from Library Manager. It automatically uses interrupts and handles debouncing:
#include <Encoder.h>
Encoder myEnc(2, 3); // CLK on 2, DT on 3
void loop() {
long newPos = myEnc.read();
// Divide by 4 for detent-based counting
// (encoder produces 4 transitions per click)
Serial.println(newPos / 4);
}
The library returns counts in units of 4 (one per transition edge), so divide by 4 to get the number of physical detent clicks.
Project 1: LCD Menu Navigation
This is the classic rotary encoder application — scroll through menu items by rotating and press the shaft to select. We’ll use a 16×2 LCD with I2C backpack.
#include <Encoder.h>
#include <LiquidCrystal_I2C.h>
Encoder enc(2, 3);
LiquidCrystal_I2C lcd(0x27, 16, 2);
const char* menuItems[] = {"Temperature", "Humidity", "Pressure", "Settings", "About"};
const int MENU_SIZE = 5;
int menuIndex = 0;
long lastEncPos = 0;
void setup() {
lcd.init();
lcd.backlight();
pinMode(4, INPUT_PULLUP); // SW pin
displayMenu();
}
void displayMenu() {
lcd.clear();
lcd.setCursor(0, 0);
lcd.print("> ");
lcd.print(menuItems[menuIndex]);
int nextIdx = (menuIndex + 1) % MENU_SIZE;
lcd.setCursor(0, 1);
lcd.print(" ");
lcd.print(menuItems[nextIdx]);
}
void loop() {
long encPos = enc.read() / 4;
if (encPos != lastEncPos) {
int delta = encPos - lastEncPos;
menuIndex = (menuIndex + delta + MENU_SIZE) % MENU_SIZE;
lastEncPos = encPos;
displayMenu();
}
if (digitalRead(4) == LOW) {
lcd.clear();
lcd.print("Selected:");
lcd.setCursor(0, 1);
lcd.print(menuItems[menuIndex]);
delay(1500); // Show selection
displayMenu();
delay(300); // Button debounce
}
}
The modulo arithmetic (% MENU_SIZE) wraps the menu index so it rolls from the last item back to the first and vice versa — creating a circular menu. The cursor symbol > indicates the currently selected item, and the second line shows the next item for context.
Project 2: Absolute Position Tracking
For robotics and CNC applications, you need to track absolute position across multiple revolutions. This example tracks a rotating arm with home-position detection.
#include <Encoder.h>
Encoder enc(2, 3);
const int HOME_SWITCH = 5; // Limit switch at home position
const int PPR = 20; // Pulses per revolution
bool isHomed = false;
long homeOffset = 0;
float getAngleDegrees() {
long rawCounts = enc.read() / 4;
if (!isHomed) return 0;
long countsFromHome = rawCounts - homeOffset;
return (countsFromHome % PPR) * (360.0 / PPR);
}
void homeEncoder() {
// Slowly rotate until home switch triggers
// (Motor control code depends on your driver)
while (digitalRead(HOME_SWITCH) == HIGH) {
delay(10); // Wait for home switch
}
homeOffset = enc.read() / 4;
isHomed = true;
Serial.println("Homed successfully");
}
void setup() {
Serial.begin(9600);
pinMode(HOME_SWITCH, INPUT_PULLUP);
homeEncoder();
}
void loop() {
Serial.print("Angle: ");
Serial.print(getAngleDegrees());
Serial.println(" degrees");
delay(100);
}
The homing sequence finds a known reference position (limit switch), then all subsequent angles are calculated relative to that home. This is exactly how CNC machines and 3D printers find their origin at startup.
Project 3: Stepper Motor Speed Control
Use the encoder as a variable speed control for a stepper motor — turning clockwise increases speed, counter-clockwise decreases it. The encoder’s push button starts/stops the motor.
#include <Encoder.h>
#include <AccelStepper.h>
Encoder enc(2, 3);
AccelStepper stepper(AccelStepper::DRIVER, 8, 9); // STEP, DIR
long lastEncPos = 0;
int motorSpeed = 0;
bool running = false;
void setup() {
pinMode(4, INPUT_PULLUP); // Encoder button
stepper.setMaxSpeed(1000);
stepper.setAcceleration(200);
Serial.begin(9600);
}
void loop() {
long encPos = enc.read() / 4;
if (encPos != lastEncPos) {
motorSpeed = constrain(motorSpeed + (encPos - lastEncPos) * 50, -1000, 1000);
lastEncPos = encPos;
stepper.setSpeed(running ? motorSpeed : 0);
Serial.print("Speed: "); Serial.println(motorSpeed);
}
if (digitalRead(4) == LOW) {
running = !running;
stepper.setSpeed(running ? motorSpeed : 0);
delay(300);
}
stepper.runSpeed();
}
Each encoder click changes speed by 50 steps/second. The constrain() function limits speed to -1000/+1000 (negative for reverse direction). The AccelStepper library handles the step pulse timing, freeing the main loop to read the encoder continuously.
Frequently Asked Questions
Why does my encoder skip steps or count in the wrong direction?
Skipping is usually caused by contact bounce without proper debouncing, or by polling-based reading when the loop is too slow. Switch to interrupt-driven reading with the Encoder library or the ENC_STATES lookup table approach. Wrong direction is simply the A/B channels being swapped — swap the CLK and DT wires or multiply the counter by -1 in code.
Can I use a rotary encoder on any Arduino pin?
For polling, yes — any digital pin works. For interrupt-driven reading, you need interrupt-capable pins. On Arduino Uno, only D2 and D3 support hardware interrupts. On Nano, same: D2 and D3. On Mega 2560, pins 2, 3, 18, 19, 20, and 21 support interrupts — allowing up to 3 encoders. The Encoder library also supports pin-change interrupts on any pin (slower but works).
What’s the difference between incremental and absolute encoders?
An incremental encoder (like KY-040) outputs pulses as it rotates but doesn’t know its absolute position — it counts from wherever it started. If you power-cycle it, position is lost. An absolute encoder (like AS5048) outputs a unique code for every shaft position and retains absolute position even without power. Incremental encoders are cheaper; absolute encoders are essential for applications where home calibration is impractical.
How do I use multiple encoders with one Arduino?
On Arduino Mega, use interrupt-capable pins (2, 3, 18, 19, 20, 21) for up to 3 encoders simultaneously. For more, use PCF8574 I2C GPIO expanders with the Encoder library’s software pin-change mode. Each encoder needs its own Encoder object in code.
Why does my encoder’s button have contact bounce too?
Yes, the push button inside a KY-040 bounces just like any mechanical switch. Use a 200ms delay after detecting a press (as in the menu project above), or use a proper software debounce routine that requires the pin to be stable for 50ms before registering a press. The Bounce2 library handles this elegantly.
Ready to start your rotary encoder project? Shop our complete range of Arduino boards and modules at Zbotic — quality components with fast delivery across India.
Add comment