Creating 3D CSS Buttons That Move

Recently, I toyed with the idea of ​​a 3D button that moves when the user moves the mouse around it. To promote this effect, I added some 3D shadows that moved in parallel to give the illusion of a 3D button sitting next to the side and moving with the user’s mouse movements.


How does it work?

The basic concept behind these buttons is that we need to track when the user holds the mouse over the button, moves and mouse out. By mouseover, we move the button so that it looks like 3D. Once the mouse is out, we reset it.

Before we get to Javascript, let’s make our button look good. Our HTML will look like this:

<button class="button"><span>Hover!</span></button>

And our CSS looks like this:

button {
    box-shadow: none;
    background: transparent;
    transform-style: preserve-3d;
    padding: 0;
    height: auto;
    float: none;

button span {
    background: linear-gradient(180deg, #ff7147, #e0417f);
    font-size: 2rem;
    padding: 1rem 2rem;
    line-height: 3rem;
    will-change: transform, filter;
    float: none;
    margin: 0;
    transition: all 0.15s ease-out;
    height: auto;
    border-radius: 100px;
    overflow: hidden;
    display: block;
    margin: 0px auto;
    display: block;
    transform: rotateX(0deg) rotateY(0deg) scale(1);
    filter: drop-shadow(0 15px 15px rgba(0,0,0,0.3));
    font-weight: 600;
    perspective-origin: 0 0;
    letter-spacing: 0;

Minor background animations

You may have noticed that the third button has a background animation. If you’re interested in how I did it, I used a pseudo-element that moves via animation. The pseudo-element has a simple gradient and the overflow is hidden. You can test this yourself by removing overflow: hidden from span and button elements.

How Javascript works

Let’s take a look at our Javascript now. You may have noticed that we have two elements to our button: the button itself and a buckle inside it. There is a good reason for this – this allows us to apply 3D perspective to the parent, which is required for the effect to work. It also allows us to target the parent for the hovering effect. If we use the mouse cursor on the child, the effect will come out as the child will rotate and we will miss the hitbox.

I use a function that uses the event variable (s) and refers to both the span (noted here as item) and the button (referred to as parent).

let calculateAngle = function(e, item, parent) {
    let dropShadowColor = `rgba(0, 0, 0, 0.3)`
    // If the button has a data-filter-color attribute, then use this for the shadow's color
    if(parent.getAttribute('data-filter-color') !== null) {
        dropShadowColor = parent.getAttribute('data-filter-color');

    // If the button has a data-custom-perspective attribute, then use this as the perspective.
    if(parent.getAttribute('data-custom-perspective') !== null) { = `${parent.getAttribute('data-custom-perspective')}`

    // Get the x position of the users mouse, relative to the button itself
    let x = Math.abs(item.getBoundingClientRect().x - e.clientX);
    // Get the y position relative to the button
    let y = Math.abs(item.getBoundingClientRect().y - e.clientY);

    // Calculate half the width and height
    let halfWidth  = item.getBoundingClientRect().width / 2;
    let halfHeight = item.getBoundingClientRect().height / 2;

    // Use this to create an angle. I have divided by 6 and 4 respectively so the effect looks good.
    // Changing these numbers will change the depth of the effect.
    let calcAngleX = (x - halfWidth) / 6;
    let calcAngleY = (y - halfHeight) / 4;

    // Set the items transform CSS property = `rotateY(${calcAngleX}deg) rotateX(${calcAngleY}deg) scale(1.15)`;
    // And set its container's perspective. = `${halfWidth * 2}px` = `${halfWidth * 3}px`

    // Reapply this to the shadow, with different dividers
    let calcShadowX = (x - halfWidth) / 3;
    let calcShadowY = (y - halfHeight) / 3;
    // Add a filter shadow - this is more performant to animate than a regular box shadow. = `drop-shadow(${-calcShadowX}px ${calcShadowY}px 15px ${dropShadowColor})`;

This effectively divides the button into four quadrants. The midpoint represents a change angle of the X and Y axes of 0, while a movement to the left results in a more negative Y angle and a more positive one to the right. The same goes for X, where movement of the cursor upwards turns the X angle more positive and downwards, more negative.

Some things worth noting:

  • We use filter box shadows because they blend better with CSS ‘ transition property.
  • I’ve added the ability to add custom perspective and box-shadow colors to provide more flexibility without having to change the code.
  • The effect is modulated by dividing calcAngle* variables. If you change how much you share them with, or even change the perspective, the effect becomes more or less pronounced.

Application of our function to each button

To apply our function to each button, we simply repeat them all with forEach.

document.querySelectorAll('.button').forEach(function(item) {
    // Add on mouseenter
    item.addEventListener('mouseenter', function(e) {
        calculateAngle(e, this.querySelector('span'), this);
    // Add on mousemove
    item.addEventListener('mousemove', function(e) {
        calculateAngle(e, this.querySelector('span'), this);

    // Reset everything on mouse leave
    item.addEventListener('mouseleave', function(e) {
        let dropShadowColor = `rgba(0, 0, 0, 0.3)`
        if(item.getAttribute('data-filter-color') !== null) {
            dropShadowColor = item.getAttribute('data-filter-color')
        item.querySelector('span').style.transform = `rotateY(0deg) rotateX(0deg) scale(1)`;
        item.querySelector('span').style.filter = `drop-shadow(0 10px 15px ${dropShadowColor})`;

We are done

With that, we have recreated the effect that was shown at the beginning of the article. We hope you enjoyed this guide.

Leave a Comment