When building a component or a webpage, you can get pretty far with just HTML and CSS but you'll often find yourself hitting a wall when you want to add interactivity or make things dynamic. Of course this is where JavaScript comes in.
But maybe it sounds too daunting to learn a whole programming language just to enhance your CSS. Turns out that most use cases can be reduced to just a handful of "recipes". In this post, I will show you some of those common recipes and how they can help you take your UIs to the next level.
This post is meant to be beginner friendly (including for designers who haven't used JS much), so you may already be familiar with a lot of the content in here. I encourage you to use the table of contents to skip to the parts that interest you the most.
Prerequisites
We're not going to learn everything about JavaScript, but it helps to start with some extremely basic terminology.Variables: This one is simple if you've used CSS custom properties or Sass variables. A variable is where you store a value and give it a name. Use the
let
keyword to define a variable (you can also use const
if the value doesn't need to change, but please never use the var
keyword!). You can assign a value to a variable using =
.
Functions: If you've used functions or mixins in Sass, it's a similar idea. Functions let you write reusable blocks of code that you can call from multiple places. You can identify them from the
function
keyword or the =>
(arrow) symbol. Functions can optionally accept "arguments" (think of them like input), perform operations on those arguments, and optionally "return" a value (think of it like an output).
Arrays: Similar to lists in Sass, arrays can contain one or more items (e.g. an array of numbers, or an array of buttons). What's really cool is that arrays provides lots of different ways to access those items and do things with them. For example, if there is an array called
numbers
, you can access the first number in it using numbers[0]
, and you can loop over all numbers using numbers.forEach
.
Objects: An object is a special type of value which consists of properties. Each of these properties can be assigned any kind of value (including functions and arrays), and you can access these properties using the dot notation. For example, if there is an object
tea
which has a property flavor
, you can access it using tea.flavor
.
This was an extremely quick overview of the terminology you'll need for understanding the recipes in this post. If you want a more detailed write-up, check out Dan Abramov's "What is JavaScript Made Of?".
Basics ("ingredients")
We'll start with some very simple recipes (you can even think of them as "ingredients" for the advanced recipes). You can try these in the browser console.
Accessing DOM elements
DOM elements represent html elements such as <button>
and <div>
. The webpage itself is represented by document
.
There are many ways to access a DOM element, but my favorite way is to use the built-in querySelector
and querySelectorAll
functions. Both of these functions accept a CSS selector as the argument, and can be called from document
or even from another element. querySelector
returns the first instance of the selector that it finds, whereas querySelectorAll
returns a list of all instances of that selector.
In the example below, we find the first <section>
element in the page and store it in a variable called firstSection
. Then inside that section, we find all <button>
elements with the class .text-button
and store them in a variable called textButtons
. Since this is an array, we can then access the individual buttons from it.
let firstSection = document.querySelector('section');
let textButtons = firstSection.querySelectorAll('button.text-button');
let firstTextButtonInSection = textButtons[0];
There are also a couple special elements which you don't have to query for: document.documentElement
will give you the <html>
element, and document.body
will give you the <body>
element.
You can access the parent of an element (let's say the element is stored in a variable called el
) using el.parentElement
and you can access its first or second child using el.children[0]
and el.children[1]
respectively.
Lastly, you can create an empty DOM element such as <div></div>
using document.createElement('div')
.
Modifying DOM elements
Now that we have access to the elements, we can make changes to certain aspects of it. For these examples, let's assume we have a DOM element in a variable called el
.
Changing attributes
You can get the current attribute value using getAttribute
, assign a new value using setAttribute
, toggle a boolean value using toggleAttribute
, and remove an attribute using removeAttribute
. The following snippet shows how to toggle the value of aria-hidden
using a ternary operator. We can't use toggleAttribute
in this case because aria-hidden
is not a boolean attribute.
let oldVal = el.getAttribute('aria-hidden');
let newVal = oldVal === 'true' ? 'false' : 'true';
el.setAttribute(`aria-hidden`, newVal);
In many cases, you can actually access the attributes directly using dot notation. For example, you can make a checkbox use the indeterminate state (which is only possible using JavaScript):
let el = document.querySelector('[type=checkbox]');
el.indeterminate = true;
However, with this method, the attribute names are camelCase instead of kebab-case.
el.ariaHidden = 'true';
There is also a special way of accessing data-*
attributes: using dataset
. The following snippet sets data-active="true"
on the element.
el.dataset.active = 'true';
Changing styles and class names
Possibly the most common thing you would want to do is programmatically change the styles or class names.
The following snippet is the equivalent of setting style="color: rebeccapurple; background-color: thistle"
on an element.
el.style.color = 'rebeccapurple';
el.style.backgroundColor = 'thistle';
This is particularly useful when combined with CSS custom properties which you can manipulate with getPropertyValue
and setProperty
. The following snippet reads the old value of --x
, converts it to a number and increments it, then sends the new value back the --x
.
let oldX = el.style.getPropertyValue('--x');
let newX = Number(oldX) + 1;
el.style.setProperty('--x', newX);
For accessing class names, we have two ways: className
will give you a single value where all the class names on an element are separated by spaces, and classList
will give you a list of those classes. I prefer classList
because it provides a way to easily add/remove/toggle classes.
el.classList.add('new-class');
el.classList.remove('old-class');
el.classList.toggle('is-active');
el.classList.toggle('is-active', true); // force this class to be present
Changing inner content
For accessing the inner text of an element, you can use textContent
.
el.textContent = 'hi';
You can add a new element inside another element using appendNode
. This is useful if you programmatically created an element using document.createElement
.
let newChildEl = document.createElement('div');
el.appendNode(newChildEl);
You can also replace the entire markup of an element using innerHTML
but be careful with this as it can create a security risk.
el.innerHTML = '<div>hi</div>';
Recipes
Now that we know how to manipulate the DOM, we can start doing more advanced stuff. You could try these recipes in the browser console, but you probably want to use a <script>
tag or even a separate .js
file. I've also included a few codepen examples showing practical uses for these recipes.
Respond to clicks
Perhaps the most common use case: make things clickable. You may already be doing this today by adding onclick
directly in your HTML. But there's a better, more maintainable approach that doesn't require you to manually add onclick
to all your buttons.
Let's first define a function called handleClick
that will be called on every click. For now I'm simply logging this action to the browser console, but you can do pretty much anything in here (e.g. change some styles) now that you've learned how to manipulate the DOM.
function handleClick() {
console.log('Clicked!');
}
Now let's find our button and add an "event listener" to it. Every DOM element has an addEventListener
function which takes two arguments: the name of the event (in this case 'click'
) and the name of the function (in this case handleClick
).
let primaryButton = document.querySelector('button.primary-button');
primaryButton.addEventListener('click', handleClick);
That's it! In fact, you're not limited to just 'click'
events. You have the ability to add listeners to all kinds of events; check out the list of event types on MDN.
Toggle and store state
CSS doesn't offer much in the way of state management, so in the past you might have found yourself reaching for hacks like using an invisible checkbox. Fortunately, it's quite easy to do using JavaScript.
One of my favorite places to store state is in a data attribute, which can be easily toggled using JavaScript and also easily styled in CSS.
let button = document.querySelector('.toggle-button');
function toggleActive() {
button.dataset.active = button.dataset.active !== "true" ? "true" : "false";
}
button.addEventListener('click', toggleActive);
[data-active="true"] {
border: 2px solid;
}
Check out this CodePen where I'm using multiple data attributes and nested functions to create button groups that have independent state yet still share the same code.
Media queries
Media queries are not just a CSS thing. You can access them in JavaScript to build some really powerful UIs. To do that, you can use window.matchMedia
. In the following snippet, we check if the user prefers reduced motion, access the boolean result using the matches
property, and store the opposite of that result (using !
) in a variable called motionOk
.
let motionOk = !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
We can also add an event listener that will be called every time the media query returns a new value. The following snippet will print true
or false
in the browser console every time the query runs.
window.matchMedia('(max-width: 500px)').addEventListener(e => {
console.log(e.matches);
});
Here's a practical example showing how to change default state and colors based on user's OS theme.
Window resizing
You have the ability to add a listener on the browser 'resize'
event, which will be called for each pixel change on the browser's window dimensions. I don't want to go into too much depth here for two reasons:
- You can do a lot with just media queries (and soon container queries).
- This method can be a bit expensive, and it is recommended to use
ResizeObserver
instead, although that is a bit harder to use.
But it can still be useful in some cases, so here's the syntax.
function doSomethingWithWindowSize() {
console.log(window.innerHeight, window.innerWidth);
}
window.addEventListener('resize', doSomethingWithWindowSize);
And here's the syntax for ResizeObserver
which lets you observe dimension changes on any element (not just the browser window). Notice how we need to create a resize observer separately, and then manually call observe
on the element.
let el = document.querySelector('div');
let elResizeObserver = new ResizeObserver(([{ contentRect }]) => {
console.log(contentRect.height, contentRect.width);
});
elResizeObserver.observe(el);
Detect scroll position
This is a fun and powerful one, as it opens up quite a few possibilities. Similar to the 'resize'
event, the browser has a 'scroll'
event that we can listen to. And we can get the current scroll position using window.scrollX
and window.scrollY
.
window.addEventListener('scroll', handleScrollEvent);
function handleScrollEvent() {
console.log(window.scrollX, window.scrollY);
}
You can also read an element's current scroll position by calling getBoundingClientRect()
on it, which will give you access to a bunch of information about the size and position of the element. In this case, we care about the top
and bottom
properties (or left
and right
for horizontal scrolling). I like to do this in a helper function where I can cleanly destructure those properties. The following function also uses window.innerHeight
to return true if either the element's top
or bottom
are in viewport.
function isElementInViewport(el) {
const { top, bottom } = el.getBoundingClientRect();
return top <= window.innerHeight && bottom > 0;
}
Lastly, we can force the page (or even an element) to scroll to a certain position by calling scrollTo
.
window.scrollTo(0, 0);
Let's look at a practical example. In this codepen, I'm adding a scroll listener to achieve two things:
- show a "scroll to top" button only after the page is scrolled down a bit
- change the "active" color in the header to match the currently visible color
Conclusion
That's all folks! I hope you enjoyed reading this article as much as I enjoyed writing it. These recipes were based on my observations over the last few years. If you feel there is a pattern not covered here that is common enough in your workflow, I'd love to hear about it.