Skip to main content

Let’s Make a Rubber Button With HTML, CSS and SVG

By Tyler Sticka

Published on August 30th, 2023

Topics

While I wasn’t looking, a rubber button I shared on CodePen was viewed more than 11,000 times (as of this writing). I thought it might be fun to break down how the effect works, including some accessibility and compatibility improvements over what I originally shared.

Try hovering or pressing the button in a supported browser to see the rubber/elastic effect:

To start, we need three SVG paths: One for the container’s default state, one for the hover state, and one for when the button is pressed down. It’s important that all three states have the same number of points to transition between: For this reason, I personally have an easier time designing these paths by hand instead of in a design tool.

I designed my shapes based on a 100-pixel square to make math a bit easier:

Three square container shapes, the first perfectly square, the second appearing slightly inflated, and the third caving in.

Here’s an example SVG with all three paths accounted for. We’ll need the first path for our markup, and the other two for our styles.

<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
  <!-- default -->
  <path d="M0,0 C0,0 100,0 100,0 C100,0 100,100 100,100 C100,100 0,100 0,100 C0,100 0,0 0,0 z" />
  <!-- hover -->
  <path d="M0,0 C0,-5 100,-5 100,0 C105,0 105,100 100,100 C100,105 0,105 0,100 C-5,100 -5,0 0,0 z" />
  <!-- active -->
  <path d="M0,0 C30,10 70,10 100,0 C95,30 95,70 100,100 C70,90 30,90 0,100 C5,70 5,30 0,0 z" />
</svg>Code language: HTML, XML (xml)

Our rubber button will consist of these elements:

  • One real, actual button element. Hooray for native functionality!
  • An svg element containing the default path of our containing shape. We’ll want to use aria-hidden="true" since this is decorative, and preserveAspectRatio="none" so it will freely stretch to match the size of our button.
  • A container for the normal button text content so we can animate it independently.

Here’s what that markup looks like:

<button class="button">
  <svg class="button__shape"
    viewBox="0 0 100 100"
    preserveAspectRatio="none"
    aria-hidden="true">
    <path class="button__path" d="M0,0 C0,0 100,0 100,0 C100,0 100,100 100,100 C100,100 0,100 0,100 C0,100 0,0 0,0 z"/>
  </svg>
  <span class="button__content">
    Hello world!
  </span>
</button>Code language: HTML, XML (xml)

Let’s begin styling the button itself. This may look familiar to those who’ve customized native HTML buttons before, with two exceptions:

  • We’ll use relative positioning so we can position the SVG shape relative to this container.
  • We’ll set background to transparent, allowing the aforementioned shape to show through.
.button {
  appearance: none;
  background: transparent;
  border: 0;
  color: #fff;
  cursor: pointer;
  font: inherit;
  font-weight: 500;
  line-height: 1;
  padding: 1em 1.5em;
  position: relative;
}
Code language: CSS (css)

Next, we’ll set the button content’s display (so it will respond to transformations correctly) and position (so it will remain visible above the SVG):

.button__content {
  display: block;
  position: relative;
}Code language: CSS (css)

And finally, we’ll position our SVG so that it fills the button. We’ll also set its fill color and allow its contents to visibly overflow while animating:

.button__shape {
  block-size: 100%;
  fill: #950cde;
  inline-size: 100%;
  inset: 0;
  overflow: visible;
  position: absolute;
}Code language: CSS (css)

And with that, we’ve achieved the unassuming default state of our rubber button. Time for some animated transitions!

Let’s start with some custom properties to keep certain aspects of our animated transitions in sync. We’ll use an ease out back bezier function for a bit of springy-ness, and we’ll plan to scale elements by 5% (since our SVG paths tend to stretch outward by 5 out of 100 pixels):

:root {
  --button-motion-ease: cubic-bezier(0.34, 1.56, 0.64, 1);
  --button-motion-duration: 0.3s;
  --button-scale-up: 1.05;
  --button-scale-down: 0.95;
}Code language: CSS (css)

We should set the duration to 0s for those with motion sensitivity:

@media (prefers-reduced-motion: reduce) {
  :root {
    --button-motion-duration: 0s;
  }
}Code language: CSS (css)

With these properties in place, we can start adding some transitions.

To the button itself, we can use a brightness filter to shade or highlight the overall shape depending on how close it appears to the viewer:

.button {
  transition: filter var(--button-motion-duration) var(--button-motion-ease);
}

.button:hover {
  filter: brightness(1.1);
}

.button:active {
  filter: brightness(0.9);
}Code language: CSS (css)

Next, let’s animate the button’s content, scaling up or down to visibly expand or recede:

.button__content {
  transition: transform var(--button-motion-duration) var(--button-motion-ease);
}

.button:hover .button__content {
  transform: scale(var(--button-scale-up));
}

.button:active .button__content {
  transform: scale(var(--button-scale-down));
}Code language: CSS (css)

As of this writing, Safari does not support the CSS path() function. As a fallback, we can apply the same scale transition to our SVG as the button content:

.button__shape {
  transition: transform var(--button-motion-duration) var(--button-motion-ease);
}

@supports not (d: path('')) {
  .button:hover .button__shape {
    transform: scale(var(--button-scale-up));
  }

  .button:active .button__shape {
    transform: scale(var(--button-scale-down));
  }
}Code language: CSS (css)

And finally, our pièce de résistance… animating the path data! We can insert the value of our alternate path shape’s d attributes into a path() function for our :hover and :active states:

.button__path {
  transition: d var(--button-motion-duration) var(--button-motion-ease);
}

.button:hover .button__path {
  d: path("M0,0 C0,-5 100,-5 100,0 C105,0 105,100 100,100 C100,105 0,105 0,100 C-5,100 -5,0 0,0 z");
}

.button:active .button__path {
  d: path("M0,0 C30,10 70,10 100,0 C95,30 95,70 100,100 C70,90 30,90 0,100 C5,70 5,30 0,0 z");
} Code language: CSS (css)

And voilà, our rubber button is complete!

There are a lot of different ways we could remix this starting point:

  • There’s nothing about this technique that constrains us to flat colors: Experiment with gradients, strokes and other effects!
  • You could use scroll-driven animations to make the button morph as it scrolls in or out of view.
  • If we want to morph between paths with incompatible points, there are quite a few JavaScript solutions available: I’d recommend checking out GreenSock’s MorphSVG plugin, Polymorph and KUTE.js.
  • If we want to use this same pattern in multiple places, it might make a good progressively enhanced web component.

Effects like this can easily overwhelm an interface if overused. But applied purposefully, they can inject a bit of fun into an otherwise banal or forgettable interaction.

Comments

Alvaro Montoro said:

This demo is so cool, and I love the bouncy effect. Inspired by this, I tried to build a CSS-only version of the button using clip-path and the path() function, and I shared it on CodePen (giving credit and a link back to this article). It is not as smooth as this version, but I like how it looks. Let me know what you think: https://codepen.io/alvaromontoro/details/OJrXpvQ

Replies to Alvaro Montoro

Tyler Sticka (Article Author ) replied:

This is awesome, Álvaro! I love that it works in Safari, too.

The big functionality difference seems to be that the clip-path version requires a fixed button size, whereas the SVG version will scale up or down with its content.

But for buttons where the size is knowable ahead of time, trading some flexibility for a simpler implementation makes perfect sense! Thank you for sharing!