Creating Directionally Lit 3D Buttons with CSS
Tags: css, design, beginners, tutorial
Published: 2021-03-18 19:55:33

I'm not too sure how I stumbled into this one. But, something led me to this [tweet](https://twitter.com/JedBridges/status/1278384065087893505).

{% twitter 1278384065087893505 %}

And, to me, that's a challenge. I took it to the live stream!

The button design is neat. But, I didn't want to do a direct copy. Instead, we decided on making a "Twitter" button. The idea is that we create an almost transparent button with a social icon on it. And then that social icon casts a shadow below. Moving our mouse across the button shines a light over it. Pressing the button pushes it onto the surface.

{% twitter 1355305224416669701 %}

And today, we're going to look at how you can make it too. The cool thing is, you can swap the icon out to whatever you want.

## The Markup

My first take approach for creating something like this is to scaffold the markup. Upon first inspection, we'll need to duplicate the social icon used. And a neat way to do this is to use Pug and leverage mixins.

```pug
mixin icon()
  svg.button__icon(role='img' xmlns='http://www.w3.org/2000/svg' viewbox='0 0 24 24')
    title Twitter icon
    path(d='M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z')
```

Here we’ve created a mixin for rendering an SVG of the Twitter icon. This would render the Twitter icon if we invoke it like so.

```pug
+icon()
```

Doing that will give us a big Twitter icon.

{% codepen https://codepen.io/jh3y/pen/yLVeNBd %}

Because social icon sets tend to use the same "0 0 24 24" `viewBox`, we could make the title and path arguments.

```pug
mixin icon(title, path)
  svg.button__icon(role='img' xmlns='http://www.w3.org/2000/svg' viewbox='0 0 24 24')
    title= title
    path(d=path)
```

Then our Twitter icon becomes

```pug
+icon('Twitter Icon', 'M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z')
```

But, we could pass it a key. And then have the paths stored in an object if we have many icons we wanted to use or repeat.

```pug
mixin icon(key)
  -
    const PATH_MAP = {
      Twitter: "M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"
    }
  svg.button__icon(role='img' xmlns='http://www.w3.org/2000/svg' viewbox='0 0 24 24')
    title= `${key} Icon`
    path(d=PATH_MAP[key])

+icon('Twitter')
```

This can be a neat way to create an icon mixin to reuse. It's a little overkill for our example, but worth noting.

Now, we need some markup for our button.

```pug
.scene
  button.button
    span.button__shadow
      +icon('Twitter')
    span.button__content
      +icon('Twitter')
      span.button__shine
```

It's always good to be mindful of accessibility. We can check what our button gives off by checking the "Accessibility" panel in Dev Tools.

![Accessibility panel showing the text read for our button](https://uploads.sitepoint.com/wp-content/uploads/2021/02/1613444375a11y-panel.png)

Might be a good idea to put a `span` in for our button text and hide the icons with `aria-hidden`. We can hide the `span` text too whilst making it available to screen readers.

```pug
.scene
  button.button
    span.button__shadow
      +icon('Twitter')
    span.button__content
      span.button__text Twitter
      +icon('Twitter')
      span.button__shine
```

We've got different options for applying those `aria-hidden` attributes. The one we will use is changing the mixin code to apply `aria-hidden`.

```pug
mixin icon(key)
  -
    const PATH_MAP = {
      Twitter: "...path code"
    }
  svg.button__icon(role='img' aria-hidden="true" xmlns='http://www.w3.org/2000/svg' viewbox='0 0 24 24')
    title= `${key} Icon`
    path(d=PATH_MAP[key])
```

Another neat way with Pug is to pass through all attributes to a mixin. This is useful in scenarios where we only want to pass some attributes through.

```pug
mixin icon(key)
  -
    const PATH_MAP = {
      Twitter: "...path code"
    }
  svg.button__icon(role='img' xmlns='http://www.w3.org/2000/svg' viewbox='0 0 24 24')&attributes(attributes)
    title= `${key} Icon`
    path(d=PATH_MAP[key])
```

If we check the “Accessibility” panel again, our button only reads “Twitter”. And that’s what we want!

## The Styles

Here's the part you came for. How we style the thing. To start, we've dropped this in;

```css
* {
  transform-style: preserve-3d;
}
```

That allows us to create the 3D transforms we need for our button. Try switching that off in the final demo and you'll see that everything breaks.

Let's hide the span text from our eyes. We can do this in many ways. One recommended way to hide an element from our eyes, but not those of the screenreader is to use these styles.

```css
.button__text {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border-width: 0;
}
```

Before we start working on our button, we're going to tilt the scene. We can do this using a `transform`. Here we chain the `transform` to get it into the position we want. I spent a bit of time tinkering with values here on live stream to get it close to the original.

```css
.scene {
  height: var(--size);
  position: relative;
  width: var(--size);
  transform: rotateX(-40deg) rotateY(18deg) rotateX(90deg);
}
```

You’ll notice a `size` variable there too. We're going to drive certain things for our button with CSS variables. This will make it handy for tinkering with values and the effect. Usually, we would put these under the scope they're required in. But, for demos like this, putting them under the `:root` at the top of our file makes it easier for us to play with.

```css
:root {
  --blur: 8px;
  --shine-blur: calc(var(--blur) * 4);
  --size: 25vmin;
  --transition: 0.1s;
  --depth: 3vmin;
  --icon-size: 75%;
  --radius: 24%;
  --shine: rgba(255,255,255,0.85);
  --button-bg: rgba(0,0,0,0.025);
  --shadow-bg: rgba(0,0,0,0.115);
  --shadow-icon: rgba(0,0,0,0.35);
  --bg: #e8f4fd;
}
```

These are the variables, we’re working with and these will make sense as we build up our button.

## The Button

Let's move onto the button!

First things first. The actual button element. This is going to fill the scene element. We could have applied the sizing and transforms direct on the button. But, if we were to introduce other buttons and elements, we'd have to transform and size them all. This is something to be mindful of with CSS in general. Try and make your container elements dictate the layout.

```css
.button {
  appearance: none;
  background: none;
  border: 0;
  cursor: pointer;
  height: 100%;
  outline: transparent;
  position: absolute;
  width: 100%;
}
```

Here we strip the button styles. And that gives us this.

{% codepen https://codepen.io/jh3y/pen/gOLPBjB %}

Next we need to create a common start point for the button content and the shadow. We can do this by giving each element absolute positioning. The content will have a 3D translate based on the depth variable we defined before.

```css
.button__content,
.button__shadow {
  border-radius: var(--radius);
  display: grid;
  height: 100%;
  place-items: center;
  position: absolute;
  width: 100%;
}
.button__content {
  transform: translate3d(0, 0, var(--depth));
}
```

Note how we’re also making use of the `--radius` variable too.

{% codepen https://codepen.io/jh3y/pen/oNYbaKq %}

It's hard to distinguish between the two icons at this stage. And now's a good time to style them. We can apply some basic icon styling and use a scoped fill for each SVG icon.

```css
.button__content {
  --fill: var(--icon-fill);
}
.button__shadow {
  --fill: var(--shadow-fill);
}

.button__icon {
  height: var(--icon-size);
  fill: var(--fill);
  width: var(--icon-size);
}
```

It’s getting there! The icons aren’t the same size at the moment though. We’ll get to that.

{% codepen https://codepen.io/jh3y/pen/mdOVQJv %}

Let’s get the button press in place. This part is real quick to integrate.

```css
.button__content {
  transition: transform var(--transition);
}
.button:active {
  --depth: 0;
}
```

That’s it! Using scoped CSS variables we’re saying remove the z-axis translation on `:active`. Adding the `transition` to the `transform` stops it from being so instant.

{% codepen https://codepen.io/jh3y/pen/VwmeVKE %}

All that's left to do is style the button layers and the shine. Let's start with the shadow.

```css
.button__shadow {
  background: var(--bg-shadow);
  filter: blur(var(--blur));
  transition: filter var(--transition);
}
.button:active {
  --blur: 0;
}
```

Another scoped style here. We’re saying that when we press the button, the shadow is no longer blurred. And to blur the shadow, we use the CSS `filter` property with a `blur` filter. The value of which we defined in our CSS variables. Have a play with the `--blur` variable and see what happens.

{% codepen https://codepen.io/jh3y/pen/OJbMawy %}

For the content layer, we're going to use a background color and then apply a backdrop filter. Much like filter, `backdrop-filter` is a way for us to apply visual effects to elements. A common use case currently is using blur for "Glassmorphism".

```css
.button__content {
  backdrop-filter: blur(calc(var(--blur) * 0.25));
  background: var(--button-bg);
  overflow: hidden;
  transition: transform var(--transition), backdrop-filter var(--transition);
}
```

We use the value of `--blur` and apply a transition for `backdrop-filter`. Because of the way we scoped our `--blur` variable on `:active`, we get the transition almost for free. Why the `overflow: hidden`? We’re anticipating that shine element that will move around the button. We don’t want it wandering off outside though.

{% codepen https://codepen.io/jh3y/pen/dyOGQQL %}

And now, the last piece of the puzzle. That light shine. This is what has been causing the icons to be a different size. Because it has no styles, it's affecting the layout. Let's give it some styles.

```css
.button__shine {
  --shine-size: calc(var(--size) * 0.5);
  background: var(--shine);
  border-radius: 50%;
  height: var(--shine-size);
  filter: blur(var(--shine-blur)) brightness(1.25);
  position: absolute;
  transform: translate3d(0, 0, 1vmin);
  width: var(--shine-size);
}
```

That `absolute` positioning will sort out the icon sizing. Applying a border radius will make the spotlight round. And we use `filter` again to give the blurry spot light effect. You’ll notice we’ve chained a `brightness` filter on the end there to brighten things up a bit after they’re blurred.

{% codepen https://codepen.io/jh3y/pen/abBdQxR %}

Using the 3D translation ensures that the shine sits above the button which it would do. This way, there’s no chance of it getting cut by z-fighting with other elements.

That's all we need for the styles for now. Now it's time for some scripts.

## Scripts

We're going to use [GreenSock](https://greensock.com) today for convenience. They have some neat utilities for what we want. But, we could achieve the same result with vanilla JavaScript. Because we're using scripts with type "module", we can take advantage of [SkyPack](https://skypack.dev).

```javascript
import gsap from 'https://cdn.skypack.dev/gsap'
```

And now we're ready to start tinkering. We want our button to respond to pointer movement. The first thing we want is to translate the shine as if it follows our pointer. The second is to shift the button dependant on where our pointer is.

Let's grab the elements we need and set up some basic event listeners on the document.

```javascript
import gsap from 'https://cdn.skypack.dev/gsap'

const BUTTON = document.querySelector('.button')
const CONTENT = document.querySelector('.button__content')
const SHINE = document.querySelector('.button__shine')

const UPDATE = ({x, y}) => console.info({x, y})

document.addEventListener('pointermove', UPDATE)
document.addEventListener('pointerdown', UPDATE)
```

Try moving your pointer around in this demo to see the valuables we get returned for `x` and `y`.

{% codepen https://codepen.io/jh3y/pen/YzpwdVy %}

This is the trickiest bit. We need some math to work out the shine position. We're going to translate the shine after it's initial reset. We need to first update the shine styles to accommodate this. We’re using the scoped CSS variables `--x` and `--y`. We give them a fallback of `-150` so they’ll be out of shot when the demo loads.

```css
.button__shine {
  top: 0;
  left: 0;
  transform: translate3d(-50%, -50%, 1vmin) translate(calc(var(--x, -150) * 1%), calc(var(--y, -150) * 1%));
}
```

Then, in our update function we calculate the new position for the shine. We're basing this on a percentage of the button size. We can calculate this by subtracting the button position from our pointer position. Then we divide that by the position. To finish, multiply by 200 to get a percentage.

```javascript
const BOUNDS = CONTENT.getBoundingClientRect()
const POS_X = ((x - BOUNDS.x) / BOUNDS.width) * 200
const POS_Y = ((y - BOUNDS.y) / BOUNDS.height) * 200
```

For example, `POS_X`.

1. Grab pointer position x.
2. Subtract button position x.
3. Divide by button width.
4. Multiply by 200.

We multiply by 200 because the shine is half the size of the button. This particular part is tricky because we’re trying to track the pointer and map it into 3D space.

To apply that to the button, we can set those CSS variables using `gsap.set`. That’s a GSAP method that works as a zero second tween. It’s particularly useful for setting values on elements.

```javascript
gsap.set(SHINE, {
  '--x': POS_X,
  '--y': POS_Y
})
```

But, if we want to take it one step further, we can use a `quickSetter` from GSAP which would be better for performance in real-world scenarios where we’re making lots of updates.

```javascript
const xySet = gsap.quickSetter(SHINE, 'css')
// Then to update the values
xySet({
  '--x': POS_X,
  '--y': POS_Y
})
```

That makes our update function look something like this.

```javascript
const UPDATE = ({x, y}) => {
  const BOUNDS = CONTENT.getBoundingClientRect()
  const POS_X = ((x - BOUNDS.x) / BOUNDS.width) * 200
  const POS_Y = ((y - BOUNDS.y) / BOUNDS.height) * 200
  xySet({
    '--x': POS_X,
    '--y': POS_Y
  })
}
```

The accuracy of following the pointer would need more calculations to be precise. Have a play with this demo where the overflow on the button is visible and the shine is more prominent. You can see how the shine element loses it's tracking.

{% codepen https://codepen.io/jh3y/pen/MWbKZrP %}

This demo puts everything where it should be.

{% codepen https://codepen.io/jh3y/pen/rNWxPgW %}

Last feature. Let’s shift the button for an added touch. Here, we're going to base the shift of the button on pointer position. But, we're going to limit it's movement. To do this, we can use another GSAP utility. We're going to use mapRange. This allows us to map one set of values to another. We can then pass a value in and get a mapped value back out.

First, we’ll define a limit for movement. This will be a percentage of the button size.

```javascript
const LIMIT = 10
```

Now in our update function we can calculate the percentage of shift. We do this by mapping the the window width against the limit. And we input our pointer position to get the mapped percentage back.

```javascript
const xPercent = gsap.utils.mapRange(
  0,
  window.innerWidth,
  -LIMIT,
  LIMIT,
  x
)
```

In this block we’re mapping the range of `0` to `window.innerWidth` against `-10` to `10`. Passing pointer position `x` will give us a value between `-10` and `10`. And then we can apply that percentage shift to our button. We do the same for vertical shift and this gives us an update function like the following.

```javascript
const buttonSet = gsap.quickSetter(BUTTON, 'css')
const xySet = gsap.quickSetter(SHINE, 'css')
const LIMIT = 10

const UPDATE = ({x, y}) => {
  const BOUNDS = CONTENT.getBoundingClientRect()
  const POS_X = ((x - BOUNDS.x) / BOUNDS.width) * 200
  const POS_Y = ((y - BOUNDS.y) / BOUNDS.height) * 200
  xySet({
    '--x': POS_X,
    '--y': POS_Y
  })
  const xPercent = gsap.utils.mapRange(
    0,
    window.innerWidth,
    -LIMIT,
    LIMIT,
    x
  )
  const yPercent = gsap.utils.mapRange(
    0,
    window.innerHeight,
    -LIMIT,
    LIMIT,
    y
  )
  buttonSet({
    xPercent,
    yPercent,
  })
}
```

## That’s it!

That’s how you create a directional lit 3D button with CSS and a little scripting. The cool thing is that we can make changes with relative ease.

For the final demo, I’ve added some extra details and changed the icon. You might recognise it.

{% codepen https://codepen.io/jh3y/pen/qBqzmGM %}

As always, thanks for reading. Wanna see more? Come find me on [Twitter](https://twitter.com/jh3yy) or [check out the live stream](https://twitch.tv/jh3yy)!

{% youtube 79ufI5x0_NU %}