This tutorial shows how to build an ESP32 web server that displays a web page with multiple sliders. The sliders control the duty cycle of different PWM channels to control the brightness of multiple LEDs. Instead of LEDs, you can use this project to control DC motors or other actuators that require a PWM signal. The communication between the clients and the ESP32 is done using WebSocket protocol. Additionally, whenever there’s a change, all clients update their slider values simultaneously.

You can also modify the code presented in this tutorial to add sliders to your projects to set threshold values or any other values you need to use in your code.
For this project, the ESP32 board will be programmed using the Arduino core. You can either use the Arduino IDE, VS Code with PlatformIO, or any other suitable IDE.
To better understand how this project works, we recommend taking a look at the following tutorials:
* This project shows how to build a web server with one slider, but it uses HTTP requests—in this tutorial, we’ll use WebSocket protocol.
We have a similar tutorial for the ESP8266 NodeMCU board:
The following image shows the web page we’ll build for this project:





Before proceeding with this tutorial, make sure you check all the following prerequisites.
To follow this project you need:
You don’t need three LEDs to test this project, you can simply see the results in the Serial Monitor or use other actuators that required a PWM signal to operate.
You can use the preceding links or go directly to MakerAdvisor.com/tools to find all the parts for your projects at the best price!
We’ll program the ESP32 using Arduino IDE. So, you must have the ESP32 add-on installed. Follow the next tutorial if you haven’t already:
If you want to use VS Code with the PlatformIO extension, follow the next tutorial instead to learn how to program the ESP32:
To upload the HTML, CSS, and JavaScript files needed to build this project to the ESP32 flash memory (LittleFS), we’ll use a plugin for Arduino IDE: LittleFS Filesystem uploader. Follow the next tutorial to install the filesystem uploader plugin if you haven’t already:
If you’re using VS Code with the PlatformIO extension, read the following tutorial to learn how to upload files to the filesystem:
To build this project, you need to install the following libraries:
You can install the libraries using the Arduino Library Manager. Go to Sketch > Include Library > Manage Libraries and search for the libraries’ names.
Wire three LEDs to the ESP32. We’re using GPIOs 12, 13, and 14. You can use any other suitable GPIOs.

Recommended reading: ESP32 Pinout Reference: Which GPIO pins should you use?
To keep the project organized and make it easier to understand, we’ll create four files to build the web server:

You should save the HTML, CSS, and JavaScript files inside a folder called data inside the Arduino sketch folder, as shown in the previous diagram. We’ll upload these files to the ESP32 filesystem (LittleFS).
You can download all project files:
Copy the following to the index.html file.
<!-- Complete project details: https://randomnerdtutorials.com/esp32-web-server-websocket-sliders/ --> <!DOCTYPE html> <html> <head> <title>ESP IOT DASHBOARD</title> <meta name="viewport" content="width=device-width, initial-scale=1"> <link rel="icon" type="image/png" href="favicon.png"> <link rel="stylesheet" type="text/css" href="style.css"> </head> <body> <div class="topnav"> <h1>Multiple Sliders</h1> </div> <div class="content"> <div class="card-grid"> <div class="card"> <p class="card-title">Fader 1</p> <p class="switch"> <input type="range" onchange="updateSliderPWM(this)" id="slider1" min="0" max="100" step="1" value ="0" class="slider"> </p> <p class="state">Brightness: <span id="sliderValue1"></span> %</p> </div> <div class="card"> <p class="card-title"> Fader 2</p> <p class="switch"> <input type="range" onchange="updateSliderPWM(this)" id="slider2" min="0" max="100" step="1" value ="0" class="slider"> </p> <p class="state">Brightness: <span id="sliderValue2"></span> %</p> </div> <div class="card"> <p class="card-title"> Fader 3</p> <p class="switch"> <input type="range" onchange="updateSliderPWM(this)" id="slider3" min="0" max="100" step="1" value ="0" class="slider"> </p> <p class="state">Brightness: <span id="sliderValue3"></span> %</p> </div> </div> </div> <script src="script.js"></script> </body> </html>
Let’s take a quick look at the most relevant parts of the HTML file.
The following tags create the card for the first slider (Fader 1).
<div class="card"> <p class="card-title">Fader 1</p> <p class="switch"> <input type="range" onchange="updateSliderPWM(this)" id="slider1" min="0" max="100" step="1" value ="0" class="slider"> </p> <p class="state">Brightness: <span id="sliderValue1"></span> %</p> </div>
The first paragraph displays a title for the card (Fader 1). You can change the text to whatever you want.
<p class="card-title">Fader 1</p>
To create a slider in HTML you use the <input> tag. The <input> tag specifies a field where the user can enter data.
There are a wide variety of input types. To define a slider, use the type attribute with the range value. In a slider, you also need to define the minimum and the maximum range using the min and max attributes (in this case, 0 and 100, respectively).
You also need to define other attributes like:
The slider is inside a paragraph with the switch class name. So, here are the tags that actually create the slider.
<p class="switch"> <input type="range" onchange="updateSliderPWM(this)" id="slider1" min="0" max="100" step="1" value ="0" class="slider"> </p>
Finally, there’s a paragraph with a <span> tag, so that we can insert the current slider value in that paragraph by referring to its id (id=”sliderValue1″).
<p class="state">Brightness: <span id="sliderValue1"></span> %</p>
To create more sliders, you need to copy all the HTML tags that create the complete card. First, however, you need to consider that you need a unique id for each slider and slider value. In our case, we have three sliders with the following ids: slider1, slider2, slider3, and three placeholders for the slider value with the following ids: sliderValue1, sliderValue2, sliderValue3.
For example, here’s the card for slider number 2.
<div class="card"> <p class="card-title"> Fader 2</p> <p class="switch"> <input type="range" onchange="updateSliderPWM(this)" id="slider2" min="0" max="100" step="1" value ="0" class="slider"> </p> <p class="state">Brightness: <span id="sliderValue2"></span> %</p> </div>
Copy the following to the style.css file.
/* Complete project details: https://randomnerdtutorials.com/esp32-web-server-websocket-sliders/ */ html { font-family: Arial, Helvetica, sans-serif; display: inline-block; text-align: center; } h1 { font-size: 1.8rem; color: white; } p { font-size: 1.4rem; } .topnav { overflow: hidden; background-color: #0A1128; } body { margin: 0; } .content { padding: 30px; } .card-grid { max-width: 700px; margin: 0 auto; display: grid; grid-gap: 2rem; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); } .card { background-color: white; box-shadow: 2px 2px 12px 1px rgba(140,140,140,.5); } .card-title { font-size: 1.2rem; font-weight: bold; color: #034078 } .state { font-size: 1.2rem; color:#1282A2; } .slider { -webkit-appearance: none; margin: 0 auto; width: 100%; height: 15px; border-radius: 10px; background: #FFD65C; outline: none; } .slider::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 30px; height: 30px; border-radius: 50%; background: #034078; cursor: pointer; } .slider::-moz-range-thumb { width: 30px; height: 30px; border-radius: 50% ; background: #034078; cursor: pointer; } .switch { padding-left: 5%; padding-right: 5%; }
Let’s take a quick look at the relevant parts of the CSS file that style the slider. In this example, we need to use the vendor prefixes for the appearance attribute.
.slider { -webkit-appearance: none; margin: 0 auto; width: 100%; height: 15px; border-radius: 10px; background: #FFD65C; outline: none; } .slider::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 30px; height: 30px; border-radius: 50%; background: #034078; cursor: pointer; } .slider::-moz-range-thumb { width: 30px; height: 30px; border-radius: 50% ; background: #034078; cursor: pointer; } .switch { padding-left: 5%; padding-right: 5%; }
Vendor Prefixes
Vendor prefixes allow a browser to support new CSS features before they become fully supported. The most commonly used browsers use the following prefixes:
Vendor prefixes are temporary. Once the properties are fully supported by the browser you use, you don’t need them. You can use the following reference to check if the property you’re using needs prefixes: shouldiprefix.com
Let’s take a look at the .slider selector (styles the slider itself):
.slider { -webkit-appearance: none; margin: 0 auto; width: 100%; height: 15px; border-radius: 10px; background: #FFD65C;outline: none; }
Setting -webkit-appearance to none overrides the default CSS styles applied to the slider in Google Chrome, Safari, and Android browsers.
-webkit-appearance: none;
Setting the margin to 0 auto aligns the slider inside its parent container.
margin: 0 auto;
The width of the slider is set to 100% and the height to 15px. The border-radius is set to 10px.
margin: 0 auto; width: 100%; height: 15px; border-radius: 10px;
Set the background color for the slider and set the outline to none.
background: #FFD65C; outline: none;
Then, format the slider handle. Use -webkit- for Chrome, Opera, Safari and Edge web browsers and -moz- for Firefox.
.slider::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 30px; height: 30px; border-radius: 50%; background: #034078; cursor: pointer; } .slider::-moz-range-thumb { width: 30px; height: 30px; border-radius: 50% ; background: #034078; cursor: pointer; }
Set the -webkit-appearance and appearance properties to none to override default properties.
-webkit-appearance: none; appearance: none;
Set a specific width, height and border-radius for the handler. Setting the same width and height with a border-radius of 50% creates a circle.
width: 30px; height: 30px; border-radius: 50%;
Then, set a color for the background and set the cursor to a pointer.
background: #034078; cursor: pointer;
Feel free to play with the slider properties to give it a different look.
Copy the following to the script.js file.
// Complete project details: https://randomnerdtutorials.com/esp32-web-server-websocket-sliders/ var gateway = `ws://${window.location.hostname}/ws`; var websocket; window.addEventListener('load', onload); function onload(event) { initWebSocket(); } function getValues(){ websocket.send("getValues"); } function initWebSocket() { console.log('Trying to open a WebSocket connection…'); websocket = new WebSocket(gateway); websocket.onopen = onOpen; websocket.onclose = onClose; websocket.onmessage = onMessage; } function onOpen(event) { console.log('Connection opened'); getValues(); } function onClose(event) { console.log('Connection closed'); setTimeout(initWebSocket, 2000); } function updateSliderPWM(element) { var sliderNumber = element.id.charAt(element.id.length-1); var sliderValue = document.getElementById(element.id).value; document.getElementById("sliderValue"+sliderNumber).innerHTML = sliderValue; console.log(sliderValue); websocket.send(sliderNumber+"s"+sliderValue.toString()); } function onMessage(event) { console.log(event.data); var myObj = JSON.parse(event.data); var keys = Object.keys(myObj); for (var i = 0; i < keys.length; i++){ var key = keys[i]; document.getElementById(key).innerHTML = myObj[key]; document.getElementById("slider"+ (i+1).toString()).value = myObj[key]; } }
Here’s a list of what this code does:
Let’s take a look at this JavaScript code to see how it works.
The gateway is the entry point to the WebSocket interface. window.location.hostname gets the current page address (the web server IP address).
var gateway = ws://${window.location.hostname}/ws;
Create a new global variable called websocket.
var websocket;
Add an event listener that will call the onload function when the web page loads.
window.addEventListener('load', onload);
The onload() function calls the initWebSocket() function to initialize a WebSocket connection with the server.
function onload(event) { initWebSocket(); }
The initWebSocket() function initializes a WebSocket connection on the gateway defined earlier. We also assign several callback functions for when the WebSocket connection is opened, closed, or when a message is received.
function initWebSocket() { console.log('Trying to open a WebSocket connection…'); websocket = new WebSocket(gateway); websocket.onopen = onOpen; websocket.onclose = onClose; websocket.onmessage = onMessage; }
Note that when the websocket connection in open, we’ll call the getValues function.
function onOpen(event) { console.log('Connection opened'); getValues(); }
The getValues() function sends a message to the server getValues to get the current value of all sliders. Then, we must handle what happens when we receive that message on the server side (ESP32).
function getStates(){ websocket.send("getValues"); }
We handle the messages received via websocket protocol on the onMessage() function.
function onMessage(event) { console.log(event.data); var myObj = JSON.parse(event.data); var keys = Object.keys(myObj); for (var i = 0; i < keys.length; i++){ var key = keys[i]; document.getElementById(key).innerHTML = myObj[key]; document.getElementById("slider"+ (i+1).toString()).value = myObj[key]; } }
The server sends the states in JSON format, for example:
{
sliderValue1 : 20;
sliderValue2: 50;
sliderValue3: 0;
}The onMessage() function simply goes through all the values and places them on the corresponding places on the HTML page.
The updateSliderPWM() function runs when you move the sliders.
function updateSliderPWM(element) { var sliderNumber = element.id.charAt(element.id.length-1); var sliderValue = document.getElementById(element.id).value; document.getElementById("sliderValue"+sliderNumber).innerHTML = sliderValue; console.log(sliderValue); websocket.send(sliderNumber+"s"+sliderValue.toString()); }
This function gets the value from the slider and updates the corresponding paragraph with the right value. This function also sends a message to the server so that the ESP32 updates the LED brightness.
websocket.send(sliderNumber+"s"+sliderValue.toString());
The message is sent in the following format:
For example, if you move slider number 3 to position 40, it will send the following message:
3s40
Copy the following code to your Arduino IDE or to the main.cpp file if you’re using PlatformIO.
/* Rui Santos & Sara Santos - Random Nerd Tutorials Complete project details at https://RandomNerdTutorials.com/esp32-web-server-websocket-sliders/ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. */ #include <Arduino.h> #include <WiFi.h> #include <AsyncTCP.h> #include <ESPAsyncWebServer.h> #include "LittleFS.h" #include <Arduino_JSON.h> // Replace with your network credentials const char* ssid = "REPLACE_WITH_YOUR_SSID"; const char* password = "REPLACE_WITH_YOUR_SSID"; // Create AsyncWebServer object on port 80 AsyncWebServer server(80); // Create a WebSocket object AsyncWebSocket ws("/ws"); // Set LED GPIO const int ledPin1 = 12; const int ledPin2 = 13; const int ledPin3 = 14; String message = ""; String sliderValue1 = "0"; String sliderValue2 = "0"; String sliderValue3 = "0"; int dutyCycle1; int dutyCycle2; int dutyCycle3; // setting PWM properties const int freq = 5000; const int ledChannel1 = 0; const int ledChannel2 = 1; const int ledChannel3 = 2; const int resolution = 8; //Json Variable to Hold Slider Values JSONVar sliderValues; //Get Slider Values String getSliderValues(){ sliderValues["sliderValue1"] = String(sliderValue1); sliderValues["sliderValue2"] = String(sliderValue2); sliderValues["sliderValue3"] = String(sliderValue3); String jsonString = JSON.stringify(sliderValues); return jsonString; } // Initialize LittleFS void initFS() { if (!LittleFS.begin()) { Serial.println("An error has occurred while mounting LittleFS"); } else{ Serial.println("LittleFS mounted successfully"); } } // Initialize WiFi void initWiFi() { WiFi.mode(WIFI_STA); WiFi.begin(ssid, password); Serial.print("Connecting to WiFi .."); while (WiFi.status() != WL_CONNECTED) { Serial.print('.'); delay(1000); } Serial.println(WiFi.localIP()); } void notifyClients(String sliderValues) { ws.textAll(sliderValues); } void handleWebSocketMessage(void *arg, uint8_t *data, size_t len) { AwsFrameInfo *info = (AwsFrameInfo*)arg; if (info->final && info->index == 0 && info->len == len && info->opcode == WS_TEXT) { data[len] = 0; message = (char*)data; if (message.indexOf("1s") >= 0) { sliderValue1 = message.substring(2); dutyCycle1 = map(sliderValue1.toInt(), 0, 100, 0, 255); Serial.println(dutyCycle1); Serial.print(getSliderValues()); notifyClients(getSliderValues()); } if (message.indexOf("2s") >= 0) { sliderValue2 = message.substring(2); dutyCycle2 = map(sliderValue2.toInt(), 0, 100, 0, 255); Serial.println(dutyCycle2); Serial.print(getSliderValues()); notifyClients(getSliderValues()); } if (message.indexOf("3s") >= 0) { sliderValue3 = message.substring(2); dutyCycle3 = map(sliderValue3.toInt(), 0, 100, 0, 255); Serial.println(dutyCycle3); Serial.print(getSliderValues()); notifyClients(getSliderValues()); } if (strcmp((char*)data, "getValues") == 0) { notifyClients(getSliderValues()); } } } void onEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, AwsEventType type, void *arg, uint8_t *data, size_t len) { switch (type) { case WS_EVT_CONNECT: Serial.printf("WebSocket client #%u connected from %s\n", client->id(), client->remoteIP().toString().c_str()); break; case WS_EVT_DISCONNECT: Serial.printf("WebSocket client #%u disconnected\n", client->id()); break; case WS_EVT_DATA: handleWebSocketMessage(arg, data, len); break; case WS_EVT_PONG: case WS_EVT_ERROR: break; } } void initWebSocket() { ws.onEvent(onEvent); server.addHandler(&ws); } void setup() { Serial.begin(115200); pinMode(ledPin1, OUTPUT); pinMode(ledPin2, OUTPUT); pinMode(ledPin3, OUTPUT); initFS(); initWiFi(); // Set up LEDC pins ledcAttachChannel(ledPin1, freq, resolution, ledChannel1); ledcAttachChannel(ledPin2, freq, resolution, ledChannel2); ledcAttachChannel(ledPin3, freq, resolution, ledChannel3); initWebSocket(); // Web Server Root URL server.on("/", HTTP_GET, [](AsyncWebServerRequest *request){ request->send(LittleFS, "/index.html", "text/html"); }); server.serveStatic("/", LittleFS, "/"); // Start server server.begin(); } void loop() { ledcWrite(ledPin1, dutyCycle1); ledcWrite(ledPin2, dutyCycle2); ledcWrite(ledPin3, dutyCycle3); ws.cleanupClients(); }
Let’s take a quick look at the relevant parts for this project. To better understand how the code works, we recommend following this tutorial about WebSocket protocol with the ESP32 and this tutorial about PWM with the ESP32.
Insert your network credentials in the following variables to connect the ESP32 to your local network:
const char* ssid = "REPLACE_WITH_YOUR_SSID"; const char* password = "REPLACE_WITH_YOUR_PASSWORD";
The getSliderValues() function creates a JSON string with the current slider values.
String getSliderValues(){ sliderValues["sliderValue1"] = String(sliderValue1); sliderValues["sliderValue2"] = String(sliderValue2); sliderValues["sliderValue3"] = String(sliderValue3); String jsonString = JSON.stringify(sliderValues); return jsonString; }
The notifyClients() function notifies all clients with the current slider values. Calling this function is what allows us to notify changes in all clients whenever you set a new position for a slider.
void notifyClients(String sliderValues) { ws.textAll(sliderValues); }
The handleWebSocketMessage(), as the name suggests, handles what happens when the server receives a message from the client via WebSocket protocol. We’ve seen in the JavaScript file, that the server can receive the getValues message or a message with the slider number and the slider value.
When it receives the getValues message, it sends the current slider values.
if (strcmp((char*)data, "getValues") == 0) { notifyClients(getSliderValues()); }
If it receives another message, we check to which slider corresponds the message and update the corresponding duty cycle value. Finally, we notify all clients that a change occurred. Here’s an example for slider 1:
if (message.indexOf("1s") >= 0) { sliderValue1 = message.substring(2); dutyCycle1 = map(sliderValue1.toInt(), 0, 100, 0, 255); Serial.println(dutyCycle1); Serial.print(getSliderValues()); notifyClients(getSliderValues()); }
In the loop(), we update the duty cycle of the PWM channels to adjust the brightness of the LEDs.
void loop() { ledcWrite(ledPin1, dutyCycle1); ledcWrite(ledPin2, dutyCycle2); ledcWrite(ledPin3, dutyCycle3); ws.cleanupClients(); }
After inserting your network credentials, save the code. Go to Sketch > Show Sketch Folder, and create a folder called data.

Inside that folder you should save the HTML, CSS and JavaScript files.
Then, upload the code to your ESP32 board. Make sure you have the right board and COM port selected. Also, make sure you’ve added your network credentials.
After uploading the code, you need to upload the files to the filesystem.
Press [Ctrl] + [Shift] + [P] on Windows or [⌘] + [Shift] + [P] on MacOS to open the command palette. Search for the Upload LittleFS to Pico/ESP8266/ESP32 command and click on it.
If you don’t have this option is because you didn’t install the filesystem uploader plugin. Check this tutorial.

Important: make sure the Serial Monitor is closed before uploading to the filesystem. Otherwise, the upload will fail.
When everything is successfully uploaded, open the Serial Monitor at a baud rate of 115200. Press the ESP32 EN/RST button, and it should print the ESP32 IP address.
Open a browser on your local network and paste the ESP32 IP address. You should get access to the web server page to control the brightness of the LEDs.

Move the sliders to control the brightness of the LEDs.

Copyright ©2025. All Rights Reserved Emblab THE RAVE INNOVATION