John KuehJohn Kueh
All articles

Article· Updated May 2026

An icon search that tells you when not to use an icon cover

I was wiring up a screen in journeys.im and needed an icon for a flight that hadn't been booked yet. Not a plane — a plane reads as "booked." I wanted something that said "intended, not confirmed." I opened the icon library's site, typed "flight," got forty planes, none of which meant what I meant, and twenty minutes later I was still scrolling.

That's the whole problem with icon libraries. They almost always have the icon you want. You just can't find it, because "search" means matching a name you don't know yet. The names are fine — ChefHatIcon, FridgeIcon, Airplane01Icon are already plain English. What they can't do is synonyms (I think "vegetarian," the icon is called Broccoli) or recall (I'm three files deep and just want the export name without alt-tabbing to a website).

So I built a search you talk to in plain English. Describe what you need:

Describe it:
  • Good matches across five libraries — pick one family and one stroke weight; mixing sets reads as broken.
  • Any functional icon needs an aria-label, a ≥24px target, and ≥3:1 contrast (WCAG).

That's the live thing, with real results. Two parts of it are worth pulling apart: how it ranks icons from a description, and the notes underneath — because the second part is the one I actually care about.

The fork I almost got wrong

The modern instinct for "rank things by meaning" is embeddings, and the flashy version is multimodal: render every glyph to an image, embed the pixels, match the query against the picture. I nearly did this. It's the wrong tool here. These are monochrome line drawings, and their names are already English — the signal lives in the text, not the pixels. Rendering 13,000 glyphs to embed them adds a heavy pipeline and a layer of noise, for worse results than just ranking the names.

Text embeddings were the next candidate: precompute a vector index, embed the query, cosine-match. Reasonable. But I had a second surface in mind — a public search box, and a chatbot coming after it — and if I'm already standing up an LLM endpoint for the chatbot, the marginal cost of letting it rank icon names is close to zero.

So the decision collapsed into something simpler than any of the embedding plans: an instant local match for the literal queries ("calendar," "trash"), and a model pass for the conceptual ones, narrowed first to a few hundred candidates so it stays fast. No vector store to maintain, and the results are always fresh against whatever is actually installed. You never pick which kind of search you're doing — you type, and it figures out whether your words are a name or a concept.

The part I'm actually proud of

Look again at the notes under the demo. Every search comes back with a line on whether an icon is even the right call.

The research on this is unambiguous and mostly ignored. Nielsen Norman Group found that only a tiny set of icons — home, search, print — are close to universally understood; nearly everything else is ambiguous without a text label. So when the top match is an overloaded glyph — a bookmark (save, or read-later?), a heart (love, or save?), a gear (settings, or profile?) — the tool flags it. When your results span two libraries, it reminds you that mixing icon sets and stroke weights reads as broken. And it always closes with the accessibility floor: a functional icon needs an aria-label, a 24px target, and 3:1 contrast.

Most icon pickers help you find an icon faster. I wanted one that occasionally tells you not to use one. That's the difference between a tool that ships pixels and a tool that has a point of view — and for the people I build for, the judgment is the product.

Under the hood, briefly

Two details make the rest work, if you care.

It never hands you an icon you can't use. The search reads which icon packages a project has installed, scopes results to those, and emits the correct import for that exact package — <Vegan /> from lucide-react, <HugeiconsIcon icon={VegetarianFoodIcon} />, Phosphor's weight prop, Tabler's Icon prefix. The names are real because they come from the package on disk, not a model's memory of what icons probably exist. That's the failure mode that makes most "ask the model for an icon" tricks useless, and it's the reason an agent can use this safely.

And each library is one small module — a reader that pulls its package into a normalized record, and an emitter that knows its import style. Adding a sixth is a single file. I deliberately didn't route through an aggregator that bundles every icon set; they normalize hundreds of thousands of icons beautifully but strip the per-icon keywords each library ships, which is exactly the signal a search wants.

Where it runs

Three surfaces, one engine. There's the search box on this site — 13,763 icons across Lucide, Phosphor, Tabler, Heroicons and HugeIcons today, growing as I add libraries. There's a page for every icon with install commands, copy-as-SVG or JSX, every weight, and the same concept rendered across the other libraries. And there's a Claude Code skill in my plugin, so when an agent is building a screen it reaches for icons the way I would.

The whole thing came together across an afternoon of background Claude Code sessions while I worked on other things. That's the part I keep coming back to: the distance between "this annoys me" and "this is live" keeps shrinking, and the leverage is in spending that distance on judgment — the heuristics, the honest scope — instead of plumbing.

It started because I couldn't find a plane that meant "not booked yet." It's Airplane off, by the way, in three of the five libraries. The search found it in one sentence.

Frequently asked

Why not use multimodal image embeddings?

Icon names are already English, so the semantic signal is mostly textual. Rendering thousands of monochrome line glyphs and embedding the pixels adds a heavy build step and a layer of noise, for worse results than ranking the names directly. The flashy approach loses to the boring one here.

How does it avoid suggesting icons a project can't import?

It detects which icon packages a project has installed and scopes the search to those, then emits the correct import for that exact package — Lucide's named export, HugeIcons' icon prop, Phosphor's weight, Tabler's prefix. The names are real because they come from the installed package, not from the model.

Can I use it from Claude Code?

Yes. The same engine ships as a skill in my claude-skills plugin, so an agent picking icons gets the same ranked results and paste-ready import snippets the web tool does — scoped to whatever the project already depends on.

Why five libraries and not all of them?

Each library is integrated from its own package so the search can use its native keywords and emit the right import — a module per library, not a free aggregator wrapper. Five is where it starts; the architecture is built so each new library is a single file, and the number climbs from there.