User Experience

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.

html
	<main>
	  <div class="loader">Loading...</div>
	</main>
javascript
	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:

javascript
	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.

javascript
	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.

css
	.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

css
	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.

html
	<button class="delete">Delete</button>
javascript
	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:

html
	<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>
javascript
	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.

html
	<main>
	  <progress value="0" max="100"></progress>
	</main>
javascript
	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:

html
	<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:

css
	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:

javascript
	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.

javascript
	window.scrollTo(0, 0);

Combined with some CSS, we can create a smooth scroll animation:

css
	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.

javascript
	window.scrollBy(0, 100);

Let’s wrap this up in a function that scrolls to the top of the page:

javascript
	export function scrollToTop() {
	  window.scrollTo(0, 0);
	}

We can then use this function in our application:

javascript
	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:

html
	<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>
javascript
	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:

html
	<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>
css
	#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;
	}
javascript
	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.