Adding a bit of whimsy for better user engagement.

Adding a bit of whimsy for better user engagement.

Subtle Animations with XState and Styled-Components for the win

Take a look at these two navigation menus, they're both identical but one is just more pleasant to use, and if you're not paying close attention you may not even notice the subtle difference. These UI designs choices add a bit of "whimsy" and polish to your web apps and keep your users engaged longer.

whimsyNavdemo.gif

We'll break down how to create this animation using React, Styled Components, and XState.

This walkthrough assumes a bit of CSS, React, and XState/React experience.

Creating our Menu

WhimsyNavGraphic.webp

We'll use Styled Components to build our menu. We'll start with our outer wrapper, followed by a nav element, an unordered list, list items, and anchors.

Let's break down the image above. We have an outer wrapper with a display of flex, centering our logo, and nav element and its children. We use space-between to push both elements away from each other. The rest is straightforward, an unordered list, with list items, and anchors as their children. (We wont discuss much styling here)

Let's start with our first navigation. In order to accomplish the underline when the link is clicked, we'll use reacts useState hook, and add a data attribute to each anchor. If you're unfamiliar with HTML data-attributes , data-*="value" allows us to add a bit of extra information to our elements. We can then style our anchor tags when a data-attribute matches its value in CSS. The logic breaks down as follows.

  • Create a function to return the inner text of each element.
  • Import and implement the useState hook.
  • Add a click even to each Styled Component Anchor Element.
  • Use useState hook to and save the value of our inner text.
  • Check if data-attribute matches the anchor element, if it does set its data-attribute to our current state.
  • style link that is clicked on "active" with a border-bottom.

Let's step through our pseudo code.

  • Create a function to return the inner text of each element.
// This function expects an event argument, we use the event to grab
// our anchor's inner text, and make sure each letter 
// returned is lower case. 

  function getInnerText(e) {
    return e.target.innerText.toLowerCase();
  }
  • Import and implement the useState hook.
import {useState} from 'react'
// I'm using null as the initial value intentionally, we will update
// to an anchors inner text, when it has been clicked on. 
const [underLine, setUnderline] = useState(null)

We can group some of our logic.

  • Add a click even to each Styled Component Anchor Element.
  • Use useState hook to and save the value of our inner text.

The Anchor component is an <a> element created with Styled Components. The onClick event updates our current state to "product"

<Anchor
    onClick={(e) => setUnderline(getInnerText(e))}
    href="#"
>
    Products
</Anchor>
  • Add a data-attribute to our anchor element. It's value will be either false or "product" depending on our conditional logic.

If our current state is set to "product", our data-attribute value will be "products"

<Anchor
    data-state={underLine === "products" && "products"}
    onClick={(e) => setUnderline(getInnerText(e))}
    href="#"
>
    Products
</Anchor>
  • Add data-attribute styling to our styled-component, if it's data-attribute value matches what's in state, we can add a border-bottom to our anchor tag.

    const Anchor = styled.a`
    color: hsla(319, 7%, 13%, 1);
    text-decoration: none;
    
    &[data-state="products"] {
      border-bottom: solid hsla(201, 64%, 55%, 1) 4px;
    }
    ... //additional data-state styles
    `
    

This implementation is pretty neat and totally acceptable. It accomplishes what we wanted, and also lets the user know where they are in our web apps. Let's go a bit further.

Adding a bit of Whimsy to improve our users' experience ever so slightly.

Surprisingly we don't have to change much of our code to accomplish this transition. We will need to add a div to replace our bottom-border indicator. We will also need to create our State Machine. Here's some logic we will need to work on.

-Create a div with an absolute position so it sits just outside of our NavWrapper component.

  • We can then pass in some props to our Styled Component div.
  • Those props will transition and scale our div
  • Add our NavigationHoverEffect component to our NavWrapper
  • Create our state machine to hold our hover effects "positioning" values.

Let's create our `div' and pass in some props.

const NavigationHoverEffect = styled.div`
  width: 10px;
  height: 4px;
  background-color: hsla(201, 63%, 55%, 1);
  position: absolute;
  top: 57px;
  right: -10px;
  transition: transform 500ms ease-in-out;
  transform: ${(props) =>
    `translateX(${props.translateX}px) scaleX(${props.scaleX})`};
`;

The first few style rules are easy to understand. Let's talk about the last two. The transition will make sure our div moves from each anchor tag "smoothly". We give it 3 properties, the transition property (transform) the transition duration (.5 seconds) and the timing function, and ease-in-out.

The transform rule takes 2 values, translateX() (moves left to right) and scaleX() will grow and shrink our div. We pass in the values via props. Those values will come from our state machine.

State Machines

In order for our div to move from one Anchor to another, we'll need to dynamically pass in values to our NavigationHoverEffect component. Let's walk through our logic again.

  • Figure out the size and location of each Element
  • When an Anchor element is clicked on, move the hover element underneith it.
  • Send its "size" and "position" to via props to our hover element.
  • Create a state machine to save each Anchor components "location".
  • Update our extended state so our div always know's where it is.

We can leverage our developer tools to find our "location" and "size" of each Anchor element.

devTools.gif

We do this for each Anchor element, once you're happy with the location and size, we'll save those values to our state machine.

Let's create a state machine. It will have 5 states. Each state transition will update our extended states. We can use the extended state values to update the size and positioning of our div. The initial state idle will have the make sure our div sit's just outside of our nav wrapper. When you click on any Anchor tag, a click event will fire and it will update our context, those values are passed into our NavigationHoverEffect component via props and will cause our slider to move between Anchor tags.

There's a lot to digest here, let's start with our state machine.

Let's start by creating our state machine.

navStateMachine.png

import { createMachine, assign } from "xstate";

const navMachine = createMachine({
  id: "navMachine",
  initial: "idle",
  context: {
    translateX: null,
    scaleX: null
  },
  states: {
    idle: {
      on: {
        PRODUCTS: {
          target: "products",
          actions: assign({ translateX: -373, scaleX: 5.9 })
        },
        COMMUNITY: {
          target: "community",
          actions: assign({ translateX: -255, scaleX: 7.7 })
        },
        PRICING: {
          target: "pricing",
          actions: assign({ translateX: -146, scaleX: 4.5 })
        },
        CONTACT: {
          target: "contact",
          actions: assign({ translateX: -48, scaleX: 5.3 })
        }
      }
    },
    products: {
      on: {
        COMMUNITY: {
          target: "community",
          actions: assign({ translateX: -255, scaleX: 7.7 })
        },
        PRICING: {
          target: "pricing",
          actions: assign({ translateX: -146, scaleX: 4.5 })
        },
        CONTACT: {
          target: "contact",
          actions: assign({ translateX: -48, scaleX: 5.3 })
        }
      }
    },
    community: {
      on: {
        PRODUCTS: {
          target: "products",
          actions: assign({ translateX: -373, scaleX: 5.9 })
        },
        PRICING: {
          target: "pricing",
          actions: assign({ translateX: -146, scaleX: 4.5 })
        },
        CONTACT: {
          target: "contact",
          actions: assign({ translateX: -48, scaleX: 5.3 })
        }
      }
    },
    pricing: {
      on: {
        PRODUCTS: {
          target: "products",
          actions: assign({ translateX: -373, scaleX: 5.9 })
        },
        COMMUNITY: {
          target: "community",
          actions: assign({ translateX: -255, scaleX: 7.7 })
        },
        CONTACT: {
          target: "contact",
          actions: assign({ translateX: -48, scaleX: 5.3 })
        }
      }
    },
    contact: {
      on: {
        PRODUCTS: {
          target: "products",
          actions: assign({ translateX: -373, scaleX: 5.9 })
        },
        COMMUNITY: {
          target: "community",
          actions: assign({ translateX: -255, scaleX: 7.7 })
        },
        PRICING: {
          target: "pricing",
          actions: assign({ translateX: -146, scaleX: 4.5 })
        }
      }
    }
  }
});

You'll notice that the states can transition between each other. When a transition happens our context values are updated. In order to make use of those values, we'll need to import the useMachine hook, and pass in our state machine. We can then use the current object to grab the current context value.

import {useMachine} from "@Xstate/React"
import {navMachine} from './navMachine'

const [current, send] = useMachine(navMachine)

We'll now need to create a helper function to grab our inner text values, this time we will need to make sure each character is capitalized.

  function getInnerText(e) {
    return e.target.innerText.toUpperCase();
  }

Now we will need to add an OnClick event to our anchor tags, the even will fire our send updater function and pass in a transition to our state machine.

<AnimatedAnchor href="#" onClick={(e) => send(getInnerText(e))}>
    Products
</AnimatedAnchor>

Lastly, we grab our state machines' context values and pass the values via props to our Styled component. Those props are used to dynamically update our div.

// The context Values passed in via props dynamically update our div!

<NavigationHoverEffect
    translateX={current.context.translateX}
    scaleX={current.context.scaleX}
/>
const NavigationHoverEffect = styled.div`
  width: 10px;
  height: 4px;
  background-color: hsla(201, 63%, 55%, 1);
  position: absolute;
  top: 57px;
  right: -10px;
  transition: transform 500ms ease-in-out;

  transform: ${(props) =>
    `translateX(${props.translateX}px) scaleX(${props.scaleX})`};
`;

That's it! We made it! With the use of state machines and styled-components we can really do some neat.