A small animation on an icon can confirm an action: a check mark that draws itself after saving, a bell that swings when there are new notifications, a copy icon that nudges when pressed. With the motion package (imported from motion/react) the animation is a few lines of variants on SVG elements. The harder part is making it fire for keyboard and touch users as well as mouse users, and stopping it when someone has asked for less motion.
How a Motion icon is put together
The icons in GradientDeck's animated icon library follow one pattern. Each file is a client component that renders a 24×24 Lucide-style SVG. The parts that move are motion.path or motion.g elements with two named variants, normal and animate. A useAnimation() controls object switches between them. Trimmed down, the check mark looks like this:
"use client";
import { motion, useAnimation, type Variants } from "motion/react";
const checkVariants: Variants = {
normal: { pathLength: 1, opacity: 1, rotateY: 0 },
animate: {
pathLength: [1, 0.8, 1],
opacity: [1, 0.8, 1],
rotateY: [0, 180, 360],
transition: { duration: 0.8, ease: "easeInOut" },
},
};
export const Check = ({ width = 28, height = 28, strokeWidth = 2, stroke = "#ffffff", ...props }) => {
const controls = useAnimation();
return (
<div
style={{ cursor: "pointer", padding: "8px", display: "flex" }}
onMouseEnter={() => controls.start("animate")}
onMouseLeave={() => controls.start("normal")}
>
<svg viewBox="0 0 24 24" width={width} height={height} fill="none"
stroke={stroke} strokeWidth={strokeWidth}
strokeLinecap="round" strokeLinejoin="round" {...props}>
<motion.path d="M20 6 9 17l-5-5" variants={checkVariants}
initial="normal" animate={controls} />
</svg>
</div>
);
};This is a good format for previewing in a gallery. In a product it has three gaps: the animation only starts from mouse events on a div, there is no reduced-motion handling, and the default stroke is white. The rest of this article covers how to close each gap.
Drawing a stroke with pathLength
Motion adds three SVG-only values: pathLength (how much of the line is drawn), pathOffset (where the drawn segment starts), and pathSpacing (the gap between repeated segments). Each is a progress value from 0 to 1. They work on path, circle, ellipse, line, polygon, polyline, and rect. Under the hood Motion sets the element's pathLength attribute to 1 and writes stroke-dasharray and stroke-dashoffset, so you never need to measure the path yourself.
const draw: Variants = {
normal: { pathLength: 1, opacity: 1 },
animate: {
pathLength: [0, 1],
opacity: [0, 1],
transition: {
pathLength: { duration: 0.45, ease: "easeOut" },
opacity: { duration: 0.1 },
},
},
};Fading opacity in alongside the stroke is not decoration. With strokeLinecap="round", a stroke at pathLength 0 still paints a dot at its starting point, because a zero-length dash gets round caps. In our Chrome test the dot was clearly visible at icon size; a butt cap left nothing. Animating opacity from 0 for the first fraction of the draw hides it.
Draw order follows the path data. The check mark above starts at its top-right end (M20 6), so it draws from the long stroke toward the short one. If you want it to draw the other way, reverse the path or animate pathOffset from 1 to 0 at the same time as pathLength. For icons made of several paths, give each its own transition delay, or put them in a parent with staggerChildren.
Hover, focus, and tap from the real control
An icon is rarely the interactive element. It sits inside a button or link, and that element should start the animation. Motion makes this easy with variant propagation: when a motion component's active variant changes, child motion elements with matching variant names follow, even through plain elements like svg in between. So the icon only declares variants, and the button decides when they play.
"use client";
import type { ComponentProps } from "react";
import { motion, type Variants } from "motion/react";
const draw: Variants = {
normal: { pathLength: 1, opacity: 1 },
animate: {
pathLength: [0, 1],
opacity: [0, 1],
transition: { pathLength: { duration: 0.45, ease: "easeOut" }, opacity: { duration: 0.1 } },
},
};
// No controls, no initial/animate props: it inherits labels from its parent.
export function CheckIcon({ size = 20, ...props }: ComponentProps<"svg"> & { size?: number }) {
return (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none"
stroke="currentColor" strokeWidth={2} strokeLinecap="round"
strokeLinejoin="round" aria-hidden="true" {...props}>
<motion.path d="M20 6 9 17l-5-5" variants={draw} />
</svg>
);
}
export function ConfirmButton() {
return (
<motion.button
type="button"
initial="normal"
animate="normal"
whileHover="animate"
whileFocus="animate"
whileTap="animate"
className="inline-flex items-center gap-2"
>
<CheckIcon />
Confirm
</motion.button>
);
}Each gesture prop covers a different input, and the details matter. In the Motion version GradientDeck uses (11.x), whileHover ignores pointer events from touch screens, so a tap on a phone does not leave an icon stuck in its hover state. whileFocus only activates when the element matches :focus-visible, which means keyboard focus animates the icon but a mouse click that focuses the button does not trigger a second play. whileTap covers mouse, touch, and pen presses. In Motion 11 it also responds to Enter on a focused element.
Animate on state, not only on gestures
Gestures are fine for decorative feedback. When an icon reports a result, such as Saved, Copied, or Sent, drive it from that state. Then it plays the same way for everyone, whatever started the action:
import { useState } from "react";
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
async function copy() {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}
return (
<motion.button type="button" onClick={copy} initial="normal"
animate={copied ? "animate" : "normal"}>
<CheckIcon />
<span aria-live="polite">{copied ? "Copied" : "Copy"}</span>
</motion.button>
);
}Respecting reduced motion
Wrapping your app in <MotionConfig reducedMotion="user"> is a good default, but it does less than people expect. According to Motion's documentation, it disables transform and layout animations when the operating system's reduce-motion setting is on, while still animating values like opacity and background color. pathLength is neither a transform nor a layout animation, so a draw-on icon keeps drawing. A rotating bell would stop swinging, but its fading parts would still fade.
For icons, choose the reduced behavior explicitly with useReducedMotion(), which returns true when the setting is on. Usually the right reduced version is no movement at all, or a short opacity change that still confirms the action:
import { motion, useReducedMotion, type Variants } from "motion/react";
const fade: Variants = {
normal: { pathLength: 1, opacity: 1 },
animate: { pathLength: 1, opacity: [0.4, 1], transition: { duration: 0.2 } },
};
export function CheckIcon(props: ComponentProps<"svg">) {
const reduceMotion = useReducedMotion();
return (
<svg viewBox="0 0 24 24" /* ...same attributes as before... */ {...props}>
<motion.path d="M20 6 9 17l-5-5" variants={reduceMotion ? fade : draw} />
</svg>
);
}Pay particular attention to icons that loop. Some icons in the library repeat while hovered (the Activity pulse line uses repeat: Infinity). A loop that runs as long as the pointer stays still is exactly what reduced-motion users want to avoid, so give these a static reduced variant.
Size, stroke, and color
The icons are drawn on a 24-unit viewBox, so the stroke scales with the rendered size. At the library's default of 28px, strokeWidth 2 renders about 2.3px wide; at 16px it is about 1.3px. If icons of different sizes sit side by side, adjust strokeWidth so the lines look the same weight: strokeWidth 1.5 at 28px is close to strokeWidth 2 at 21px.
- Use stroke="currentColor" so the icon follows the text color of its button, including hover, disabled, and dark-mode states. The library components default to white, which disappears on a white background.
- Size icons with the width and height props (or CSS on the svg) rather than transform: scale. Scaling a parent also scales the stroke and can blur it.
- The library components wrap the svg in a div with 8px padding and a pointer cursor. That adds 16px to the layout box. Remove the wrapper when the icon sits inside your own button.
- Mark decorative icons aria-hidden="true" and give the button an accessible name with visible text or aria-label. Because the components spread extra props onto the svg, you can pass aria-hidden and className directly.
Adapting a downloaded icon
To turn one of the library files into the propagation-based version above:
- Remove the outer div with onMouseEnter and onMouseLeave, or keep it only for a standalone preview.
- Delete const controls = useAnimation() and remove initial="normal" and animate={controls} from the motion children. They will now take their variant from the nearest motion parent.
- Change the stroke default to currentColor and add aria-hidden="true".
- Add a reduced variant with useReducedMotion for any icon that draws, loops, or moves more than a few pixels.
- Put the icon inside a motion.button or motion.a with initial, animate, whileHover, whileFocus, and whileTap set to the variant names.
Most icons in the library use the simple pattern. A few, including CheckCheck, Grip, and the chart icons, run a short sequence by awaiting several controls.start() calls. For those, keep the controls object and call the same function from your button's onFocus, onPointerEnter, and onClick handlers instead of relying on propagation.
Common mistakes
- Starting animations from onMouseEnter on a div. Keyboard users never see them, and touch browsers emulate the hover on tap and can leave it active.
- Assuming MotionConfig reducedMotion="user" covers SVG drawing. It does not stop pathLength or opacity.
- Animating many icons on page load. A grid of icons that draw in at once is hard to follow and delays nothing useful. Animate in response to an action.
- Long durations. Icon feedback should finish in a few hundred milliseconds; the check mark library icon uses 0.8 seconds for a full flip, which suits a gallery preview more than a button.
- Forgetting "use client" in Next.js App Router files. Motion components use hooks and must render on the client.
Getting the icons from GradientDeck
The GradientDeck animated icon library has more than 150 icons, searchable by name. Hover or tap an icon to preview it. The copy button opens the full React component source, and the download button saves it as a .tsx file; Download all saves every icon as separate .tsx files in a ZIP. Each file includes its own SVG paths, so Lucide does not need to be installed. The only dependency is Motion (npm install motion). The files work as they are for previews and galleries. For buttons and links, apply the changes above so the animation follows keyboard focus and the reduced-motion setting.
References
Motion: SVG animation in React
