motion-icon-state

Icons that morph between two states on a spring, and can be interrupted midway. Set name to pick the icon and active for the second state. They are drawn on a 24 px grid with 2 px round strokes, so they sit next to Lucide, Tabler and Heroicons icons.

This component used to be called motion-state-icon. The old tag and the motion-components/motion-state-icon import still work, with a warning in the console, until version 2.0.

Preview

Click an icon to switch its state.

menu
play
copy
plus
chevron
heart
loading
eye
<motion-icon-state name="menu" toggle label="Menu"></motion-icon-state>
<motion-icon-state name="play" toggle label="Play"></motion-icon-state>
<motion-icon-state name="copy" toggle label="Copy"></motion-icon-state>
<motion-icon-state name="plus" toggle label="Expand"></motion-icon-state>
<motion-icon-state name="chevron" toggle label="Expand"></motion-icon-state>
<motion-icon-state name="heart" toggle label="Like"></motion-icon-state>
<motion-icon-state name="loading" toggle label="Saving"></motion-icon-state>
<motion-icon-state name="eye" toggle label="Show password"></motion-icon-state>

Quick start

Save this as index.html and open it in a browser. No install, no build step.

<!doctype html>
<html>
  <head>
    <script type="module" src="https://cdn.jsdelivr.net/npm/motion-components@1/dist/motion-icon-state.js/+esm"></script>
  </head>
  <body>
    <motion-icon-state name="heart" toggle label="Like"></motion-icon-state>
  </body>
</html>

In a project with a bundler, install the package and import the component once:

npm install motion-components
import 'motion-components/motion-icon-state'

Icons

Without active, the icon shows its first state. With active, it shows the second.

name Default active
menu menu close
play play pause
copy copy check
plus plus minus
chevron chevron down chevron up
heart empty heart filled heart
loading spinner check
eye eye eye-off

Two ways to switch

1. The icon is the button

Add toggle and label. The icon switches itself on click, Space or Enter, and fires motion-change. label is required here: it is the button's name for screen readers, and leaving it out logs a warning.

not liked
<motion-icon-state id="like" name="heart" toggle label="Like"></motion-icon-state>

<script type="module">
  document.querySelector('#like').addEventListener('motion-change', (event) => {
    console.log(event.detail.active ? 'liked' : 'unliked')
  })
</script>

2. Your button controls the icon

Leave out toggle and set active from your own code. Use this when the icon sits inside a button or link that already handles the click and has the accessible name. All the recipes below work this way.

Recipes

Each snippet is complete. Paste it into a page that loads the library.

Menu button

<button id="menu-button" aria-label="Menu" aria-expanded="false">
  <motion-icon-state name="menu"></motion-icon-state>
</button>

<script type="module">
  const button = document.querySelector('#menu-button')

  button.addEventListener('click', () => {
    const open = button.getAttribute('aria-expanded') !== 'true'
    button.setAttribute('aria-expanded', open)
    button.querySelector('motion-icon-state').active = open
  })
</script>

Copy button

<button id="copy-button">
  <motion-icon-state name="copy"></motion-icon-state>
  Copy
</button>

<script type="module">
  const button = document.querySelector('#copy-button')
  const icon = button.querySelector('motion-icon-state')

  button.addEventListener('click', async () => {
    await navigator.clipboard.writeText('Text to copy')
    icon.active = true
    setTimeout(() => (icon.active = false), 1500)
  })
</script>

Save button

<button id="save-button">
  <motion-icon-state name="loading" active></motion-icon-state>
  Save
</button>

<script type="module">
  const button = document.querySelector('#save-button')
  const icon = button.querySelector('motion-icon-state')

  button.addEventListener('click', async () => {
    icon.active = false   // spinner
    await new Promise((resolve) => setTimeout(resolve, 1500))   // replace with your save
    icon.active = true    // check
  })
</script>

Show password

<input id="password" type="password" value="secret" />
<button id="password-button" aria-label="Show password">
  <motion-icon-state name="eye"></motion-icon-state>
</button>

<script type="module">
  const input = document.querySelector('#password')
  const button = document.querySelector('#password-button')

  button.addEventListener('click', () => {
    const visible = input.type === 'password'
    input.type = visible ? 'text' : 'password'
    button.setAttribute('aria-label', visible ? 'Hide password' : 'Show password')
    button.querySelector('motion-icon-state').active = visible
  })
</script>

Accordion

What is motion-components?

Web components with spring-based motion.

<details id="faq">
  <summary>
    What is motion-components?
    <motion-icon-state name="chevron"></motion-icon-state>
  </summary>
  Web components with spring-based motion.
</details>

<script type="module">
  const details = document.querySelector('#faq')

  details.addEventListener('toggle', () => {
    details.querySelector('motion-icon-state').active = details.open
  })
</script>

More patterns, such as a like button with a count and a download button, are on the Icon Recipes page.

Frameworks

Set the active attribute, or the active property from a script.

<script type="module" src="https://cdn.jsdelivr.net/npm/motion-components@1/dist/motion-icon-state.js/+esm"></script>

<motion-icon-state name="heart" toggle label="Like"></motion-icon-state>

React 19 sets active as a property, so pass a boolean from state.

import { useState } from 'react'
import 'motion-components/motion-icon-state'

export function LikeButton() {
  const [liked, setLiked] = useState(false)

  return (
    <button aria-pressed={liked} onClick={() => setLiked(!liked)}>
      <motion-icon-state name="heart" active={liked}></motion-icon-state>
      Like
    </button>
  )
}

Tell Vue that motion-* tags are custom elements (see Getting started), then bind :active.

<script setup>
import { ref } from 'vue'
import 'motion-components/motion-icon-state'

const liked = ref(false)
</script>

<template>
  <button :aria-pressed="liked" @click="liked = !liked">
    <motion-icon-state name="heart" :active="liked"></motion-icon-state>
    Like
  </button>
</template>

Colors

Lines use --mc-icon-color, which defaults to the text color. --mc-icon-color-active colors the icon in its second state, such as the close cross of menu, the filled heart or the copy check. It defaults to the line color, so set it when the second state should stand out.

default
--mc-icon-color-active
--mc-icon-color
--mc-icon-size
--mc-icon-color-active
<motion-icon-state name="heart" active style="--mc-icon-color-active: #f59e0b"></motion-icon-state>
<motion-icon-state name="copy" active style="--mc-icon-color: #6366f1"></motion-icon-state>
<motion-icon-state name="eye" style="--mc-icon-color: #0ea5e9; --mc-icon-size: 2rem"></motion-icon-state>
<motion-icon-state name="menu" active style="--mc-icon-color-active: #e11d48"></motion-icon-state>

Properties

Attribute Property Type Default Description
name name string 'menu' 'menu', 'play', 'copy', 'plus', 'chevron', 'heart', 'loading' or 'eye'
active active boolean false The second state: close, pause, check, minus, up, filled, done or eye-off
toggle toggle boolean false Makes the icon a button that switches active on click, Space or Enter
label label string '' Accessible name. Required with toggle: without it the icon name is read out and a warning is logged. Without label and toggle, the icon is decorative
duration duration number 0.45 Spring duration of the morph, in seconds
bounce bounce number 0.3 Spring bounciness of the morph

CSS custom properties

Property Type Default Description
--mc-icon-color color currentColor Line color
--mc-icon-color-active color --mc-icon-color Color of the icon in its second state, when active is set, including the filled heart and the copy and loading checks
--mc-icon-size length 1.5em Width and height of the icon

Events

Event Detail Description
motion-change { active } Fired when toggle is set and the user switches the state.

Accessibility

With toggle, the icon is a button with aria-pressed, reachable with Tab and switched with Space or Enter. Give it a label. Inside your own button, put the name on the button (its text or aria-label) and leave the icon without label. With prefers-reduced-motion, the icon switches state at once and the loading spinner stands still.