动效图标
New一个组件承载七种 hover 动效图标(描边/摆动/旋转/弹跳/弹入/脉动/微移),用单个 variant prop 切换。基于 motion + lucide,尊重减少动态偏好。
Draw
Wiggle
Spin
Pop
TSXcomponents/previews/motion/animated-icon.preview.tsx
"use client";
import { Bell, Plus, Settings } from "lucide-react";
import {
AnimatedIcon,
type AnimatedIconVariant,
} from "@/components/motion/animated-icon";
const SAMPLES: { variant: AnimatedIconVariant; label: string; icon?: typeof Bell }[] = [
{ variant: "draw", label: "Draw" },
{ variant: "wiggle", label: "Wiggle", icon: Bell },
{ variant: "spin", label: "Spin", icon: Settings },
{ variant: "pop", label: "Pop", icon: Plus },
];
export function AnimatedIconPreview() {
return (
<div className="flex flex-wrap items-center justify-center gap-10 p-8 text-foreground">
{SAMPLES.map(({ variant, label, icon }) => (
<div key={variant} className="flex flex-col items-center gap-3">
<AnimatedIcon variant={variant} icon={icon} size={32} />
<span className="text-xs text-muted-foreground">{label}</span>
</div>
))}
</div>
);
}
TSXcomponents/motion/animated-icon.tsx
"use client";
// ui-lab-ten.vercel.app/components/motion/animated-icon
import type { LucideIcon } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export type AnimatedIconVariant =
| "draw"
| "wiggle"
| "spin"
| "bounce"
| "pop"
| "pulse"
| "nudge";
export interface AnimatedIconProps {
/** Which hover animation to play. */
variant: AnimatedIconVariant;
/** Icon size in px. */
size?: number;
className?: string;
/** Lucide icon component to animate. Ignored by the `draw` variant. */
icon?: LucideIcon;
/** SVG path `d` strings the `draw` variant renders. Defaults to a checkmark. */
paths?: string[];
}
const DEFAULT_PATHS = ["M5 13l4 4L19 7"];
/**
* Seven hover-played icon micro-interactions behind a single `variant` prop:
* `draw` self-draws its stroke (pathLength 0 → 1) — ignores `icon`, uses `paths`;
* `wiggle` rocks side to side; `spin` rotates a full turn; `bounce` springs
* vertically; `pop` springs its scale up and back; `pulse` loops a gentle
* scale while hovered; `nudge` springs a small step sideways. Reduced-motion
* renders a static icon (or a static checkmark for `draw`) with no animation.
*/
export function AnimatedIcon({
variant,
size = 28,
className,
icon: Icon,
paths = DEFAULT_PATHS,
}: AnimatedIconProps) {
const reduce = useReducedMotion();
if (variant === "draw") {
if (reduce) {
return (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
width={size}
height={size}
className={cn("text-current", className)}
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
{paths.map((d) => (
<path key={d} d={d} pathLength={1} />
))}
</svg>
);
}
return (
<motion.svg
aria-hidden="true"
viewBox="0 0 24 24"
width={size}
height={size}
className={cn("text-current", className)}
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
initial="rest"
whileHover="hover"
>
{paths.map((d) => (
<motion.path
key={d}
d={d}
variants={{
rest: { pathLength: 1 },
hover: { pathLength: [0, 1] },
}}
transition={{ duration: 0.6 }}
/>
))}
</motion.svg>
);
}
if (!Icon) return null;
if (reduce) {
return <Icon size={size} className={cn("text-current", className)} />;
}
switch (variant) {
case "wiggle":
return (
<motion.div
style={{ display: "inline-flex" }}
whileHover={{ rotate: [0, -14, 10, -6, 0] }}
transition={{ duration: 0.6 }}
>
<Icon size={size} className={cn("text-current", className)} />
</motion.div>
);
case "spin":
return (
<motion.div
style={{ display: "inline-flex" }}
whileHover={{ rotate: 360 }}
transition={{ duration: 0.6, ease: "easeInOut" }}
>
<Icon size={size} className={cn("text-current", className)} />
</motion.div>
);
case "bounce":
return (
<motion.div
style={{ display: "inline-flex" }}
whileHover={{ y: [0, 4, 0] }}
transition={{ type: "spring", stiffness: 400, damping: 12 }}
>
<Icon size={size} className={cn("text-current", className)} />
</motion.div>
);
case "pop":
return (
<motion.div
style={{ display: "inline-flex" }}
whileHover={{ scale: [1, 1.25, 1] }}
transition={{ type: "spring", stiffness: 400, damping: 12 }}
>
<Icon size={size} className={cn("text-current", className)} />
</motion.div>
);
case "pulse":
return (
<motion.div
style={{ display: "inline-flex" }}
whileHover={{ scale: [1, 1.15, 1] }}
transition={{ duration: 0.8, repeat: Number.POSITIVE_INFINITY }}
>
<Icon size={size} className={cn("text-current", className)} />
</motion.div>
);
case "nudge":
return (
<motion.div
style={{ display: "inline-flex" }}
whileHover={{ x: [0, 4, 0] }}
transition={{ type: "spring", stiffness: 400, damping: 14 }}
>
<Icon size={size} className={cn("text-current", className)} />
</motion.div>
);
default:
return null;
}
}
安装
用 shadcn CLI 添加,或手动复制源码。
$ bunx --bun shadcn add @uilab/animated-icon
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util file
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
TSXcomponents/motion/animated-icon.tsx
"use client";
// ui-lab-ten.vercel.app/components/motion/animated-icon
import type { LucideIcon } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export type AnimatedIconVariant =
| "draw"
| "wiggle"
| "spin"
| "bounce"
| "pop"
| "pulse"
| "nudge";
export interface AnimatedIconProps {
/** Which hover animation to play. */
variant: AnimatedIconVariant;
/** Icon size in px. */
size?: number;
className?: string;
/** Lucide icon component to animate. Ignored by the `draw` variant. */
icon?: LucideIcon;
/** SVG path `d` strings the `draw` variant renders. Defaults to a checkmark. */
paths?: string[];
}
const DEFAULT_PATHS = ["M5 13l4 4L19 7"];
/**
* Seven hover-played icon micro-interactions behind a single `variant` prop:
* `draw` self-draws its stroke (pathLength 0 → 1) — ignores `icon`, uses `paths`;
* `wiggle` rocks side to side; `spin` rotates a full turn; `bounce` springs
* vertically; `pop` springs its scale up and back; `pulse` loops a gentle
* scale while hovered; `nudge` springs a small step sideways. Reduced-motion
* renders a static icon (or a static checkmark for `draw`) with no animation.
*/
export function AnimatedIcon({
variant,
size = 28,
className,
icon: Icon,
paths = DEFAULT_PATHS,
}: AnimatedIconProps) {
const reduce = useReducedMotion();
if (variant === "draw") {
if (reduce) {
return (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
width={size}
height={size}
className={cn("text-current", className)}
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
{paths.map((d) => (
<path key={d} d={d} pathLength={1} />
))}
</svg>
);
}
return (
<motion.svg
aria-hidden="true"
viewBox="0 0 24 24"
width={size}
height={size}
className={cn("text-current", className)}
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
initial="rest"
whileHover="hover"
>
{paths.map((d) => (
<motion.path
key={d}
d={d}
variants={{
rest: { pathLength: 1 },
hover: { pathLength: [0, 1] },
}}
transition={{ duration: 0.6 }}
/>
))}
</motion.svg>
);
}
if (!Icon) return null;
if (reduce) {
return <Icon size={size} className={cn("text-current", className)} />;
}
switch (variant) {
case "wiggle":
return (
<motion.div
style={{ display: "inline-flex" }}
whileHover={{ rotate: [0, -14, 10, -6, 0] }}
transition={{ duration: 0.6 }}
>
<Icon size={size} className={cn("text-current", className)} />
</motion.div>
);
case "spin":
return (
<motion.div
style={{ display: "inline-flex" }}
whileHover={{ rotate: 360 }}
transition={{ duration: 0.6, ease: "easeInOut" }}
>
<Icon size={size} className={cn("text-current", className)} />
</motion.div>
);
case "bounce":
return (
<motion.div
style={{ display: "inline-flex" }}
whileHover={{ y: [0, 4, 0] }}
transition={{ type: "spring", stiffness: 400, damping: 12 }}
>
<Icon size={size} className={cn("text-current", className)} />
</motion.div>
);
case "pop":
return (
<motion.div
style={{ display: "inline-flex" }}
whileHover={{ scale: [1, 1.25, 1] }}
transition={{ type: "spring", stiffness: 400, damping: 12 }}
>
<Icon size={size} className={cn("text-current", className)} />
</motion.div>
);
case "pulse":
return (
<motion.div
style={{ display: "inline-flex" }}
whileHover={{ scale: [1, 1.15, 1] }}
transition={{ duration: 0.8, repeat: Number.POSITIVE_INFINITY }}
>
<Icon size={size} className={cn("text-current", className)} />
</motion.div>
);
case "nudge":
return (
<motion.div
style={{ display: "inline-flex" }}
whileHover={{ x: [0, 4, 0] }}
transition={{ type: "spring", stiffness: 400, damping: 14 }}
>
<Icon size={size} className={cn("text-current", className)} />
</motion.div>
);
default:
return null;
}
}
API 参考
variant"pulse" | "pop" | "draw" | "wiggle" | "spin" | "bounce" | "nudge"Which hover animation to play.
—size?numberIcon size in px.
28className?string—icon?LucideIconLucide icon component to animate. Ignored by the `draw` variant.
—paths?string[]SVG path `d` strings the `draw` variant renders. Defaults to a checkmark.
["M5 13l4 4L19 7"]Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.