Mayank
Mayank

Mayank

Common JavaScript recipes for CSS developers

Common JavaScript recipes for CSS developers

Mayank's photo
Mayank
·Jan 17, 2022·

10 min read

Table of contents

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:

  1. You can do a lot with just media queries (and soon container queries).
  2. 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.