Icon Recipes
Everyday interface patterns built with motion-icon and
motion-icon-state. Each snippet is complete: copy it into a page that loads the library.
<script type="module" src="https://cdn.jsdelivr.net/npm/motion-components@1/+esm"></script>Button with an animated icon
Put a motion-icon inside a button. It animates when the pointer enters any part of the button, not only the icon.
<button>
<motion-icon src="https://cdn.jsdelivr.net/npm/lucide-static@1/icons/send.svg" animation="bounce"></motion-icon>
Send
</button>Notification bell
The bell wiggles when a notification arrives. Call replay() from your code, and update the badge and the button name for screen readers.
<button id="notifications" aria-label="Notifications">
<motion-icon src="https://cdn.jsdelivr.net/npm/lucide-static@1/icons/bell.svg" animation="wiggle"></motion-icon>
<span id="notification-count" class="badge" hidden>0</span>
</button>
<button id="simulate">Simulate a notification</button>
<script type="module">
const button = document.querySelector('#notifications')
const bell = button.querySelector('motion-icon')
const badge = document.querySelector('#notification-count')
let count = 0
function notify() {
count += 1
badge.textContent = count
badge.hidden = false
button.setAttribute('aria-label', `Notifications, ${count} new`)
bell.replay()
}
document.querySelector('#simulate').addEventListener('click', notify)
</script>
<style>
.badge {
min-width: 1.1rem;
padding: 0 0.3rem;
border-radius: 999px;
background: #e11d48;
color: #fff;
font-size: 0.7rem;
font-weight: 700;
}
</style>Like button with a count
The heart fills on a spring and the count goes up or down. The button keeps aria-pressed in sync, so screen readers know the state.
<button id="like" aria-pressed="false" aria-label="Like">
<motion-icon-state name="heart"></motion-icon-state>
<span id="like-count">128</span>
</button>
<script type="module">
const button = document.querySelector('#like')
const heart = button.querySelector('motion-icon-state')
const count = document.querySelector('#like-count')
button.addEventListener('click', () => {
const liked = button.getAttribute('aria-pressed') !== 'true'
button.setAttribute('aria-pressed', liked)
heart.active = liked
count.textContent = Number(count.textContent) + (liked ? 1 : -1)
})
</script>Download button: arrow, spinner, check
Two components work together. motion-icon shows the arrow and bounces on hover. On click, motion-icon-state takes over with a spinner that turns into a check.
<button id="download">
<motion-icon src="https://cdn.jsdelivr.net/npm/lucide-static@1/icons/download.svg" animation="bounce"></motion-icon>
<motion-icon-state name="loading" hidden></motion-icon-state>
Download
</button>
<script type="module">
const button = document.querySelector('#download')
const arrow = button.querySelector('motion-icon')
const status = button.querySelector('motion-icon-state')
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
button.addEventListener('click', async () => {
button.disabled = true
arrow.hidden = true
status.hidden = false
status.active = false // spinner
await wait(1500) // replace with your download
status.active = true // check
await wait(1500)
status.hidden = true
arrow.hidden = false
button.disabled = false
})
</script>
<style>
motion-icon[hidden],
motion-icon-state[hidden] {
display: none;
}
</style>The [hidden] rule is needed because a component's own display style is stronger than the browser's default for hidden.
Navigation with icons
Each link has an icon with its own animation. Inside a link, the icon animates when the pointer enters the whole link.
<nav class="icon-nav">
<a href="#home"><motion-icon src="https://cdn.jsdelivr.net/npm/lucide-static@1/icons/house.svg"></motion-icon>Home</a>
<a href="#inbox"><motion-icon src="https://cdn.jsdelivr.net/npm/lucide-static@1/icons/inbox.svg" animation="bounce"></motion-icon>Inbox</a>
<a href="#calendar"><motion-icon src="https://cdn.jsdelivr.net/npm/lucide-static@1/icons/calendar.svg"></motion-icon>Calendar</a>
<a href="#settings"><motion-icon src="https://cdn.jsdelivr.net/npm/lucide-static@1/icons/settings.svg" animation="rotate"></motion-icon>Settings</a>
</nav>
<style>
.icon-nav {
display: grid;
gap: 0.25rem;
width: 12rem;
}
.icon-nav a {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.5rem 0.75rem;
border-radius: 8px;
color: inherit;
text-decoration: none;
}
.icon-nav a:hover {
background: rgb(0 0 0 / 0.05);
}
</style>Feature list that draws in on scroll
Each icon draws itself in when the list scrolls into view. A growing delay makes them follow each other.
-
Light Each icon file loads once and is shared. -
Accessible Reduced motion is respected. -
Simple One tag per icon.
<ul class="feature-list">
<li>
<motion-icon src="https://cdn.jsdelivr.net/npm/lucide-static@1/icons/zap.svg" trigger="view"></motion-icon>
<span><strong>Light</strong> Each icon file loads once and is shared.</span>
</li>
<li>
<motion-icon src="https://cdn.jsdelivr.net/npm/lucide-static@1/icons/shield-check.svg" trigger="view" delay="0.15"></motion-icon>
<span><strong>Accessible</strong> Reduced motion is respected.</span>
</li>
<li>
<motion-icon src="https://cdn.jsdelivr.net/npm/lucide-static@1/icons/sparkles.svg" trigger="view" delay="0.3"></motion-icon>
<span><strong>Simple</strong> One tag per icon.</span>
</li>
</ul>
<style>
.feature-list {
display: grid;
gap: 0.9rem;
margin: 0;
padding: 0;
list-style: none;
--mc-icon-size: 1.4rem;
}
.feature-list li {
display: flex;
align-items: center;
gap: 0.75rem;
}
</style>More recipes
The motion-icon-state page has five more: a menu button, a copy button, a save button with a spinner, a show password button and an accordion.