Find icons by describing your use case in natural language.
The AI search uses sentence transformers (all-MiniLM-L6-v2) to understand your query and match it against pre-computed embeddings for all 2,146 icons — one vector per icon, not one per drawing.
Search for icons by describing where you'll use them:
Examples:
"shopping cart in header""user profile sidebar""navigation menu mobile""settings gear icon""delete trash button""download arrow"
| Good Queries | Less Effective |
|---|---|
"cart in checkout page" |
"cart" |
"user avatar dropdown" |
"user" |
"menu hamburger mobile" |
"menu" |
"notification bell badge" |
"bell" |
"play video button" |
"play" |
Be specific about context: the AI understands semantic relationships.
npx @devigner-ui/icons search "upload progress"
npx @devigner-ui/icons search "user profile sidebar" --limit=5 --threshold=0.2
npx @devigner-ui/icons search "map pin" --category=location-mapsThe CLI ranks against the same embeddings.json the browser does. Embedding the
query needs the model, so install it alongside:
npm install @xenova/transformersWithout it the command ranks by keyword and says so on stderr; --keyword asks
for that path directly and skips the model load. Full options are in the
CLI reference.
The package ships the vectors, not a search function; there is no
@devigner-ui/icons/ai subpath to import. Ranking is a dot product, so it is
shorter to write than to depend on:
import { pipeline } from '@xenova/transformers';
import metadata from '@devigner-ui/icons/metadata.json';
import embeddings from '@devigner-ui/icons/embeddings.json';
async function searchIcons(
query: string,
{ limit = 10, threshold = 0.15, category = null as string | null } = {},
) {
const extract = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2', {
quantized: true,
});
const out = await extract(query, { pooling: 'mean', normalize: true });
const q = Array.from(out.data as Float32Array);
const pool = category
? metadata.icons.filter(i => i.category === category)
: metadata.icons;
return pool
.map(icon => {
const vec = embeddings[icon.name] ?? [];
let dot = 0;
for (let i = 0; i < q.length; i++) dot += q[i] * (vec[i] ?? 0);
// Stored vectors are unit length scaled by 127; the query is unit
// length. So this is cosine, in [-1, 1].
return { icon, score: dot / 127 };
})
.filter(r => r.score >= threshold)
.sort((a, b) => b.score - a.score)
.slice(0, limit);
}A lower threshold returns more and worse; 0.15 is where the CLI sits.
Whichever model you use has to be the one that produced the vectors. bge-small
is also 384 dimensions, so swapping it in throws nothing; the query just lands
in the wrong space and every score becomes noise.
| Property | Value |
|---|---|
| Model | Xenova/all-MiniLM-L6-v2 |
| Dimensions | 384 |
| Size (quantized) | ~22MB |
| License | Apache-2.0 |
| Runtime | Transformers.js (WASM) |
| Metric | Value |
|---|---|
| First load | ~3-5s (model download) |
| Subsequent searches | ~50ms |
| Memory usage | ~50MB |
| Accuracy | High (semantic understanding) |
scripts/generate-embeddings.mjs builds one string per icon out of its display
name, category, keywords and intended use, embeds that, and quantizes the result
to int8, which is why embeddings.json is 2MB rather than 3MB of floats.
Quantization is the reason scores divide by 127 rather than comparing directly. The loss is well under the gap between a good match and a bad one.