Featured resource
Tech Upskilling Playbook 2025
Tech Upskilling Playbook

Build future-ready tech teams and hit key business milestones with seven proven plays from industry leaders.

Learn more
  • Labs icon Lab
  • Core Tech
Labs

Guided: Enhancing User Engagement with JavaScript

In this lab, you'll gain hands-on practice with JavaScript interaction fundamentals like event handling, implement practical features like drag-and-drop support, and learn how to animate your content with CSS managed by JS through the Web Animations API.

Labs

Path Info

Level
Clock icon Beginner
Duration
Clock icon 58m
Last updated
Clock icon Aug 21, 2025

Contact sales

By filling out this form and clicking submit, you acknowledge our privacy policy.

Table of Contents

  1. Challenge

    Introduction

    This lab will guide you through a series of small but widely applicable JavaScript implementations to enhance front-end experiences and boost user engagement. The lab content assumes you're already somewhat familiar with JavaScript, HTML, CSS, and the DOM.

    Some notes:

    • You'll do all your coding in src/main.html. Per best practices, it's recommended you keep strict mode enabled by leaving 'use strict' as the first line in the <script> area.
    • You can click the refresh button in the Web Browser tab to see the effects of the changes you've made.
    • Sometimes you'll replace your previous content in the same file. The first lines of the task will specify either way.
    • If you get stuck on a task, you can consult the solutions/ folder.
  2. Challenge

    Event Binding

    In web programming, an event is like when your phone chimes — it's a signal that something happened that may warrant some kind of action in response. Maybe your phone's "incoming text" chime plays, but you ignore it for the moment — making a mental note to check your texts later — because you're focusing on a code lab right now. Or maybe it rings because there's an incoming call, and you glance at the screen to see if it's that important call you're waiting for.

    Just as you listen for these signals and respond a certain way depending on the type of chime, your web code can listen for specific events and take associated action when they occur. Instead of incoming texts or calls, web events are things like a timer going off, a CSS transition completing, a document finishing loading, or your end user pointing, clicking, or typing.

    Consider the following HTML:

    <div id="square"></div>
    
    <select id="mySelector">
      <option>ABC</option>
      <option>DEF</option>
      <option>GHI</option>
    </select>
    

    The select element automatically emits a built-in 'change' event when a user selects an option. (Another common way to say this: The event "fires.") In this case, there are several ways the user could do this. For example:

    1. They can press Tab to put the keyboard focus on the element, then press Space or Enter. Or, they could click or tap the element once.
      1. Either way, a dropdown menu will appear, and they can use the mouse to hover, or use the arrow keys, to highlight an option. But the event doesn't fire yet.
      2. If they then hit Enter while an option is highlighted, the event fires.
      3. If they click or tap an option, the event fires.
    2. They can press Tab to put the keyboard focus on the element, then use the arrow keys. Each time an arrow keypress actually changes the current option, the event fires.

    Often you only need to know when the current option selection changes and what it changes to, but you don't need to know exactly how the user changes it. Thankfully, when this is the case, you only need your code to listen for the 'change' event.

    To that end, you need a function to handle the event whenever it fires. The only parameter the function takes is an event object, which has useful members like target. In this case, target will be the select element, whose value corresponds to that of the newly selected option element:

    function handleSelectorChange(event) {
      document.getElementById('square').textContent = event.target.value
    }
    

    Listening for and handling this event is as straightforward as passing the above function in a call to the element's addEventListener method:

    var select = document.getElementById('mySelector')
    select.addEventListener('change', handleSelectorChange)
    

    Next, you'll use the above pattern to implement a selector for the background color of the square <div> element. Built-in events cover many common use cases, but you can also create, listen for, and "dispatch" (emit, fire) your own custom events.

    CustomEvents are created with the CustomEvent constructor, where you can specify an event name and whatever you like in the details that they should carry to their event handlers:

    var colorChangeEvent = new CustomEvent('colorChange', {
      detail: { color: someColor },
    })
    

    Dispatching such an event is done with an element reference and its dispatchEvent method:

    someElement.dispatchEvent(colorChangeEvent)
    

    The event name you use in the constructor is used (instead of 'change') to listen for it:

    someElement.addEventListener('colorChange', handleColorChange)
    

    Next, you'll use this pattern to add a layer to the event flow of your previous task solution. Instead of changing the square's color directly, you'll use the original 'change' event to, in turn, fire a custom 'colorChange' event to the square itself. You're right to wonder — if the code is longer and more involved, and the app behavior from a user's perspective is the same, why go to this trouble? Indeed, in simple situations like the scenario used for this task, it doesn't make sense to add a custom event layer.

    In more complicated situations, however, this approach is a way to identify events that are more specific to your app's context than built-in events are. For example, say you were building an interface that had not only a select element but also a color wheel and RGB sliders. If the handlers for each sub-interface change the square color and also have to update each other to stay in sync, there's a lot of duplication and accompanying maintenance burden. Instead the handlers can dispatch a 'colorChange' event. Better still, when some new requirement comes along — e.g., that user color selections should also be logged to a server somewhere — you can add a single new 'colorChange' listener for that, rather than bloating your existing centralized handler.

  3. Challenge

    Form Validation

    Thanks to HTML5's built-in constraint validation semantics, form validation often doesn't require any JavaScript at all. Take the following form code, for example:

    <form>
      <input type="password" id="new_password" minlength="8" required />
      <input type="submit" value="Set new password" />
    </form>
    

    Given the minlength and required attributes, browsers won't let the form be submitted without a password having at least eight characters. Better still, they'll automatically generate an appropriate pop-up when user input doesn't meet the specified requirements. It's straightforward to have form element styles change correspondingly:

    input:user-invalid {
      background-color: pink;
    }
    

    Using the :user-invalid pseudo-class is similar to another built-in (:invalid) but carries the advantage of waiting for the user to interact with the form field first. It avoids the ugly situation of a form being always initially full of "complaints" about data the user hasn't had a chance to input yet.

    You can do a lot with built-in constraints — even using regular expression pattern matching — but not everything. For example, suppose you would like to prevent users from entering a password that's known to be extremely common and therefore likely to be guessed by a malicious third party. Thankfully, you don't need to abandon the benefits of built-in validation: You can integrate this extra validation requirement seamlessly using the Constraint Validation API.

    The heart of this mechanism lies in the built-in setCustomValidity method of a given form element. Pass it an empty string to signal that a field's input is valid according to your custom constraint, while still leaving the built-in constraints to do their own validation. Pass it any other string, and that's the message that will pop up for the user the next time they try to submit the form.

    For this particular validation against an array of common passwords, you can use the built-an Array.includes function like this:

    if (commonPasswords.includes(field.value)) // (exactly the way it reads)
    

    Validation still happens in response to every keystroke if you use the 'input' event rather than the 'change' event you saw earlier:

    passwordField.addEventListener('input', myHandler) // where myHandler calls setCustomValidity
    

    No message will pop up until subsequent form submissions, but the pseudo-class :user-invalid will meanwhile indicate the live validity status of an element. If your validation used some particularly computation-heavy method on every keystroke, you could avoid it by returning early in cases where user input was failing built-in validations already:

    field.setCustomValidity('')
    if (!field.validity.valid) return
    
  4. Challenge

    Pointer Events

    Form elements aren't the only use case for events, and events can get more fine-grained than you've seen with forms.

    For example, suppose you're building a color selector, starting with this HTML structure:

    <div id="main_square"></div>
    <div id="palette">
      <div></div>
      <div></div>
      <div></div>
    </div>
    

    You then give these divs some sizes and line the palette swatches up horizontally:

    #main_square {
      border: 1px solid black;
      width: 300px;
      height: 300px;
    }
    #palette > div {
      display: inline-block;
      width: 100px;
      height: 100px;
    }
    

    You want to support both mobile and desktop users, and make it so that when they click, tap, hover over, or swipe through a swatch, the main_square changes color to match the event. Thankfully, you don't need to code the same logic multiple times to support touch surfaces, mice, and other pointing devices like stylus pens: You can use pointer events to support them all through a single interface.

    In this case, you need only add event listeners to the palette for 'pointerdown' and 'pointermove', and they can both point to the same function. To get an idea of what data is exposed by pointer events, you decide to have this function also display in the main_square some text indicating several properties of the pointer event it's handling: ${pointerType}: ${pressure} @ ${x}, ${y}.

    But then you notice that on mobile, you encounter difficulty dragging the touch pointer around the palette and the rest of the page. Because of the default way that phone browsers interpret such actions, you see unintentional navigation events (back, forward, reload) and/or scrolling, instead of behavior aligned with how the same page works on desktops/laptops with mice/trackpads.

    To override this, your swatch style definition just needs one more line, touch-action: none;. There are some subtle differences between mouse and touch events that pointer events don't quite capture. For example, say your app needs special behavior when a user holds down a mouse button, then (without releasing the first button) clicks and holds a second (and even third) mouse button. In this situation, only one 'pointerdown' event will fire for the whole sequence, so you would need to use 'mousedown' events to handle this corner case.

    Another example is double-clicking — there is a mouse event for this (hooking into OS-level user preferences around the timing that constitutes a double-click) but no agreed-upon equivalent for touch events. (You could implement the logic for that manually if double-tap support were important to your use case.)

    Both of these cases are uncommon: Pointer events will suffice for most single-pointer applications. What about multi-touch gestures?

    You can modify the pointer demo you just created to see how they work. The biggest difference with multi-touch is that you have to manually track a list of pointerId values from event objects to even know how many currently active touch points there are. Every time a new touch begins, the DOM generates a new pointerId; when an existing touch ends, that ID must be scratched from the list.

    Since multi-touch gestures will take more space, it will be better to listen to events on the whole window instead of just the palette div.

    In the previous task, you had one handler for both 'pointerdown' and 'pointermove', but these must be separated for multi-touch. The previous logic can stay in the "move" handler, though the output being displayed in the square can be changed to show the active ID list. You'll then need the pointerId-tracking logic in separate "down" and "up" handlers:

    var pointerIds = {}
    function pointerDownHandler(event) {
      pointerIds[event.pointerId] = 1
      pointerMoveHandler(event)
    }
    function pointerUpHandler(event) {
      delete pointerIds[event.pointerId]
      pointerMoveHandler(event)
    }
    

    Here, the pointerIds being an object allows quick access; the value of 1 could be anything. (Alternatively, you could use a set.) Both functions call pointerMoveHandler to reuse its now-separate display logic.

    There are several ways a pointer event can end (for example, dragging outside the device or window boundary, a pointing device becoming disconnected, or simply lifting a finger so it's no longer in contact with a touchscreen). In the context of this palette demo, pointerUpHandler can treat four different event types as equivalent and handle all of them: 'pointerup', 'pointerout', 'pointerleave', and 'pointercancel'.

    Lastly, the previous touch-action: none use will work best if moved into a declaration for html. But even then it won't suffice in preventing default multi-touch gestures like pinching and zooming; for that, you need to add the following header tag:

    <meta name="viewport" content="width=device-width, user-scalable=no" />
    

    (The user-scalable=no is what helps, but because it prevents users from zooming, width=device-width makes sure the interface is a decent size to keep the text readable.) This demo displays basic multi-touch debug output, but what about actual gestures? Since these vary by device and application, there's no built-in standard for them. In real-world multi-touch scenarios, you need to implement your own algorithm for gestures like pinching and zooming, or use a library like PinchZoom.js or panzoom that handles the algorithmic basics while allowing you to adjust parameters to suit your specific use case.

  5. Challenge

    Drag and Drop

    Unlike multi-touch gestures, there is straightforward, built-in support for drag-and-drop events. In fact, it's so built-in, even regular text on a web page already has functionality enabled by default — any text that you can select, you can subsequently drag to start a drag-and-drop event sequence:

    <p><b>Normal</b> rich text you can only drag if you first select it.</p>
    

    If you want to make a whole element (of nearly any kind) draggable, all it takes is the addition of one attribute:

    <div id="main_square" draggable="true">
      Some rich text, <b>draggable as a unit</b>.
    </div>
    

    This already makes the div draggable. To also make it droppable, you need to have it listen for the 'dragstart' event, and handle it like this:

    function dragstartHandler(event) {
      var { dataTransfer, target } = event
      dataTransfer.setData('text/html', target.innerHTML)
      dataTransfer.setData('text/plain', target.textContent)
    }
    

    The event's dataTransfer property is a special object that can hold one value for any particular MIME type. (I.e., if you added dataTransfer.setData('text/html', '<b>something else</b>') to the end of the above function, it would override the value for 'text/html', not add to it.) Often it makes sense to set data for several similar MIME types, as this function shows.

    The app on the receiving end of this data (which may or may not be your app) can decide what to do with each part of the data, including ignoring MIME types it doesn't want to handle. You can create two drop zones to see this in action:

    <div id="html_target" class="drop_zone"></div>
    <div id="text_target" class="drop_zone"></div>
    

    Before these can be useful, you must prevent the default browser behavior for drops (just like you had to prevent certain touch event defaults in earlier tasks). I.e., browsers normally don't accept drops on most elements. All that's needed there is to call event.preventDefault() in the handler of a 'dragover' event.

    To actually do something with the dropped data, you need a separate handler for 'drop' events. The API is very similar to drag handlers: You're given a dataTransfer property in the event, and it has a getData method that takes a MIME type.

    It would be handy if users could drag your draggable elements and drop them elsewhere in their operating system. In some cases, they can: The above HTML snippet can be dropped into office apps, other types like image/png data might be droppable into some image-editing apps. On some Linux and Windows desktops, you can even drop text/plain data into a file folder to automatically create a text file. But this isn't consistently supported and only works with plain text.

    So, to export data as a file in a consistently supported way, you need a workaround that uses blobs, which are file-like data storage objects. To create one, you put data in an array (even if you just have one piece of data) and pass that data to a Blob constructor along with an object specifying the MIME type under a key called type:

    var sampleData = [document.getElementById('main_square').innerHTML],
      blob = new Blob(sampleData, { type: 'text/html' })
    

    The more "workaround-feeling" part comes next. You need to create an anchor element (<a>), set its href to represent your blob via URL.createObjectURL, provide a suggested end-user filename, then click this link on the user's behalf — without ever making it visible to the user by adding it to the DOM:

    var a = document.createElement('a')
    a.href = URL.createObjectURL(blob)
    a.download = 'sample.html'
    a.click()
    

    To make the process user-friendly, have this code live in the click handler of a button with a clear label like "Export." The subtle bug you may have noticed is that if you dropped onto the bold part of some previously-dropped text, it didn't replace the contents of the whole drop zone. That's because the event's target is the <b> element, so it's only the bold element's contents that get replaced with your dragged text. The solution is to use the event's currentTarget instead, which is the element the handler is registered on.

    About the draggable attribute — it can also be used to override the default value ("auto") in the other direction. So elements like images, which are draggable by default, can be made non-draggable with draggable="false".

  6. Challenge

    Animation

    User engagement can also be enhanced with CSS transitions and animations. While both provide animation in a sense, animations are the more flexible of the two: They can loop and be more complex, whereas transitions are used more for simple, momentary effects. To see the difference, you can create two elements with the same content:

    <div id="heart">♥</div>
    <div id="heart2">♥</div>
    

    They can have some styling in common:

    #heart,
    #heart2 {
      color: red;
      font-size: 10em;
      display: inline-block;
    }
    

    To animate #heart with an animation, you need a set of keyframes, which define the states that CSS properties must be at different moments during the timeline of the animation. To make #heart beat, you can animate it such that at 0% (the beginning of the timeline), it has transform: scale(1) CSS styling applied to it, then at 50% the scale goes up to 1.2 (the heart gets 20% bigger), then at 100% (the end), it returns to normal size.

    @keyframes heartbeat {
      0%   { transform: scale(1); }  /* implied */
      50%  { transform: scale(1.2); }
      100% { transform: scale(1); }  /* implied */
    }
    

    Here, since 0% and 100% (synonyms: from and to, respectively) both use the default CSS transform property, they can be omitted, so your keyframe definition only needs to mention the 50% marker.

    To use this keyframe definition, you refer to it by the name you gave it above (heartbeat) in the CSS animation property:

    #heart {
      animation: 1s infinite heartbeat;
    }
    

    The animation property is shorthand for up to nine properties. Here, you've specified the equivalent of:

    #heart {
      animation-duration: 1s;
      animation-iteration-count: infinite;
      animation-name: heartbeat;
    }
    

    Since it loops, this same animation isn't possible as a transition, but here's something similar:

    #heart2 {
      transition: transform 0.5s;
    }
    #heart2:hover {
      transform: scale(1.2);
    }
    

    Here, the :hover pseudo-class specifies that #heart2 should be 20% bigger when the user mouses over the heart. The transition property then specifies that any such effects should be applied smoothly over the course of a half-second. (If you moved the transition property to the pseudo-class instead, it would only be applied when the heart grows; the heart would suddenly return to normal size whenever hovering ends.) Just like you can use JavaScript to add CSS to an element, the Web Animations API adds the same ability for animations. As with styles, the semantics for animations differ slightly between JavaScript and CSS. Likewise, JavaScript lets you create animation specifications that are a bit more dynamic than what CSS allows. For example, you could add a click handler to the page that would make the heart rotate to point at the user's click location, which isn't possible in CSS.

    Another Web Animations API feature exposed specifically in JavaScript is the ability to pause, unpause, and skip to the end of animations — even when an element uses multiple animations.

    While sequences aren't yet first-class citizens, multiple animations let you achieve a similar effect. Suppose you want your heartbeat keyframes not to loop forever, but instead be followed by another set of keyframes, heartspin.

    If two time values are specified in the CSS animation property shorthand, the second will be interpreted as the delay to use before the animation starts. You can start heartbeat right away, then heartspin can start after the duration of heartbeat. Just separate their definitions with a comma within the CSS animation property:

    animation: 1s 0s heartbeat, 2s 1s heartspin;
    

    Here, heartbeat runs for 1 second after no delay, then heartspin runs for 2 seconds after waiting 1 second. And the 0s can be omitted, since starting right away is the default — the two shorthand definitions don't need to have completely parallel components.

    But what if you want to try running heartbeat for shorter or longer than 1 second? You'll need to change both instances of 1s. Thankfully, CSS variables can be used to eliminate the duplication:

    :root {
      --beat-duration: 1s;
    }
    /* ... */
    #heart {
      /* ... */
      animation: var(--beat-duration) heartbeat,
                 2s var(--beat-duration) heartspin;
    }
    

    While somewhat less readable, this approach is much more maintainable. Consider if you want heartbeat to run three times before heartspin starts. You can define another variable, --beat-count, then use the calc() feature of CSS to multiply the values of your variables together — i.e. calc(var(--beat-duration) * var(--beat-count)) — as the starting delay for heartspin. The --beat-count can also be included just before heartbeat in the shorthand, to specify the number of loops.

    Though these animations apply to the same element, their timelines are technically independent — hence all the manual timing calculations. What this means when controlling such animations via the Web Animations API is that when you want to pause the heart, you'll have to pause both animations. Otherwise, heartspin will still run after the specified delay.

    To pause, you need to get the animations by calling the getAnimations method (without parameters) of the element in JavaScript. Then, for each animation in the array it returns, call the animation's pause method (without parameters). The same can be done for unpausing via play and skipping to the end via finish. The Web Animations API has another feature commonly used in animation playback, which is the ability to control playback speed. Animations have a updatePlaybackRate method for this. It takes a number as its only parameter as a multiplier for the playback speed (with 0 meaning stopped, though this is internally different from the paused state you get by calling the pause method).

    Though some video playback interfaces use a drop-down menu with set choices (0.5x, 1x, 2x, etc.), it's possible to give more fine-grained control to your users using a range input element:

    <input type="range" id="rateSlider" />
    

    By default, this will have a value property consisting of an integer from 0-100, which will be useful in handling the element's 'input' event. However, you probably don't want to pass that along to updatePlaybackRate unmodified — most users won't want to watch a five-second animation at 100 times its normal speed. To limit the output range to numbers between 0.3 and 3.0, and also ensure finer-grained control on the slower end of the spectrum, you can use this formula in your event handler (assuming its parameter is named event) to find the appropriate value to call updatePlaybackRate with:

    0.3 * 10 ** (event.target.value / 100)
    

    Here, the ** operator means "to the power of." So from input values of 0-100, this gives values from 0.3 (because 10 to the power of 0/100 is 1) to 3.0 (because 10 to the power of 100/100 is 10), smoothed exponentially thanks to the ** operator.

    Note that, even though this input element is outside a <form>, some browsers will remember its last value between page loads. This is true even if you explicitly set it in HTML with value="50". This may or may not be what you want for an animation player, but in the context of this lab, you'll need to force its starting value on page load by setting it via JavaScript.

    Likewise, because the animation is relatively short, it's helpful for manual testing purposes to have all animations start in a paused state. To do this, you can prepend paused to each animation definition, e.g. animation: paused ..., paused .... One last thing: In other contexts you may need to get your element references and add event listeners to them within a 'DOMContentLoaded' event handler added to the window object:

    window.addEventListener('DOMContentLoaded', function() {
      var xyz = document.getElementById('xyz')
      xyz.addEventListener('dragstart', dragstartHandler)
    })
    

    In this lab you didn't need this technique, thanks to the scripts being defined last within the HTML file.

    --

    Congratulations on completing this lab!

Kevin has 25+ years in full-stack development. Now he's focused on PostgreSQL and JavaScript. He's also used Haxe to create indie games, after a long history in desktop apps and Perl back ends.

What's a lab?

Hands-on Labs are real environments created by industry experts to help you learn. These environments help you gain knowledge and experience, practice without compromising your system, test without risk, destroy without fear, and let you learn from your mistakes. Hands-on Labs: practice your skills before delivering in the real world.

Provided environment for hands-on practice

We will provide the credentials and environment necessary for you to practice right within your browser.

Guided walkthrough

Follow along with the author’s guided walkthrough and build something new in your provided environment!

Did you know?

On average, you retain 75% more of your learning if you get time for practice.