// One named export per icon, 2,146 of them
import { IconShoppingCart, IconArrowRight } from '@devigner-ui/icons';
// Types
import type {
IconName,
IconStyle,
IconVariant,
IconProps,
IconComponentType,
} from '@devigner-ui/icons';
// Data, as plain JSON
import metadata from '@devigner-ui/icons/metadata.json';
import embeddings from '@devigner-ui/icons/embeddings.json';
// Optional stylesheet
import '@devigner-ui/icons/styles.css';Those are the only entry points. There is no default export, no Icon shell
component, and no /ai subpath.
Every icon is a ref-forwarding component that renders its own <svg>. The
ref lands on that <svg>.
type IconVariant = 'Outline' | 'TwoTone' | 'Bold' | 'Bulk';
interface IconProps extends SVGProps<SVGSVGElement> {
/** Sizes and colours the glyph. Strokes are currentColor. */
className?: string;
/** Which of the four drawings to render. */
variant?: IconVariant;
/** Stroke width, in the 24px drawing's own units. */
strokeWidth?: string | number;
}Three props of its own, on top of everything an <svg> accepts: onClick,
style, id, width, fill, data-* and the aria attributes all pass
through to the element, and the ref reaches it too.
There is still no size prop; size comes from className. Nothing is
omitted from the inherited set: width and fill used to be taken over for
stroke width and which drawing, and are the element's own attributes again.
Icons render aria-hidden="true" and focusable="false" by default, because
an icon beside its own text label would otherwise be announced twice. Pass
aria-label or aria-labelledby and the icon becomes role="img" and is no
longer hidden.
<IconTrash className="size-4" /> {/* decorative, hidden */}
<IconTrash aria-label="Delete" className="size-4" /> {/* role="img" */}A style is one variant string:
| Style | variant |
What it draws |
|---|---|---|
outline |
"Outline" (the default) |
Full linework, every stroke at 1.5 |
twotone |
"TwoTone" |
The same linework, secondary strokes at half tone |
bold |
"Bold" |
No strokes. One filled form, cut with even-odd holes |
bulk |
"Bulk" |
Filled, with the secondary mass at half tone |
<IconShoppingCart className="size-6" /> {/* outline */}
<IconShoppingCart variant="TwoTone" className="size-6" />
<IconShoppingCart variant="Bold" className="size-6" />
<IconShoppingCart variant="Bulk" className="size-6" />
<IconShoppingCart strokeWidth={2} className="size-6 text-blue-600" />The defaults are variant = "Outline" and strokeWidth = "1.5", so a bare
<IconShoppingCart /> is outline at 1.5.
strokeWidth only reaches the linework, which Bold and Bulk do not have.
icons copy <name> --style=<style> prints the right prop for you.
A literal union of all 2,146 icon names, with the Icon prefix left off:
type IconName = '24Support' | '3dCubeScan' | 'ShoppingCart' | /* ... */;
const name: IconName = 'ShoppingCart'; // ✅
const invalid: IconName = 'NotExist'; // ❌ Type errorThe lowercase spelling the metadata, the CLI and the URLs use.
type IconStyle = 'outline' | 'twotone' | 'bold' | 'bulk';The same four drawings, spelled the way the variant prop takes them.
type IconVariant = 'Outline' | 'TwoTone' | 'Bold' | 'Bulk';type IconComponentType = ForwardRefExoticComponent<
IconProps & RefAttributes<SVGSVGElement>
>;Not FC: every icon forwards its ref to the <svg>, which is what Radix,
Headless UI and any tooltip or popover trigger need in order to measure and
position against it.
The components carry no static metadata: no displayName, keywords,
category or styles properties. That information is in metadata.json, keyed
by name.
There is deliberately no IconMap alias, because one of the icons is called
Map and the export IconMap is already that component. Write it out:
const byName: Partial<Record<IconName, IconComponentType>> = { ShoppingCart: IconShoppingCart };The shape of each entry in metadata.json. It is data, not an exported type:
interface IconMetadata {
name: string;
displayName: string;
category: string;
keywords: string[];
pathCount: number;
styles: Record<IconStyle, 'free' | 'pro'>;
freeStyles: IconStyle[];
proStyles: IconStyle[];
usage: string;
}metadata.json is a JSON file, so it is a default import; there is no named
metadata export to destructure.
import metadata from '@devigner-ui/icons/metadata.json';
metadata.version; // 1
metadata.generatedAt; // ISO timestamp
metadata.totalIcons; // 2146
metadata.icons; // IconMetadata[]const icon = metadata.icons.find(i => i.name === 'ShoppingCart');
icon.freeStyles; // ['outline', 'twotone', 'bold', 'bulk']
icon.proStyles; // []
const arrowIcons = metadata.icons.filter(i => i.category === 'arrows-direction');proStyles is empty for every icon: all 8,584 icons are free. The
field is kept so that consumers read the data rather than assume, and there is a
single place to change if anything is ever sold again.
embeddings.json is Record<string, number[]>: one 384-dimension vector per
icon name, quantized to int8. The vectors were produced by
Xenova/all-MiniLM-L6-v2 from each icon's name, category, keywords and usage.
There is no similarity helper to import; the arithmetic is three lines and inlining it keeps you from depending on a subpath that does not exist.
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) {
// Must be the model that produced the vectors. bge-small is also 384
// dimensions, so a mismatch throws nothing and simply scores in the wrong
// space.
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);
return metadata.icons
.map(icon => {
const vec = embeddings[icon.name] ?? [];
let dot = 0;
for (let i = 0; i < q.length; i++) dot += q[i] * (vec[i] ?? 0);
// The stored vectors are unit length scaled by 127, and the query is
// unit length, so this is cosine * 127.
return { icon, score: dot / 127 };
})
.filter(r => r.score > 0.15)
.sort((a, b) => b.score - a.score)
.slice(0, limit);
}@xenova/transformers is not a dependency of this package. Installing icons
does not pull a machine-learning runtime into your project; you add it only if
you want to embed queries yourself. The icons search command does exactly the
above; see the CLI reference.
The package ships one executable, icons. Full options are in the
CLI reference.
npx @devigner-ui/icons <command> [options]| Command | Description |
|---|---|
search <query> |
Rank every icon against a description |
list |
List icons, with filters |
info <name> |
Show one icon's metadata |
categories |
List categories and their counts |
copy <name> |
Print an icon as a React snippet or as SVG |
# Semantic search over the shipped vectors
npx @devigner-ui/icons search "shopping cart in header" --limit=5
# Print one icon's snippet
npx @devigner-ui/icons copy ShoppingCart --style=outline
# Write a family out as SVG
npx @devigner-ui/icons copy "Arrow*" --format=svg --out=./arrows
# List with filters
npx @devigner-ui/icons list --category=arrows-direction --style=outlineRanking a query needs @xenova/transformers. Without it, search falls back to
keyword matching and says so.
The stylesheet is optional. It gives you one class and three variables; the components work without it.
@import '@devigner-ui/icons/styles.css';
:root {
--icon-size: 24px;
--icon-stroke-width: 2;
--icon-color: currentColor;
}<IconShoppingCart className="icon" />.icon reads all three, and stroke-width in CSS wins over the attribute the
component renders, so it overrides the strokeWidth prop.
There is nothing to check before use: every style of every icon is free. Map the style to its props:
import type { IconStyle, IconVariant, IconComponentType } from '@devigner-ui/icons';
const VARIANT: Record<IconStyle, IconVariant> = {
outline: 'Outline',
twotone: 'TwoTone',
bold: 'Bold',
bulk: 'Bulk',
};
function StyledIcon({
Component,
style,
}: {
Component: IconComponentType;
style: IconStyle;
}) {
return <Component variant={VARIANT[style]} className="size-6" />;
}