Every Lucide icon, animated. A drop-in replacement for lucide-react with hover-to-draw motion, full prop parity, and a low-level escape hatch for custom variants. Powered by Motion.
→ Docs, gallery, playground: lucide-motion.aashuu.tech
→ npm: lucide-motion
import { Heart } from "lucide-motion";
export default function Like() {
return <Heart trigger="hover" size={32} />;
}- 1,700+ icons — every Lucide icon exported as a React component.
- Drop-in for
lucide-react— identical prop surface (size,color,strokeWidth,absoluteStrokeWidth,className, refs). - Triggers —
hover,parent-hover,in-view,mount,manual. Imperativeplay()/reset()via ref. - Modes —
"draw"plus per-icon"signature"animations (heart beats, bell rings, loader spins forever). - Leave behavior —
complete,snap,redraw. Pick what happens when hover ends. - App-wide defaults — wrap a subtree in
<MotionIconConfig>and override duration, easing, trigger, etc. - Custom motion — pass your own
variantsto bypass the built-in draw and animate anything Motion can. - RSC-safe — components are client by default; library is
sideEffects: falseand tree-shakeable. - Dual ESM/CJS, full TypeScript types, ships with provenance.
- Respects
prefers-reduced-motionout of the box.
npm install lucide-motion motion
pnpm add lucide-motion motion
yarn add lucide-motion motion
bun add lucide-motion motionmotion, react, and react-dom are peer dependencies (motion >= 11, React 18 or 19).
import { Heart, Bell, Search } from "lucide-motion";
export function Toolbar() {
return (
<div className="flex gap-3 text-zinc-700">
<Heart trigger="hover" />
<Bell trigger="mount" />
<Search trigger="in-view" />
</div>
);
}Color is currentColor by default, so Tailwind text utilities work directly:
<Heart className="text-rose-500 hover:text-rose-600 transition-colors" />For the full API — triggers, leave modes, custom variants, manual control — see lucide-motion.aashuu.tech/docs.
This is a pnpm + Turborepo monorepo.
packages/
lucide-motion/ The published library (tsup, dual ESM/CJS, RSC-safe)
src/
engine.tsx Core <DrawIcon /> + provider + trigger plumbing
generated/ 1,700+ icon components (codegen output, gitignored)
manifest.ts Generated icon catalog: { name, component, tags: readonly string[] }
scripts/generate.ts Codegen from lucide-static
tsup.config.ts Two-pass build: main bundle + /manifest subpath
apps/
site/ Docs, gallery, playground (Next.js + Fumadocs)
The site imports the library by package name via workspace:*, so every example shown in the docs is the same code an npm consumer writes.
- Node 20+ (
engines.nodedeclares>=18; the workspace is tested on 20). - pnpm 10 — install with
corepack enable && corepack prepare pnpm@10 --activate, ornpm i -g pnpm.
git clone https://github.com/codeaashu/lucide-motion.git
cd lucide-motion
pnpm install
pnpm devpnpm dev runs the library in watch mode (tsup --watch) and the docs site (next dev) in parallel via Turborepo. The site auto-reloads when library source changes.
- Site: http://localhost:3000
- Library output:
packages/lucide-motion/dist/
Root (run from repo root, fans out via Turborepo):
| Script | What it does |
|---|---|
pnpm dev |
Library --watch + site next dev (parallel, persistent, not cached) |
pnpm build |
Build every package. Honors dependsOn: ["^build"] — lib builds before site. |
pnpm typecheck |
tsc --noEmit across all packages |
pnpm lint |
ESLint across all packages |
pnpm changeset |
Author a changeset for the next release |
pnpm version-packages |
Apply pending changesets locally (bumps versions + updates changelogs) |
pnpm release |
Build the lib, then changeset publish. Used by CI; you shouldn't run it locally unless you know what you're doing. |
Library workspace:
| Script | What it does |
|---|---|
pnpm --filter lucide-motion generate |
Regenerate src/generated/ + manifest.ts from lucide-static |
pnpm --filter lucide-motion build |
Two-pass tsup build (main bundle + /manifest subpath); runs generate first |
pnpm --filter lucide-motion dev |
tsup --watch --no-dts (skips type emit for faster reloads) |
pnpm --filter lucide-motion typecheck |
tsc --noEmit |
pnpm --filter lucide-motion clean |
Remove dist/ and src/generated/ |
Site workspace (apps/site):
| Script | What it does |
|---|---|
pnpm --filter site dev |
next dev only (no library watch — use pnpm dev from root for the full loop) |
pnpm --filter site build |
Production next build. Lib must be built first (Turborepo handles this from root). |
pnpm --filter site start |
Serve the built site (next start) |
pnpm --filter site lint |
eslint |
pnpm --filter site typecheck |
tsc --noEmit |
Before cutting a release, the library is checked against:
pnpm dlx publint packages/lucide-motion
pnpm dlx @arethetypeswrong/cli --pack packages/lucide-motion --ignore-rules no-resolutionpublint catches package.json issues (missing types, wrong main, broken exports). arethetypeswrong verifies the type entry resolves under every module system. CI runs both on every push and PR (.github/workflows/ci.yml).
The no-resolution rule is intentionally ignored — the /manifest subpath export is unresolvable on Node 10, which we don't support (engines.node >=18).
- Catalog versions. Shared deps (
react,react-dom,motion,typescript,@types/*) are pinned inpnpm-workspace.yamlundercatalog:. Reference them with"react": "catalog:"in workspacepackage.jsons. Bump once inpnpm-workspace.yaml, runpnpm install, every workspace picks it up. - Cross-workspace linking. The site depends on the library via
"lucide-motion": "workspace:*". pnpm replaces this with the actual version at publish time. - Strict postinstall.
pnpm-workspace.yamlonlyBuiltDependenciesallowlists which packages may run install scripts (esbuild,sharp,unrs-resolver). Anything else is skipped — pnpm's supply-chain default. If a new dep needs an install script, add it explicitly. - Frozen lockfile in CI. CI uses
pnpm install --frozen-lockfile. Always commitpnpm-lock.yamlchanges alongside dependency edits.
turbo.json caches build and typecheck outputs (dist/**, .next/** excluding .next/cache/**). Re-running an unchanged build is a near-instant cache hit. To force a clean run:
pnpm turbo run build --forceFor remote caching across machines/CI, sign in once with pnpm turbo login && pnpm turbo link.
Two workflows live under .github/workflows/:
ci.yml— runs on every push and PR tomain. Installs with a frozen lockfile, builds, typechecks, lints, then validates the library tarball withpublintandarethetypeswrong.release.yml— runs on push tomain. Useschangesets/actionto either open a "Version Packages" PR (when unreleased changesets exist) or publish to npm (after the version PR is merged).
Module not found: lucide-motion/...in the site after a fresh clone. Runpnpm --filter lucide-motion buildonce — the site needsdist/to exist.pnpm devfrom the root handles this automatically.- Icons missing or stale after a Lucide bump. Delete and regenerate:
pnpm --filter lucide-motion clean && pnpm --filter lucide-motion generate. ERR_PNPM_BAD_PM_VERSION. Your pnpm is older than thepackageManagerpin (pnpm@10.12.1). Runcorepack prepare pnpm@10 --activate.- Turborepo cache lying about a real failure.
pnpm turbo run build --forcebypasses the cache.
Per-icon component files in the library workspace source tree are codegen output and gitignored. They're regenerated from lucide-static before every build and dev run.
To pull in new icons, bump lucide-static and regenerate:
pnpm --filter lucide-motion up lucide-static
pnpm --filter lucide-motion generateThe generator writes:
- one component file per icon under
src/generated/ - a
manifest.tscataloging every icon (name, component, tags) - the barrel
index.tsre-exports
The engine source is hand-written. It owns:
<DrawIcon />— the core SVG renderer every generated component wraps<MotionIconConfig />— app-wide defaults provider- Trigger plumbing (
hover,parent-hover,in-view,mount,manual) - Leave behavior (
complete/snap/redraw) - The default draw variants
Changes here affect every icon; prefer adding props with safe defaults over breaking existing behavior.
The site uses workspace:* so it doesn't catch packaging bugs (missing exports, broken dist shape). Before releasing, smoke-test against a fresh app:
pnpm --filter lucide-motion build
cd /tmp && npx create-next-app@latest smoke --ts --no-tailwind --app
cd smoke
npm install /absolute/path/to/lucide-motion/packages/lucide-motion
# import { Heart } from "lucide-motion"; ...
npm run devVersioning and publishing go through Changesets.
- Author a changeset describing your change and the semver bump:
pnpm changeset
- Commit the generated
.changeset/*.mdfile with your PR. - On merge to
main, the Release GitHub Action opens a "Version Packages" PR that bumps versions and updates changelogs. - Merging that PR publishes
lucide-motionto npm with provenance.
The site app is private and never published.
Publishing requires either an NPM_TOKEN repo secret or (preferred) npm Trusted Publishing configured against .github/workflows/release.yml.
Issues and PRs welcome at github.com/codeaashu/lucide-motion. Before opening a PR:
- Run
pnpm typecheckandpnpm lint. - If the change is user-visible, add a changeset (
pnpm changeset). - Keep the public API of
lucide-motionbackwards compatible unless the change is intentionally breaking — flag it on the changeset.
PRs welcome — see Contributing.
MIT © aashuu
