Roaming Shapes for Empty States

I really enjoy sweating the details and going the extra mile to elevate experiences in apps. One example I've worked on recently had a few empty states that were missing something. The app had this shape theme, where there were shapes all throughout the design. One random idea came to mind: why not let those shapes float around a bit?

Adding micro animations to empty states are a great way to add a little extra detail from a UI/UX perspective. A lot of times empty states use illustrations that look quite nice already; however, adding some movement makes things more fun.

There isn't any media for this item.

Maybe it's too subtle? Here's a sped up version.

There isn't any media for this item.

Notice how each individual shape around the one in the center drifts around a bit? Fun! After creating one of these, it was essentially rinse and repeat. I kept rolling with this pattern for other empty states as well in this particular application, swapping out individual icons and tweaking their animations slightly. It was a good time.


So how did I go about building this?

I ended up going over to Figma and combining multiple chromicon icons (free, open source icons I helped build!) into a single frame. Once I had the frame, I exported it to a raw SVG.

From there, we can use the raw SVG and animate each individual shape using CSS. There are really awesome tools like GSAP and Framer Motion, but I like to try using only CSS before reaching for another tool. For this case in particular, making shapes move is pretty straight forward.

I ended up creating a couple of options for different shapes, as you can see, with staggered start delays and animation durations. All of them leveraged keyframes and used the translate CSS property.

You may notice the "hero" icon in the middle is a different shade than the surrounding shapes. I ended up using the stroke-opacity attribute (or strokeOpacity in React) to ensure the main icon is front and center, and the surrounding icons have a much lower opacity and are thus lighter.

The shapes roaming in this example are pretty subtle, but if you begin adding more motion in your empty states you should consider using the prefers-reduced-motion media query. If your animation duration is pretty short, you'd use this media query to increase the duration so that it reduces the amount of motion on the screen.

That's it! Pretty straight forward! I've pasted the code below for React, but you can also find a pure HTML + CSS version on my CodePen. Happy coding!

import styles from './styles.module.css';

export function RoamingShape() {
  return (
    <svg
      className={styles.svg}
      width="77"
      height="56"
      viewBox="0 0 77 56"
      fill="none"
      aria-hidden="true"
    >
      <path
        d="M46 36H32C31.4701 35.9984 30.9623 35.7872 30.5875 35.4125C30.2128 35.0377 30.0016 34.5299 30 34V20C30.0021 19.4702 30.2135 18.9627 30.5881 18.5881C30.9627 18.2135 31.4702 18.0021 32 18H46C46.5299 18.0016 47.0377 18.2128 47.4125 18.5875C47.7872 18.9623 47.9984 19.4701 48 20V34C48 34.5304 47.7893 35.0391 47.4142 35.4142C47.0391 35.7893 46.5304 36 46 36ZM33.2 36L37.4 31.15L41.6 26.3L44.8 29.7L48 33.1L33.2 36ZM35.8 21.4C36.3321 21.3998 36.8478 21.5842 37.2593 21.9217C37.6707 22.2591 37.9524 22.7288 38.0564 23.2507C38.1603 23.7725 38.0801 24.3143 37.8294 24.7837C37.5787 25.253 37.173 25.6209 36.6814 25.8247C36.1898 26.0285 35.6428 26.0555 35.1336 25.9011C34.6243 25.7468 34.1843 25.4206 33.8886 24.9783C33.5929 24.5359 33.4597 24.0046 33.5117 23.4751C33.5638 22.9455 33.7978 22.4503 34.174 22.074C34.3871 21.8599 34.6406 21.6901 34.9196 21.5745C35.1987 21.4588 35.4979 21.3995 35.8 21.4V21.4Z"
        stroke="currentColor"
        strokeWidth="2"
        strokeLinecap="round"
        strokeLinejoin="round"
      ></path>
      <path
        className={styles['animate-shape-roam-1']}
        d="M58.6785 8.26405C58.9702 7.97233 59.3659 7.80844 59.7784 7.80844C60.191 7.80844 60.5866 7.97233 60.8784 8.26405C61.1701 8.55577 61.334 8.95143 61.334 9.36399C61.334 9.77655 61.1701 10.1722 60.8784 10.4639C60.7326 10.5954 60.5601 10.6937 60.3728 10.7522C60.1854 10.8107 59.9876 10.828 59.793 10.803C59.3758 10.788 58.9142 10.6996 58.4428 10.6996M62.6069 6.53557C63.4248 7.35354 63.9338 8.42982 64.0472 9.58102C64.1606 10.7322 63.8713 11.8871 63.2286 12.8489C62.5859 13.8108 61.6296 14.52 60.5227 14.8558C59.4157 15.1916 58.2266 15.1331 57.1578 14.6904C56.0891 14.2478 55.207 13.4482 54.6617 12.428C54.1164 11.4078 53.9417 10.2301 54.1674 9.09559C54.3931 7.96104 55.0051 6.93985 55.8994 6.206C56.7936 5.47216 57.9145 5.07107 59.0713 5.07107C59.7281 5.07023 60.3785 5.19917 60.9852 5.4505C61.592 5.70183 62.1431 6.07058 62.6069 6.53557V6.53557ZM57.225 11.9174L57.2643 11.8781L57.225 11.9174Z"
        stroke="currentColor"
        strokeWidth="1.5"
        strokeLinecap="round"
        strokeLinejoin="round"
        strokeOpacity="50%"
      ></path>
      <path
        className={styles['animate-shape-roam-3']}
        d="M18.6886 41.2523C18.4519 40.9143 18.0907 40.6842 17.6845 40.6126C17.2782 40.541 16.8601 40.6336 16.5221 40.8703C16.1842 41.1069 15.954 41.4681 15.8824 41.8744C15.8108 42.2807 15.9035 42.6988 16.1401 43.0367C16.2608 43.1915 16.4136 43.3183 16.588 43.4085C16.7623 43.4986 16.9541 43.55 17.1501 43.5591C17.5636 43.6168 18.0335 43.6099 18.4978 43.6918M15.12 38.8679C14.1725 39.5314 13.4843 40.503 13.1727 41.617C12.8612 42.731 12.9455 43.9186 13.4114 44.9774C13.8773 46.0362 14.6959 46.9007 15.7278 47.4236C16.7596 47.9465 17.9408 48.0955 19.0702 47.8451C20.1995 47.5947 21.2071 46.9605 21.9213 46.0505C22.6355 45.1405 23.012 44.011 22.9867 42.8545C22.9615 41.698 22.536 40.5861 21.7829 39.7081C21.0297 38.8301 19.9954 38.2405 18.8562 38.0396C18.2095 37.9247 17.5466 37.9388 16.9054 38.0809C16.2643 38.2231 15.6575 38.4905 15.12 38.8679V38.8679ZM19.4856 45.1026L19.4537 45.0571L19.4856 45.1026Z"
        stroke="currentColor"
        strokeWidth="1.5"
        strokeLinecap="round"
        strokeLinejoin="round"
        strokeOpacity="50%"
      ></path>
      <circle
        className={styles['animate-shape-roam-1']}
        cx="18"
        cy="12"
        r="3"
        fill="currentColor"
        fillOpacity="50%"
      ></circle>
      <circle
        className={styles['animate-shape-roam-2']}
        cx="6.5"
        cy="1.5"
        r="1.5"
        fill="currentColor"
        fillOpacity="50%"
      ></circle>
      <circle
        className={styles['animate-shape-roam-3']}
        cx="70.5"
        cy="1.5"
        r="1.5"
        fill="currentColor"
        fillOpacity="50%"
      ></circle>
      <circle
        className={styles['animate-shape-roam-2']}
        cx="75.5"
        cy="54.5"
        r="1.5"
        fill="currentColor"
        fillOpacity="50%"
      ></circle>
      <circle
        className={styles['animate-shape-roam-3']}
        cx="6.5"
        cy="54.5"
        r="1.5"
        fill="currentColor"
        fillOpacity="50%"
        style={{ animationDelay: '2s' }}
      ></circle>
      <circle
        className={styles['animate-shape-roam-1']}
        cx="58"
        cy="42"
        r="3"
        fill="currentColor"
        fillOpacity="50%"
      ></circle>
      <path
        className={styles['animate-shape-roam-3']}
        d="M-0.000199058 28.237L9.41478 25.1779L6.23685 31.415L-0.000199058 28.237Z"
        fill="currentColor"
        fillOpacity="50%"
        style={{ animationDelay: '1s' }}
      ></path>
      <path
        className={styles['animate-shape-roam-2']}
        d="M67.8472 34.242L71.3948 25L74.242 31.3948L67.8472 34.242Z"
        fill="currentColor"
        fillOpacity="50%"
        style={{ animationDelay: '2s' }}
      ></path>
    </svg>
  );
}
.svg {
  height: 11rem;
  width: 11rem;
  color: #9ca3af;
}

@keyframes shape-roam-1 {
  0% {
    transform: translate(0);
  }

  50% {
    transform: translate(1px, -1px);
  }
  100% {
    transform: translate(0);
  }
}

@keyframes shape-roam-2 {
  0% {
    transform: translate(0);
  }
  50% {
    transform: translate(-2px, -4px);
  }
  100% {
    transform: translate(0);
  }
}

@keyframes shape-roam-3 {
  0% {
    transform: translate(0);
  }
  50% {
    transform: translate(2px, -2px);
  }
  100% {
    transform: translate(0);
  }
}

/*
  If your animation duration is short,
  consider using the prefers-reduced-motion
  media query to respect user's preferences.

  In this case it's probably okay, but if you
  get to a 1-2s duration, you may consider
  adding the media query and bumping it up to
  something more like 6-8s here.
*/
.animate-shape-roam-1 {
  -webkit-animation: shape-roam-1 6s infinite;
  animation: shape-roam-1 6s infinite;
}

.animate-shape-roam-2 {
  -webkit-animation: shape-roam-2 7s infinite;
  animation: shape-roam-2 7s infinite;
}

.animate-shape-roam-3 {
  -webkit-animation: shape-roam-3 8s infinite;
  animation: shape-roam-3 8s infinite;
}