Why I built my own animated icons
Your first reaction is probably "another icon library?" That's fair. If you're building a normal app and want static icons, use Lucide or React Icons. They're great, and nothing here replaces them.
I wanted something those libraries don't try to do. I wanted icons that animate, take more than one color, have several animations each, and get configured once for the whole app instead of prop by prop. I couldn't find a library that did even 70% of that, so I built Animated Icons.
import { Bell } from "@kovenlabs/animated-icons";
<Bell /> // theme colors, plays on hover
<Bell variant="shake" trigger="auto" /> // its own variants, typed per icon
<Bell colors={{ accent: "destructive" }} /> // a shadcn token name or any CSS color
<Bell corners="sharp" /> // round (default), bevel or sharp geometry
<Bell trigger="none" /> // staticHow a folder of custom icons turned into a library
It started the way these things usually do. I needed a few multi-colored, animated icons for one project, so I drew them by hand in that repo. They were good enough.
Then I wanted them in a second project. Copying the folder over was easy. Copying the decisions was the hard part. Which color goes where? What does "hover" mean on an icon inside a button? How fast should the bell ring? I had answered all of that once, implicitly, inside the components.
So before drawing another icon I wrote down the API I actually wanted:
- A global config that every icon reads, so the defaults live in one place.
- Shared colors that come from my theme, with a way to override a single icon when a screen needs it.
- Several animation variants per icon, not one canned wiggle.
- Control over size, trigger, interval and speed.
- Control over how sharp the corners are, because a rounded icon looks wrong in a sharp-edged UI.
Then I went looking for a library that already had most of this, so I could contribute the rest instead of starting over. I found plenty of animated icon sets. Most were a single color, one animation per icon, and configured only through props on each instance. Adding a global config to them would have meant rewriting their API, and that's not a pull request anyone merges.
Colors come from your theme, not from props
Every icon paints with up to three slots: primary, secondary and accent. The slots are CSS
variables, and by default they point at your shadcn/ui tokens:
:root {
--icon-primary: var(--foreground);
--icon-secondary: var(--muted-foreground);
--icon-accent: var(--primary);
}Because they're plain CSS, dark mode just works, with no JavaScript and no re-render. The accent is
always the part that moves or means something. On the bell, the body is primary and the clapper
and sound waves are accent.
Overriding is just as loose. A token name resolves to its variable, so "destructive" becomes
var(--destructive), and any CSS color passes straight through:
<Mail colors={{ accent: "destructive" }} />
<Mail colors={{ accent: "#facc15" }} />The part I'm happiest with is the cascade. A slot you leave out takes the nearest slot above it, and never one below:
| You pass | primary | secondary | accent |
|---|---|---|---|
{ primary: A } | A | A | A |
{ primary: A, accent: C } | A | A | C |
{ accent: C } | theme | theme | C |
So one color paints the whole icon, and recoloring only the accent leaves the rest of your theme
alone. I went back and forth on this for a while. "Missing slots fall back to the theme" sounds
simpler, but then colors={{ primary: "red" }} gives you a red body with a theme-colored accent,
which is almost never what you meant.
Every icon has its own animations, and TypeScript knows which
A bell rings, shakes or jumps. A loader has different options. The variant names are declared per icon through module augmentation, so the type checker catches a variant that doesn't exist:
<Bell variant="shake" /> // ok
<Bell variant="spin" /> // type error: "spin" isn't a bell variantHere's roughly what a variant looks like inside the bell. Each one animates named parts of the drawing, and the clapper lags the body slightly, the way a real bell does:
ring: {
duration: 700,
run: ({ animate, seconds }) =>
Promise.all([
animate("[data-part=body]", { rotate: [0, -14, 11, -7, 4, 0] }, { duration: seconds }),
animate(
"[data-part=clapper]",
{ x: [0, 2, -2, 1.2, 0] },
{ duration: seconds, delay: seconds * 0.07 },
),
]),
},Every keyframe track ends at rest, which is a rule the test suite enforces on every icon. A finished cycle always leaves the icon still, and that makes the next feature possible.
Triggers, and why re-hovering never snaps
There are five triggers plus none:
<Bell /> // hover: one cycle per pointer enter
<Bell trigger="click" /> // one cycle per click
<Bell trigger="auto" interval={2000} /> // loops from mount, 2s rest between cycles
<Message trigger="inView" /> // loops while on screen
<Bell trigger="manual" ref={bell} /> // plays only when you call bell.current.play()
<Bell trigger="none" /> // static, nothing can play itIf you re-trigger an icon mid-animation, the running animations stop where they are and the new cycle starts from that pose. Sweep your mouse in, out and back in quickly, and the bell keeps swinging instead of jumping back to the start. This was the most annoying thing about the hand-rolled version, and fixing it is most of what the internal player does.
manual exists for the most common real case, which is an icon inside a button. You want the whole
button to play the icon, not the 16px square in its corner:
const bell = useRef<AnimatedIconHandle>(null);
<Button onMouseEnter={() => bell.current?.play()}>
<Bell ref={bell} trigger="manual" /> Notifications
</Button>trigger="none" is there for dense tables, print views, or a "reduce animations" setting in your
app. Separately, icons respect prefers-reduced-motion by default and render as their finished,
still drawing.
Corners are geometry, not a stroke setting
Most icon sets bake their corner radius into the drawing. If your UI is sharp and the icons are soft, you're stuck with it.
Here every icon is drawn from straight segments with sharp corners, and the factory reshapes the
path at render time. corners="round" turns each corner into a curve, "bevel" cuts it, and
"sharp" leaves it as drawn. Caps and joins follow along. cornerRadius sets how far each corner
reaches, and a corner never eats more than half a side, so short segments survive:
<Bell /> // round, radius 2 (the default)
<Bell corners="round" cornerRadius={3} /> // softer
<Bell corners="sharp" /> // as drawnThe trade-off is a strict drawing rule. Contributors can't hand-round a corner with rx or an arc,
because it would get rounded twice. Real curves like a loader ring or a camera lens are left alone.
It's more discipline than a normal icon set asks for, but it's the only way one prop can restyle all
207 icons consistently.
Configure it once, override where you need to
All of the above can be set at three levels, and the closest one wins: the global config, a provider around a subtree, and props on one icon.
export default defineIconConfig({
trigger: "hover",
interval: 1000,
speed: 1,
corners: "round",
cornerRadius: 2,
size: 24,
// per-icon overrides beat each icon's own defaults and lose only to props
icons: {
bell: { variant: "shake" },
rocket: { speed: 0.75, colors: { accent: "chart-1" } },
},
});Providers nest, and each one overrides only what it sets:
<AnimatedIconsProvider speed={1.5} colors={{ accent: "destructive" }}>
<Hero />
<AnimatedIconsProvider icons={{ message: { variant: "pop" } }}>
<Chat />
</AnimatedIconsProvider>
</AnimatedIconsProvider>This is the piece I couldn't find anywhere else, and it's the reason the library exists. Colors are deliberately not in the config, since they live in CSS where your theme already is.
Why I think your coding agent will like it
I build most things with an agent next to me now, so I designed for that reader too.
Everything an agent needs to get right is checked by the compiler or lives in one file. Variant names
are typed per icon, so a hallucinated variant fails the build instead of silently doing nothing.
Defaults sit in one config.ts, so "make all the icons slower" is a one-line change rather than a
search across the codebase. Every icon declares its keywords and what each color slot paints, and the
docs ship an llms.txt so an agent can read the
whole API without scraping the site.
Two ways to install: own the source or add a dependency
I've worked with shadcn/ui long enough to know that a lot of people want to own their components, so both paths are first class.
The shadcn registry copies the source into components/animated-icons/, adds the three color
variables to your globals.css, and gives you a config.ts you edit directly. Add the namespace to
components.json once:
{
"registries": {
"@kovenlabs": "https://animated-icons-nu.vercel.app/r/{name}.json"
}
}Then add one icon, a few, or all of them:
npx shadcn add @kovenlabs/bell
npx shadcn add @kovenlabs/allThe npm package is one tree-shaken dependency. Import one icon and you ship one icon. Your global config becomes a provider at the root of your app:
pnpm add @kovenlabs/animated-icons@alpha motionimport { AnimatedIconsProvider, defineIconConfig } from "@kovenlabs/animated-icons";
const iconConfig = defineIconConfig({ trigger: "hover", icons: { bell: { variant: "shake" } } });
// inside <body>:
<AnimatedIconsProvider {...iconConfig}>{children}</AnimatedIconsProvider>Pick the registry if icons are part of your design system and you want to tweak them. Pick the package if you just want to use them and bump a version to update.
What it costs, and what isn't done yet
The library is in alpha (0.1.0-alpha.2). The API can still change between 0.x releases, and
I'd rather change it now based on real feedback than freeze a mistake.
It requires React 19 and Motion 13. Motion is a real dependency, not a small one, so if you need a
single static icon, a static set is the lighter choice. That's also why trigger="none" exists, so
you can use one library for both cases once you've paid for it.
There are 207 icons today in 24 categories, from arrows and files to time, education and weather. That's far fewer than Lucide, and growing. Every icon goes through the same style guide, so the set stays consistent as it grows.
Try it, break it, send icons
The catalog lets you play every icon and variant, and the usage docs cover every prop. If something feels wrong in the API, open an issue. Now is the cheapest time to change it.
Contributions are welcome, especially new icons. The contributing guide walks through drawing one on the grid and giving it its variants.