Introduction
In this lesson we will cover a range of features that enhance the user experience of our web applications. This includes providing feedback to the user when the application is loading, and animating elements on the page.
Loading
When a user interacts with our application, we want to provide feedback to the user that the application is loading. This is especially important when the application is making a request to a server, as this can take a long time. Users that do not receive feedback on a long running task are more likely to abandon the page.
The basic concept of a loader
is that we show the user a visual animation that informs them that a short wait is required. When the long running task has finished, we can remove or hide the loader element.
Basic Loader
Let’s start with a very basic loader, using the word “Loading…” instead of an animation.
<main>
<div class="loader">Loading...</div>
</main>
function showLoader() {
const loader = document.querySelector('.loader');
loader.hidden = false;
}
function hideLoader() {
const loader = document.querySelector('.loader');
loader.hidden = true;
}
export default { show: showLoader, hide: hideLoader };
We can then use this code in our application:
import loader from './loader.js';
import { getPosts } from './api.js';
import { renderPosts } from './render.js';
async function app() {
loader.show();
const posts = await getPosts();
renderPosts(posts);
loader.hide();
}
app();
Here we are showing the loader when we start to load the data, and hiding it again when the data has been loaded.
Error Handling
In order to correctly handle errors in UI functions, we need to make sure that we always hide the loader, even if an error occurs. We can do this by using a try...catch
block.
async function app() {
loader.show();
try {
const posts = await getPosts();
renderPosts(posts);
} catch (error) {
alert(error);
} finally {
loader.hide();
}
}
app();
This means that the loader will always be hidden, even if an error occurs.
Animated Loader
Instead of using the word “Loading…”, we can use an animation to show the user that the application is loading. We can use CSS to create a simple animation.
.loader {
width: 1em;
height: 1em;
border: 0.25em solid;
border-bottom-color: transparent;
border-radius: 50%;
display: inline-block;
box-sizing: border-box;
animation: rotation 1s linear infinite;
}
@keyframes rotation {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
The size of the loader can be changed by setting the font-size of the parent element or loader element
main .loader {
font-size: 2em;
}
There are many hundreds of existing loader animations that use pure CSS to create an animation effect. You can view more here.
User Feedback
Providing a loader or spinner animation during a long task is a form of user feedback. We can also provide feedback to the user when they interact with the application.
Confirm Click
In some cases, we may want to protect an action from being performed accidentally. For example, we may want to ask the user to confirm that they want to delete a post.
<button class="delete">Delete</button>
const deleteButton = document.querySelector('.delete');
deleteButton.addEventListener('click', () => {
const confirmed = confirm('Are you sure you want to delete this post?');
if (confirmed) {
// Delete the post
}
});
The confirm
function will show a dialog box with the message “Are you sure you want to delete this post?“. The user can then click “OK” or “Cancel”. If the user clicks “OK”, the confirm
function will return true
. If the user clicks “Cancel”, the confirm
function will return false
.
We could also use a custom dialog element instead of the built-in confirm
function:
<button class="delete">Delete</button>
<div class="dialog">
<p>Are you sure you want to delete this post?</p>
<button value="true" name="choice">Yes</button>
<button value="false" name="choice">No</button>
</div>
const dialog = document.querySelector('.dialog');
const deleteButton = document.querySelector('.delete');
const confirmButton = dialog.querySelector('button[value="true"]');
const cancelButton = dialog.querySelector('button[value="false"]');
deleteButton.addEventListener('click', () => {
dialog.show();
});
confirmButton.addEventListener('click', () => {
dialog.close();
// Delete the post
});
cancelButton.addEventListener('click', () => {
dialog.close();
// Do nothing
});
This allows us to customise the dialog box, and with some additional code, also to use the same dialog box for multiple actions.
Progress
When a long running task is being performed, we can provide feedback to the user on the progress of the task. This is especially useful when the task is a file upload or download.
We can combine the HTML <progress>
element with the fetch function to show the progress of a download.
<main>
<progress value="0" max="100"></progress>
</main>
const progressElement = document.querySelector('progress');
const exampleImage = 'https://picsum.photos/2000/2000';
export async function fetchWithProgress(url, updateProgress) {
const response = await fetch(url);
const reader = response.body.getReader();
const contentLength = +response.headers.get('Content-Length');
let receivedLength = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
receivedLength += value.length;
updateProgress((receivedLength / contentLength * 100).toFixed(0));
}
return response;
}
async function app() {
const response = await fetchWithProgress(exampleImage, (progress) => {
progressElement.value = progress;
});
}
app()
Although much more complicated than a standard fetch operation, this function will provide feedback to the user on the progress of the download. This is particularly useful with very large files that require the user’s patience. The advantage of a progress bar compared with a loader is that the user can see roughly how much longer they need to wait.
Drag and Drop
Aside from providing feedback when an action has been taken, we can also provide visual feedback when the user is interacting with the application. One example of this is drag and drop - a common feature in many modern applications.
Let’s setup a simple HTML page with a list of ingredients that can be dragged into a recipe:
<main>
<div class="ingredients">
<h1>Ingredients</h1>
<ul>
<li draggable="true">Flour</li>
<li draggable="true">Eggs</li>
<li draggable="true">Milk</li>
<li draggable="true">Butter</li>
</ul>
</div>
<div class="recipe">
<h1>Recipe</h1>
<ul></ul>
</div>
</main>
We can then add some CSS to style the page:
ul {
list-style: none;
padding: 0;
padding-bottom: 1rem;
}
.recipe {
border: 1px dashed;
}
ul li {
padding: 0.5em;
border: 1px solid #ccc;
margin-bottom: 0.5em;
}
ul li:hover {
background-color: #eee;
}
We can then add some JavaScript to handle the drag and drop functionality:
function setupDragAndDrop() {
const ingredients = document.querySelectorAll('.ingredients li');
const recipe = document.querySelector('.recipe ul');
// Function to handle the dragstart event
function handleDragStart(event) {
event.dataTransfer.setData('text/plain', event.target.textContent);
}
// Add dragstart event listeners to ingredients
ingredients.forEach(ingredient => {
ingredient.addEventListener('dragstart', handleDragStart);
});
// Function to handle the dragover event
function handleDragOver(event) {
event.preventDefault(); // Necessary to allow dropping
}
// Function to handle the drop event
function handleDrop(event) {
event.preventDefault();
const ingredient = event.dataTransfer.getData('text/plain');
recipe.appendChild(document.createElement('li')).textContent = ingredient;
}
// Add dragover and drop event listeners to the recipe list
recipe.addEventListener('dragover', handleDragOver);
recipe.addEventListener('drop', handleDrop);
}
// Call the function to set up drag and drop
setupDragAndDrop();
This functionality can be used to create a wide range of drag and drop features. For example, we could allow the user to drag and drop items to reorder them, or to drag and drop items into a trash can to delete them.
Scroll
There are many situations where we may want to control or access the scroll position of the page. For example, we may want to scroll to the top of the page when the user clicks a button, or we may want to scroll to a specific element on the page.
Likewise, we may want to trigger certain actions or animations when the user scrolls to a specific point on the page. We can do this by using the Intersection Observer API.
Programmatic Scroll
We can use the scrollTo
function to scroll to a specific position on the page. This function accepts two arguments: the x and y coordinates to scroll to.
window.scrollTo(0, 0);
Combined with some CSS, we can create a smooth scroll animation:
html {
scroll-behavior: smooth;
}
Similar to scrollTo
, we can use the scrollBy
function to scroll by a specific amount. This function also accepts two arguments: the x and y coordinates to scroll by.
window.scrollBy(0, 100);
Let’s wrap this up in a function that scrolls to the top of the page:
export function scrollToTop() {
window.scrollTo(0, 0);
}
We can then use this function in our application:
import { scrollToTop } from './scroll.js';
const button = document.querySelector('button');
button.addEventListener('click', scrollToTop);
Intersection Observer
The Intersection Observer API allows us to observe when an element enters or leaves the viewport. This is useful for triggering animations or other actions when the user scrolls to a specific point on the page.
Let’s create a simple example that shows an alert when the user scrolls to the bottom of the page:
<main>
<article>
<h1>Contents</h1>
<p style="height: 300vh;">Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatum.</p>
<div id="observer-target" style="height: 1px;"></div>
</article>
</main>
function setupObserver() {
const target = document.getElementById('observer-target');
const observer = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// Triggered when the target element comes into view
alert('You have reached the end of the content!');
// Optional: Unobserve the target after the alert
observer.unobserve(target);
}
});
}, {
root: null, // Observing relative to the viewport
threshold: 1.0 // Fully in view
});
observer.observe(target);
}
setupObserver();
When the target element comes into view, the alert will be shown. Let’s look at another simple example, changing the colour of the body element when the user scrolls halfway down the page:
<main>
<article>
<h1>Contents</h1>
<p style="height: 300vh;">Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatum.</p>
<div id="observer-target" style="height: 1px; position: absolute; top: 50%"></div>
</article>
</main>
#observer-target {
height: 50vh;
background: blue;
width: 50%;
position: absolute;
top: 100vh;
}
#observer-target:after {
content: "";
position: absolute;
top: 50%;
width: 100%;
bottom: 0;
background: cyan;
}
function setupObserver() {
const target = document.getElementById('observer-target');
const observer = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// Triggered when the target element comes into view
document.body.style.backgroundColor = 'red';
} else {
document.body.style.backgroundColor = 'unset';
}
});
}, {
root: null, // Observing relative to the viewport
threshold: 0.5
});
observer.observe(target);
}
setupObserver()
In this example, the target is much bigger, visible and positioned midway down the page using absolute positioning. There is also a marker to show the halfway point of the target, which is when the effect will be triggered.
We can control how much of the target should be in view before the effect should be triggered by using the threshold
option. This is a number between 0 and 1, where 0 is when the target first comes into view, and 1 is when the target is fully in view.
Lesson Task
Create a simple application to fetch and show a list of products. Use the UX features in this lesson to improve the experience for the user. Show a loader animation while the user is waiting for the data to load, and create a drag and drop system for moving each product to the shopping cart. When the user reaches the end of the list of products, show an alert with the text “No more products!“.
Additional Goals
- Use localStorage to save the basket contents and render these on page load.
- Use Interaction Observer to show a button to scroll to the top of the page when the user scrolls past the header.