These days I’m getting a kick remaking things with more modern approaches that used to be problematic.

Here’s a little example: a material design style click/selection. What I mean is where you have a button and where you click it creates an effect that starts from the point you clicked/touched.

Here is the example we will build:

See the Pen
Material design inspired buttons
by Ben Frain (@benfrain)
on CodePen.

It used to be necasary to handle touch/clicks separately handling events like touchstart and click individually. Nowadays we can just use pointerdown. So nice!

Anyway, let’s get into this:

The building blocks — button elements and pointer events

I’m going to add a few buttons to the DOM. You can tweak the actual effect of the click to your hearts content. However, in terms of requisite mechanics needed to make this work, it’s essential to know just one thing; whereabouts did the user click inside the button? Once you know that you can communicate it back to the DOM so that your CSS can react accordingly.

We could do this kind of thing in the past, it’s just so much easier with CSS Custom Properties.

The complete JavaScript

Anyway, here is the JavaScript I used. Take a look and then we will go through it in more detail:

document.body.addEventListener("pointerdown", e => {
    // Ignore anything that isn't the button or a child of it
    if (e.target.tagName !== "BUTTON" && e.target.parentNode.tagName !== "BUTTON") {
        return;
    }
    const theBtn = e.target.closest("button");
    const neededLeft = e.x - theBtn.getBoundingClientRect().left;
    const neededTop = e.y - theBtn.getBoundingClientRect().top;
    const biggestA = Math.max(neededLeft, theBtn.getBoundingClientRect().width - neededLeft);
    const biggestB = Math.max(neededTop, theBtn.getBoundingClientRect().height - neededTop);
    //length of diagonal through rectangle is a2 + b2 = c2.
    const neededWidth = Math.sqrt(biggestA * biggestA + biggestB * biggestB) * 2;
    theBtn.style.setProperty("--x", `${neededLeft}px`);
    theBtn.style.setProperty("--y", `${neededTop}px`);
    theBtn.style.setProperty("--width", `${neededWidth}px`);
    theBtn.setAttribute("aria-selected", theBtn.getAttribute("aria-selected") === "true" ? "false" : "true");
});

Breaking down the JavaScript

I’ve set a listener on the whole DOM, and while this saves on listeners being applied to every element, I do need to then discard clicks/touches on elements I’m not interested in. I’m doing that with the ‘early return’ for any element that isn’t a button or the child of a button.

Then I’m making sure I’m dealing with the actual button element and not a child of it by using closest.

I then need to get the coordinates for the X and Y of the click/touch. There are a few ways to do this, I’m getting the x from the pointerevent and subtracting the BoundingClientRect left/top of the button. First time around I hadn’t factored in scroll position and used offsetLeft/offsetTop. Using getBoundingClientRect() also takes into account the scroll position of the elements.

Sizing the circle effect

Now, a slight complication that I think is worth the extra effort. We want the circle to expand from the click/touch to fill the button. However, to get the best effect, we only want the circle to be big enough to cover the button. If we make the circle massive to ensure it will cover a button of any size (e.g. 1000px) the effect looks crap; because the transition speed is a constant, depending on the button size, you might now see the effect — the circle will have passed the bounds of the button before you can visually process it.

Getting the largest of two values with Math.max()

So to this ends we want to get the largest distance in both x/y from where the click was and the edge of the button. We do this with Math.max() and pass it the neededLeft (which is the distance of the click from the left edge of the button) and the width of the button minus that same value, which would give us the distance of the click from the right edge of the button. Same approach with the y axis. This gives us a biggestA (x axis) and biggestB (y axis).

Calculating the hypotenuse

Now here’s the fun part as we finally get to use some of the mathematics taught at school.

I mentioned to my Dad (78 years old) I was trying to remember how to get the length of the diagonal of a rectangle and without missing a beat he said “Hypotenuse — a2 + b2 = c2“. Now that’s an engineer!

Once we have the longest x and y we want the hypotenuse of the rectangle those dimensions create. To get the hypotenuse we use the formula ‘a2 + b2 = c2‘; the square root of that formula is the hypotenuse. That hypotenuse is going to be the radius of the circle we want for our button ‘circle’.

Custom Properties and styling with CSS

Right, we have all we need now, we just set some CSS Custom Properties for the --x and --y of our click along with the needed width for our background circle: --width. Naturally, we need to set the aria-selected state on each button click too. Good ’ol ternary operator for that!

The rest is CSS. At the top of the Codepen I have included App Reset but that’s a generic reset. Look from line 185 onwards for the styles relevant to this technique. More specifically, line 226 – 247:

.btn-Btn:before {
    content: "";
    position: absolute;
    top: calc(var(--y) - var(--width) / 2);
    left: calc(var(--x) - var(--width) / 2);
    transform-origin: 50% 50%;
    background-color: #d2d2d2;   
    height: var(--width);
    width: var(--width);
    border-radius: 50%;
    z-index: -1;
    transform: scale(0);
    opacity: 0;
    transition: transform 0.2s ease, opacity 0.2s ease;
}

.btn-Group-circle .btn-Btn[aria-selected="true"]:before {
    transform: none;
    opacity: 1;
    transition: transform 0.2s ease, opacity 0.1s ease;
}

That is how the circle effect happens. We use a ::before pseudo element, absolutely positioned according to our click. The position is the click minus half the width/height. The circle width and height is the --width we pass in from JavaScript. We then scale that circle down to nothing using transform: scale(0) and remove that transform when the button is selected. Adjust transition of transform and opacity to taste!

Summary

The effect here is quite basic and restrained. You might go wild and add additional elements that animate as needed, maybe just add an additional circle with ::after; the possibilities are near endless.

The important takeaway is just how economical it is thesedays to create these effects with standard CSS and JavaScript.

If browser support is a concern, you can wrap up the more progressive CSS in a @supports. For example, if calc is your worry, @supports(calc(100%)) and use a standard background colour change as the default styling.

Hope you had fun following along. It was fun to remake this effect with simpler syntax!