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.
<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-componentsimport 'motion-components/motion-icon-state'Icons
Without active, the icon shows its first state. With active, it shows the second.
| | Default | |
|---|---|---|
| | menu | close |
| | play | pause |
| | copy | check |
| | plus | minus |
| | chevron down | chevron up |
| | empty heart | filled heart |
| | spinner | check |
| | 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.
<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.
<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 |
|---|---|---|---|---|
| | | string | 'menu' | |
| | | boolean | false | The second state: close, pause, check, minus, up, filled, done or eye-off |
| | | boolean | false | Makes the icon a button that switches |
| | | string | '' | Accessible name. Required with |
| | | number | 0.45 | Spring duration of the morph, in seconds |
| | | number | 0.3 | Spring bounciness of the morph |
CSS custom properties
| Property | Type | Default | Description |
|---|---|---|---|
| | color | currentColor | Line color |
| | color | --mc-icon-color | Color of the icon in its second state, when |
| | length | 1.5em | Width and height of the icon |
Events
| Event | Detail | Description |
|---|---|---|
| | | Fired when |
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.