Introduction
In this tutorial we are going to make the User Interface of our application look better by adding some CSS to style our HTML elements. We will follow the same approach from Part 2 and also serve the CSS file from the ESP32 file system. The expected look is shown below in figure 1.
As can be seen in the image, all the elements (buttons, text inputs) will have a slightly rounded border. We have moved all the connection related elements to the top (user name input and connect and disconnect buttons). The elements to send messages are kept at the bottom of the UI.
The element that contains the chat messages has a maximum height and if the elements inside pass that height, a scroll will be displayed. Each row of the chat contains the name of the participant and the message sent. The name of the participant will be inside a tag to highlight it.
The whole UI container will have a slight shade around the borders to stand out from the rest of the page.
Like before, to serve the CSS file from the ESP32, we need to add a route to the HTTP server. The HTML file will also need to reference this file. We will also need to do some changes to the HTML code and to the JavaScript code, to adapt to the new UI.
For a detailed tutorial on how to serve CSS files from the ESP32, please check here.
The tests shown below were performed on a ESP32-E FireBeetle board from DFRobot. The Arduino core version used was 2.0.0 and the Arduino IDE version was 1.8.15, working on Windows 8.1. The code was also tested on Platformio.
The CSS code
The first thing we will do is defining a CSS variable. As we could see in figure 1, most of the elements that compose our UI have a slightly rounded border, which has the same value for all of them. Thus, instead of repeating that value when defining the border-radius property in all of these elements, we just reuse the same variable.
Another advantage of using this variable is that we can more easily play around with the UI to fine tune the values that look better. If we had this value defined per class and we wanted to change it globally in the browser inspector, we would have to adjust it individually. With a variable, we can just change it in the browser inspector and see all of the elements adapt accordingly.
In order for this variable to be available globally, we will define it inside the root pseudo class. I will define a value of 10px, which is the one used in figure 1, but you can play around with this variable to make the elements more or less round-looking.
:root {
--border-radius: 10px;
}
Then we will define two classes that are unsemantic, but will be used to position the elements. In this case, since they will be just having a property each, we will follow the convention suggested here: the name of the class will start with a “u”, followed by the name of the property, followed by the value it sets. Between the name of the property and the value, we will use two dashes, since properties in CSS may have dashes in names themselves.
The first one will allow us to set display property of an element to flex.
.u-display--flex{
display: flex;
}
The second one will allow to set an element to have a flex-grow value of 1. You can read here a very good explanation on how flex-grow behaves. In our particular case, in the divs that contain both text and buttons, we will use a value of flex-grow equal to 1 on the text boxes so they can grow to occupy all the available size that is left. This will allow us to keep the buttons with the same size and allow the text input to grow to fill the rest of the div.
.u-flex-grow--1{
flex-grow:1;
}
In order to style our button, we will define a class called “btn”.
.btn{
/* button attributes */
}
We will set its background color to a light blue, so we can change it to a darker blue when the user hovers the mouse, thus making a nice effect.
background-color:#4eb5f1;
We will set its borders to be round, reusing the already defined variable that holds the global border-radius value we want to apply to all elements.
border-radius: var(--border-radius);
We will set the border of the button to none so we don’t need to worry about changing also its color when the button is hovered or disabled. Instead, without a border set, we will only need to change the background color of the button.
border: none;
Then we will set the color of the text of the button to white. Note that the color attribute we will be using for this should not be confused with the background-color attribute.
color:#ffffff;
To avoid having buttons of different sizes depending on the text they contain, we will set a minimum width of 120px.
min-width: 120px;
Then we will set some margins and paddings for our button. You can read more about these two properties here. Basically, the margins will allow to set some space between the buttons and other elements (controls the space outside the element), and the paddings will allow us to set some space between the contents of the button (the text) and its border (controls the space inside the element).
margin: 0 4px 4px 0;
padding: 8px 12px;
The whole “btn” class is available below.
.btn{
background-color:#4eb5f1;
border-radius: var(--border-radius);
border: none;
color:#ffffff;
margin: 0 4px 4px 0;
min-width: 120px;
padding: 8px 12px;
}
In order to give a visual indication to the user when a button is disabled, we will change its background to a different color when on that state. To do so, we can leverage the disabled selector.
.btn:disabled{
background-color:#cccccc;
}
Similarly, as we already mentioned, we want to assign a darker blue color to the button when the user hovers the mouse. Once again, we can resort to two different selectors: the hover and the enabled. We need to make use of the enabled selector after the hover selector because the user can hover a disabled button and, in that case, we don’t want it to change from grey to dark blue.
.btn:hover:enabled{
background-color: #4095c6;
}
Moving on to the class for our text inputs (we wil call this class “text-input“), we will start by defining a grey solid border, also slightly rounded.
border: 1px solid #ced4da;
border-radius: var(--border-radius);
Besides that, we will add some margin and padding. The complete class is available below.
.text-input{
border: 1px solid #ced4da;
border-radius: var(--border-radius);
margin:0 4px 4px 0;
padding: 4px 12px;
}
When disabled, we will set the background of the text input to grey, with a lighter tone than the borders.
.text-input:disabled{
background-color:#e9ecef;
}
When we select the text input, we will add an outline, to make the element stand out and give the visual indication that it is currently selected. We will use the focus selector to apply the outline only when the input field is selected.
.text-input:focus{
outline: 1px solid #ced4da;
}
As we can see in figure 1, the part of the UI that contains the chat has a scroll bar. Basically, in order to avoid that div to grow indefinitely, we will set its height to 300px and an overflow for when the content doesn’t fit that size. This will add a scrollbar to the right side of the div in case the content doesn’t fit, so we can scroll through all the content on the 300px div.
We will call “chat” to the class that will define the mentioned style.
.chat{
height: 300px;
overflow: auto;
}
Note however that, besides what we have already set, the chat UI area we see in figure 1 has a gray rounded border. Nonetheless, if we just added this same rounded border to the same div that has the scroll (which is rectangular), that scroll would render on top of the rounded corners, and also too close to the border.
To prevent this, in the HTML, we will add an outer div around the chat div (we will call it “chat-container“), which will be the one setting the border. This way, we can use the padding to push the inner content (the chat div, which is the that contains the scroll) away from the borders, thus giving a reasonable amount of space between the overflow scroll and the borders.
We are also going to add a margin, to set some space between the whole chat UI element and the other elements.
.chat-container{
border: 1px solid #ced4da;
border-radius: var(--border-radius);
margin: 0 4px 4px 0;
padding: 6px 2px;
}
Inside the chat, we could see that the names of the participants are inside a green tag. This tag corresponds to a span element with a style that will be called “chat-tag”.
This tag will have a green background and a rounded border, like the other elements. We will also add it a margin and a padding. We will se the display attribute to “inline-block” in order for all the margins and paddings to be respected (which doesn’t happen with inline elements), but still allowing other elements to appear after them (in our case, the message from that participant).
.chat-tag{
background-color: #aee5cb;
border-radius: var(--border-radius);
display: inline-block;
margin: 10px;
padding: 4px;
}
We could also see that we have a title in our UI, which is basically a center-aligned header with some margin from the top of the page.
.title{
margin-top: 50px;
text-align: center;
}
Since we want to add some space between the div that contains the connection elements and the chat div, we will add a class to set some bottom margin.
.connect-row{
margin-bottom:20px;
}
Finally, we will define a class to set the style of the div that wraps the main content (all the UI elements except the title).
.main-container{
/* main container attributes*/
}
we will set its maximum width to 500px, so it doesn’t horizontally occupy the whole page.
max-width: 500px;
We will also set margins and padding. Notice the usage of “auto” for the right and left margins, to center the content, which will ensure that the remaining space not occupied by the div will be equally splited by the left and right margins.
margin: 50px auto;
padding: 30px 54px;
Finally, we will set the border to be round and also set the box-shadow attribute to add a shadow around the element. You can check here in more detail the syntax to define this attribute.
border-radius: var(--border-radius);
box-shadow: 0px 1px 34px #999;
The complete class is shown below.
.main-container{
border-radius: var(--border-radius);
box-shadow: 0px 1px 34px #999;
max-width: 500px;
margin: 50px auto;
padding: 30px 54px;
}
The complete CSS can be seen below.
:root {
--border-radius: 10px;
}
.u-display--flex{
display:flex;
}
.u-flex-grow--1{
flex-grow: 1;
}
.connect-row{
margin-bottom: 20px;
}
.main-container{
border-radius: var(--border-radius);
box-shadow: 0px 1px 34px #999;
max-width: 500px;
margin: 50px auto;
padding: 30px 54px;
}
.btn{
background-color:#4eb5f1;
border-radius: var(--border-radius);
border: none;
color: #ffffff;
margin: 0 4px 4px 0;
min-width: 120px;
padding: 8px 12px;
}
.btn:disabled{
background-color: #cccccc;
}
.btn:hover:enabled{
background-color: #4095c6;
}
.text-input{
border: 1px solid #ced4da;
border-radius: var(--border-radius);
margin: 0 4px 4px 0;
padding: 4px 12px;
}
.text-input:disabled{
background-color: #e9ecef;
}
.text-input:focus{
outline: 1px solid #ced4da;
}
.chat-container{
border: 1px solid #ced4da;
border-radius: var(--border-radius);
margin: 0 4px 4px 0;
padding: 6px 2px;
}
.chat{
height: 300px;
overflow: auto;
}
.chat-tag{
background-color: #aee5cb;
border-radius: var(--border-radius);
display: inline-block;
margin: 10px;
padding: 4px;
}
.title{
margin-top: 50px;
text-align: center;
}
The HTML
The HTML will also have some slight changes, to properly render the UI from figure 1. Also, we need to add the correct classes to the elements, as we will see below.
Like in part 2 of this tutorial, we will include the JS file in the head of the document. Additionally, we will also use a link tag to include the CSS file we have defined before.
<head>
<script src="chat.js"></script>
<link rel="stylesheet" href="chat.css">
</head>
Next we will analyze the body.
<body>
<!-- BODY ELEMENTS -->
</body>
We will start by adding a div with the h1 element inside that will contain the title of our chat. This div will have the class “title” we have defined before in our CSS.
<div class="title">
<h1>ESP32 CHAT</h1>
</div>
After that, still inside the body of the document, we will analyze the remaining elements of the UI, which are contained inside a wrapper div. The class name for this div will be “main-container“.
<div class = "main-container">
<!-- UI ELEMENTS -->
</div>
Now, inside this wrapper div, we will start analyzing each row of elements that compose our UI (each row basically will be a div with the elements inside). The first one will be the row that contains the elements that allow a user to connect to / disconnect from the chat.
First, we will add the class “u-display–flex” to this div, in order for us to later have control of how much the input will be able to grow. We will also add the class “connect-row” to this div, which adds a bottom margin.
<div class = "u-display--flex connect-row">
<!-- CONNECTION ELEMENTS -->
</div>
The first element will be a text input. It will have the class “text-input“, which adds the style we defined earlier for the look of the text input with the gray border, and the class “u-flex-grow–1“, which will ensure that this text element will grow to occupy all the remaining space that is not taken by the buttons.
<input type = "text" id = "nameText" class = "text-input u-flex-grow--1"></input>
After this, we will add both the connect and disconnect buttons, with the class “btn“.
<button onclick = "OpenWebsocket()" id = "connectButton" class="btn">CONNECT</button>
<button onclick = "CloseWebsocket()" disabled id = "disconnectButton" class="btn">DISCONNECT</button>
The row with the connection elements can be seen below.
<div class = "u-display--flex connect-row">
<input type = "text" id = "nameText" class = "text-input u-flex-grow--1"></input>
<button onclick = "OpenWebsocket()" id = "connectButton" class="btn">CONNECT</button>
<button onclick = "CloseWebsocket()" disabled id = "disconnectButton" class="btn">DISCONNECT</button>
</div>
After that we have the chat row. As we have mentioned before, while defining the CSS, we will need a wrapper div, which will render the borders, and an inner div, which will render the overflow. We will assign to these the “chat-container” and the “chat” classes, respectively. Note that, like we have been doing in the previous tutorials, we will leave the inner div without any content, since when the application starts there is no content. This will be added later dynamically by the JS code.
<div class="chat-container">
<div id = "chatDiv" class="chat">
</div>
</div>
To finalize, we need to add the row that allows a user to send new messages. Once again, we start with a div with the “u-display–flex” class. Inside, we will have a text input with the “text-input” and the “u-flex-grow–1” classes. After this input, we will have the button to send the message.
<div class = "u-display--flex">
<input type = "text" disabled id = "inputText" class="text-input u-flex-grow--1"></input>
<button onclick = "SendData()" disabled id = "sendButton" class="btn">SEND</button>
</div>
The complete code can be seen below.
<!DOCTYPE html>
<html>
<head>
<script src="chat.js"></script>
<link rel="stylesheet" href="chat.css">
</head>
<body >
<div class="title">
<h1>ESP32 CHAT</h1>
</div>
<div class = "main-container">
<div class = "u-display--flex connect-row">
<input type = "text" id = "nameText" class = "text-input u-flex-grow--1"></input>
<button onclick = "OpenWebsocket()" id = "connectButton" class="btn">CONNECT</button>
<button onclick = "CloseWebsocket()" disabled id = "disconnectButton" class="btn">DISCONNECT</button>
</div>
<div class="chat-container">
<div id = "chatDiv" class="chat">
</div>
</div>
<div class = "u-display--flex">
<input type = "text" disabled id = "inputText" class="text-input u-flex-grow--1"></input>
<button onclick = "SendData()" disabled id = "sendButton" class="btn">SEND</button>
</div>
</div>
</body>
</html>
Local testing HTML and CSS
Before trying to test the end to end system, it is a good idea to make sure that the CSS and the HTML we wrote before will look as expected. We don’t need to serve it from the ESP32 neither to have a functioning JavaScript code just to render the UI and do some basic tests.
In the snippet below, you can find a version only with the HTML and the CSS inline, with all the JS removed. Note that I’ve kept the function calls in the onclick attributes, but naturally nothing will happen because no function is defined on this testing version.
<!DOCTYPE html>
<html>
<head>
<style>
:root {
--border-radius: 10px;
}
.u-display--flex{
display:flex;
}
.u-flex-grow--1{
flex-grow:1;
}
.connect-row{
margin-bottom:20px;
}
.main-container{
border-radius: var(--border-radius);
box-shadow: 0px 1px 34px #999;
max-width: 500px;
margin: 50px auto;
padding: 30px 54px;
}
.btn{
background-color:#4eb5f1;
border-radius: var(--border-radius);
border: none;
color: #ffffff;
margin:0 4px 4px 0;
min-width: 120px;
padding: 8px 12px;
}
.btn:disabled{
background-color:#cccccc;
}
.btn:hover:enabled{
background-color: #4095c6;
}
.text-input{
border: 1px solid #ced4da;
border-radius: var(--border-radius);
margin:0 4px 4px 0;
padding: 4px 12px;
}
.text-input:disabled{
background-color:#e9ecef;
}
.text-input:focus{
outline: 1px solid #ced4da;
}
.chat-container{
border:1px solid #ced4da;
border-radius: var(--border-radius);
margin:0 4px 4px 0;
padding:6px 2px;
}
.chat{
height: 300px;
overflow:auto;
}
.chat-tag{
background-color: #aee5cb;
border-radius: var(--border-radius);
display: inline-block;
margin: 10px;
padding: 4px;
}
.title{
margin-top: 50px;
text-align: center;
}
</style>
</head>
<body>
<div class="title">
<h1>ESP32 CHAT</h1>
</div>
<div class = "main-container">
<div class = "u-display--flex connect-row">
<input type = "text" id = "nameText" class = "text-input u-flex-grow--1"></input>
<button onclick = "OpenWebsocket()" id = "connectButton" class="btn">CONNECT</button>
<button onclick = "CloseWebsocket()" disabled id = "disconnectButton" class="btn">DISCONNECT</button>
</div>
<div class="chat-container">
<div id = "chatDiv" class="chat">
<div>
<span class="chat-tag">name</span>
<span>This is a sample text.</span>
</div>
<div>
<span class="chat-tag">name</span>
<span>This is a sample text.</span>
</div>
<div>
<span class="chat-tag">name</span>
<span>This is another sample text.</span>
</div>
<div>
<span class="chat-tag">name</span>
<span>This is a sample text.</span>
</div>
<div>
<span class="chat-tag">name</span>
<span>This is another longer sample text.</span>
</div>
<div>
<span class="chat-tag">name</span>
<span>This is a sample text.</span>
</div>
<div>
<span class="chat-tag">name</span>
<span>This is another longer sample text.</span>
</div>
<div>
<span class="chat-tag">name</span>
<span>This is yet another piece of sample text.</span>
</div>
<div>
<span class="chat-tag">name</span>
<span>This is yet another piece of sample text.</span>
</div>
<div>
<span class="chat-tag">name</span>
<span>This is a sample text.</span>
</div>
<div>
<span class="chat-tag">name</span>
<span>This is a sample text.</span>
</div>
</div>
</div>
<div class = "u-display--flex">
<input type = "text" disabled id = "inputText" class="text-input u-flex-grow--1"></input>
<button onclick = "SendData()" disabled id = "sendButton" class="btn">SEND</button>
</div>
</div>
</body>
</html>
Note that, in the previous code snippet, we have added some content to the chat div, so we can see the scroll and test it.
In order to test this locally in a computer, simply copy the previous snippet and save it on a .html file with any name of your choice. Then, open the file a web browser and you should see the rendered content.
The JavaScript code
Our JavaScript code will be similar to what we have covered in the previous parts, except that when adding a new entry to the chat we now need to add a div with some nested span elements, instead of just a paragraph. This is necessary for us to be able to display the name inside the green tag, followed by the actual content of the message.
From the previous tutorials, we can recall that adding new messages was done on the onmessage handler of the websocket. The previous code is shown below, as a refresher:
ws.onmessage = function(event) {
const receivedObj = JSON.parse(event.data);
const textToDisplay = `${receivedObj.name}: ${receivedObj.msg}`;
const newChatEntryElement = document.createElement('p');
newChatEntryElement.textContent = textToDisplay;
const chatDiv = document.getElementById("chatDiv");
chatDiv.appendChild(newChatEntryElement);
};
The first thing we will do is still parsing the received event.
const receivedObj = JSON.parse(event.data);
Next, instead of creating only a paragraph, we will create a div and two span elements. At this stage, they are not related yet.
const newChatEntryElement = document.createElement('div');
const newTag = document.createElement('span');
const newMsg = document.createElement('span');
Then we will set the text of the tag span to contain the name of the user and the message span to contain the actual content of the message.
newTag.textContent = receivedObj.name;
newMsg.textContent = receivedObj.msg;
We will also add the “chat-tag” class to the corresponding span, so it has the look we have defined before.
newTag.classList.add('chat-tag');
Finally, we will add the tag span and the message span as childs of the div we created, and then we add the div to the list of elements of the chat.
newChatEntryElement.appendChild(newTag);
newChatEntryElement.appendChild(newMsg);
const chatDiv = document.getElementById("chatDiv");
chatDiv.appendChild(newChatEntryElement);
As such, the complete handling function should look like the snippet below.
ws.onmessage = function(event) {
const receivedObj = JSON.parse(event.data);
const newChatEntryElement = document.createElement('div');
const newTag = document.createElement('span');
const newMsg = document.createElement('span');
newTag.textContent = receivedObj.name;
newMsg.textContent = receivedObj.msg;
newTag.classList.add('chat-tag');
newChatEntryElement.appendChild(newTag);
newChatEntryElement.appendChild(newMsg);
const chatDiv = document.getElementById("chatDiv");
chatDiv.appendChild(newChatEntryElement);
};
Although we haven’t changed anything else, below you can see the complete JavaScript necessary for the application to run.
var ws = null;
var name = null;
function OpenWebsocket() {
const nameTextElement = document.getElementById("nameText");
name = nameTextElement.value;
nameTextElement.value = '';
const url = `ws://${location.hostname}/chat`;
ws = new WebSocket(url);
ws.onopen = function() {
document.getElementById("inputText").disabled = false;
document.getElementById("sendButton").disabled = false;
document.getElementById("disconnectButton").disabled = false;
document.getElementById("connectButton").disabled = true;
document.getElementById("nameText").disabled = true;
};
ws.onclose = function() {
document.getElementById("inputText").disabled = true;
document.getElementById("sendButton").disabled = true;
document.getElementById("disconnectButton").disabled = true;
document.getElementById("connectButton").disabled = false;
document.getElementById("nameText").disabled = false;
document.getElementById("chatDiv").innerHTML = '';
};
ws.onmessage = function(event) {
const receivedObj = JSON.parse(event.data);
const newChatEntryElement = document.createElement('div');
const newTag = document.createElement('span');
const newMsg = document.createElement('span');
newTag.textContent = receivedObj.name;
newMsg.textContent = receivedObj.msg;
newTag.classList.add('chat-tag');
newChatEntryElement.appendChild(newTag);
newChatEntryElement.appendChild(newMsg);
const chatDiv = document.getElementById("chatDiv");
chatDiv.appendChild(newChatEntryElement);
};
}
function CloseWebsocket(){
ws.close();
}
function SendData(){
const inputTextElement = document.getElementById("inputText");
const msg = inputTextElement.value;
inputTextElement.value = '';
const objToSend = {
msg: msg,
name: name
}
const serializedObj = JSON.stringify(objToSend);
ws.send(serializedObj);
}
The Arduino code
Finally, we need to adapt our code to serve the CSS file we have added to our application. In the previous tutorial, we had already seen how to serve the HTML and the JS files in two distinct routes. As such, in the Arduino setup, when configuring the HTTP server, we will just add a new route for the CSS file to be served.
server.on("/chat.css", HTTP_GET, [](AsyncWebServerRequest *request){
request->send(SPIFFS, "/chat.js", "text/css");
});
Don’t forget that you will need to upload the CSS file to the SPIFFS file system before running the code (the ile should be called “chat.css” and it should be uploaded to the root of the file system, like the other two files). Since we have done changes both to the HTML and the JS files, you also need to re-upload the new versions. If you need a refresher on how to perform this upload, please check part 2.
The full code is available below.
#include <WiFi.h>
#include <ESPAsyncWebServer.h>
#include <SPIFFS.h>
const char* ssid = "yourNetworkName";
const char* password = "yourNetworkPassword";
AsyncWebServer server(80);
AsyncWebSocket ws("/chat");
void onWsEvent(AsyncWebSocket * server, AsyncWebSocketClient * client, AwsEventType type, void * arg, uint8_t *data, size_t len){
if(type == WS_EVT_CONNECT){
Serial.println("Websocket client connection received");
} else if(type == WS_EVT_DISCONNECT){
Serial.println("Client disconnected");
} else if(type == WS_EVT_DATA){
ws.textAll(data, len);
Serial.print("Data received: ");
for(int i=0; i < len; i++) {
Serial.print((char) data[i]);
}
Serial.println();
}
}
void setup(){
Serial.begin(115200);
if(!SPIFFS.begin()){
Serial.println("An Error has occurred while mounting SPIFFS");
return;
}
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) {
delay(1000);
Serial.println("Connecting to WiFi..");
}
Serial.println(WiFi.localIP());
ws.onEvent(onWsEvent);
server.addHandler(&ws);
server.on("/chat", HTTP_GET, [](AsyncWebServerRequest *request){
request->send(SPIFFS, "/chat.html", "text/html");
});
server.on("/chat.js", HTTP_GET, [](AsyncWebServerRequest *request){
request->send(SPIFFS, "/chat.js", "text/javascript");
});
server.on("/chat.css", HTTP_GET, [](AsyncWebServerRequest *request){
request->send(SPIFFS, "/chat.css", "text/css");
});
server.begin();
}
void loop(){}
Testing the system
The first thing to be done is to upload the HTML, JavaScript and CSS files to the ESP32 SPIFFS file system. This procedure was covered in more detail on the previous part of this tutorial. This time, we need to also add the “chat.css” file.
Then, compile the Arduino code and upload it to your ESP32 using a tool of your choice (Arduino IDE, Platformio). Once the procedure finishes, open a serial monitor tool to get the output. After the ESP32 finishes the connection to the WiFi network, you should obtain the IP address assigned to it. Copy it, as it will be needed to reach the server.
Then, open a web browser of your choice (the result show in figure 1 was tested on Chrome) and type the following, changing #yourEsp32Ip# by the IP address you have just copied:
http://#yourEsp32Ip#/chat
You should see a UI similar to figure 1. Then, try to connect different devices to the chat (or open multiple tabs in the same device) and the sent messages should appear. Figure 2 shows an example of the UI on the web browser of a smartphone, receiving messages from two instances of the chat running on a computer.
Do you have a link to the project files