Typesafe SVG icons for Vite. Framework-independent JSX compilation with Lucide, Tabler, your own SVG files, or a custom icon library. Every icon renders as <svg><use /></svg>.
Install znaki and an icon library:
pnpm add -D znaki lucide-staticAdd znaki before your framework plugin in vite.config.ts:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import znaki, { lucide } from "znaki/vite";
export default defineConfig({
plugins: [
znaki({
sources: [lucide()],
}),
react(),
],
});Use your application's JSX plugin (React, Solid, reze, etc.). znaki does not select a rendering backend or import framework runtime APIs. Only .jsx and .tsx sources are supported.
Add "znaki/client" to your existing TypeScript types list and "znaki.d.ts" to include:
{
"compilerOptions": { "types": ["znaki/client"] },
"include": ["src", "znaki.d.ts"]
}Start Vite once to generate znaki.d.ts, then use an icon:
import { Icon } from "znaki";
<Icon name="lucide:arrow-right" size={24} />;Icon names are autocompleted and checked by TypeScript. Run Vite or a build before typechecking a fresh checkout. Generated declarations contain the icon-name catalogue, not framework-specific types.
Icon forwards SVG attributes, event handlers and refs to the generated <svg>. Its default attribute types are framework-neutral. For native attribute completion and contextual event types, augment IconAttributes in your application's declaration file; for example, with React:
import type { SVGProps } from "react";
import "znaki";
declare module "znaki" {
interface IconAttributes extends Omit<SVGProps<SVGSVGElement>, "name" | "children" | "dangerouslySetInnerHTML"> {}
}For another JSX implementation, extend its SVG attribute type instead. This is a type-only integration; znaki's compiler and runtime remain unchanged.
import { Icon } from "znaki";
// Inherits the surrounding text color; size defaults to 1em.
<Icon name="lucide:heart" />;
// Explicit width and height take precedence over size.
<Icon name="lucide:arrow-right" size={24} width={32} />;
// Give a meaningful standalone icon an accessible name.
<Icon name="lucide:circle-alert" size={20} aria-label="Warning" />;
// A button's text already describes this decorative icon.
<button>
<Icon name="lucide:save" size={16} />
Save
</button>;Use className in React and class in Solid/reze. Lucide and Tabler follow currentColor; custom SVGs must use currentColor if they should inherit text color.
Icons are hidden from assistive technology unless you supply aria-label or aria-labelledby. An explicit aria-hidden overrides this default.
Use Icon directly in JSX. Import aliases are supported, but passing it as a component value, calling it as a function, and re-exporting it through a barrel are not. To make a reusable component, wrap <Icon> in your own component. Do not pass children or innerHTML/dangerouslySetInnerHTML to Icon.
Optionally render PreloadSprite once near the top of your page to start loading the shared icon file earlier:
import { PreloadSprite } from "znaki";
<PreloadSprite />;It takes no children.
Icons reference an external file, so the browser draws them empty until that file arrives, and they can flash on reload. To avoid this, embed the main sprite in the page:
import { PreloadSprite } from "znaki";
<body>
<PreloadSprite inline />…
</body>;Icons from the main sprite then use same-document references (href="#…") and render with the first paint. Lazy icons still load from their own file.
- Render it once, at the start of
<body>, not in<head>: it is a hidden<div>holding the sprite's<svg>. inlinemust be static (inlineorinline={true}). It changes every main-sprite reference in the app, so the file must be inside the scannedincludedirectories.- The sprite markup goes into the HTML and into the JS chunk of the component that renders it. Put it in a server-only document to keep it out of client bundles.
- The markup is injected with
dangerouslySetInnerHTMLwhen a React Vite plugin is present and withinnerHTMLotherwise.
Import sources from znaki/vite and add them to sources. You can combine several sources.
pnpm add -D lucide-staticimport { lucide } from "znaki/vite";
lucide(); // lucide:arrow-right, lucide:heart, ...
lucide({ prefix: "ui" }); // ui:arrow-right, ui:heart, ...Names use kebab-case. You do not need lucide-react or another framework-specific Lucide package.
pnpm add -D @tabler/iconsimport { tabler } from "znaki/vite";
tabler(); // tabler:home, tabler:user, ...
tabler({ variant: "filled", prefix: "filled" }); // filled:heart, ...The default variant is "outline".
import { local } from "znaki/vite";
local({ dir: "src/icons" });
// src/icons/logo.svg → local:logo
// src/icons/brand/mark.svg → local:brand/mark
local({ dir: "src/icons", prefix: "app" });
// src/icons/logo.svg → app:logoDirectories are relative to your Vite root. File changes are picked up during development.
Names normally have the form <prefix>:<name>. Set prefix: "" for bare names. If two sources provide the same full name, change their prefixes or set allowOverrides: true to prefer the first source.
Use library() when your icons come from JSON, path data, or another local format. Provide the available names, a loader, and a parser:
import znaki, { library } from "znaki/vite";
const catalogue: Record<string, { width: number; height: number; path: string }> = {
home: { width: 24, height: 24, path: "M3 12 12 3 21 12v9H3Z" },
};
const custom = library({
prefix: "custom",
list: () => Object.keys(catalogue),
load: (name) => catalogue[name] ?? null,
parse: (icon) => ({
viewBox: `0 0 ${icon.width} ${icon.height}`,
attrs: { fill: "currentColor" },
body: `<path d="${icon.path}"/>`,
}),
});
znaki({ sources: [custom] });
// Use <Icon name="custom:home" /> in your application.list()returns local names, without the prefix.load(name)returns the raw icon data, ornullwhen unavailable.parse(value, name)returns an SVG string, an object withbody,viewBoxandattrs, ornullwhen unavailable.- Callbacks must be synchronous. Download remote catalogues before running Vite.
- For a file-backed catalogue, add
dirs: ["icons"]and aninit(root)callback that reads it.initruns at startup and again when files in those directories change. Relative directories are resolved from the Vite root.
Custom icons have the same name completion and loading options as built-in sources.
Literal names and simple conditionals work without extra configuration:
<Icon name="lucide:heart" />;
<Icon name={expanded ? "lucide:chevron-up" : "lucide:chevron-down"} />;For names supplied through props, arrays, configuration or API responses, declare which icons your application can use:
znaki({
sources: [lucide()],
includeIcons: ["lucide:home", "lucide:arrow-*"],
lazyIcons: ["lucide:chart-*"],
});includeIconsputs matching icons in the main sprite, together with statically discovered icons.lazyIconsputs matching icons in one separate SVG sprite. The browser loads that whole file when a<use>first references one of its icons; other icons use the same file. There are no shards, JS icon chunks, or inline-markup renderers.- An exact name matches only that icon.
*matches any sequence of characters;"lucide:*"selects the whole Lucide catalogue. - Static delivery wins: literal names, finite conditionals and
includeIconsstay in the main sprite even when they matchlazyIcons. - A dynamic
namewith neither list configured emits one warning per file with its location, namingincludeIconsorlazyIconsto fix delivery. Literal names and finite conditionals never emit this warning.
Type dynamic values as IconName:
import { Icon } from "znaki";
import type { IconName } from "znaki";
function MenuIcon(props: { name: IconName }) {
return <Icon name={props.name} size={20} />;
}TypeScript checks names, but does not configure loading: includeIcons or lazyIcons is still required for this wrapper. Validate names received from external data before using them.
Both paths emit <svg><use href="…svg#symbol-id" /></svg> and work without framework-specific loading components. PreloadSprite preloads or inlines only the main sprite.
| Option | Default | Purpose |
|---|---|---|
sources |
required | Icon libraries and local directories |
includeIcons |
[] |
Names or * patterns to load together |
lazyIcons |
[] |
Names or * patterns to load on demand |
allowOverrides |
false |
Prefer the first source when names overlap |
dts |
"znaki.d.ts" |
Generated type file path; false disables generation |
include |
project root | Directories to search for icon usage at startup |
exclude |
[] |
Additional directories to skip during that search |
Start Vite or run a build to regenerate znaki.d.ts. Check that it is included in your TypeScript project. If native SVG attributes lack completion, add the type-only IconAttributes augmentation described above.
Check its source, prefix and spelling. For a dynamic name, check includeIcons and lazyIcons too. Unknown literal names and missing exact names in those options produce development warnings and fail production builds.
Provide a valid viewBox, or positive numeric width and height (plain numbers or px). For example:
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M3 12 12 3 21 12v9H3Z" />
</svg>Use presentation attributes such as fill and stroke instead of <style> elements. Event handler attributes, scripts and other active elements, DTD/entity declarations, root-level paint references and SMIL syncbase references are unsupported. Use trusted project files or installed icon packages; znaki is not a sanitizer for user-uploaded SVGs.
- Import
IconandPreloadSpritefromznakiinstead ofznaki/reactorznaki/solid. - Remove
targetand place znaki before your application's JSX plugin. - Regenerate
znaki.d.tsand include it in your TypeScript project. - Replace
dynamicprefixes withincludeIcons/lazyIconsand explicit*patterns. - Replace
component: "MyIcon"withimport { Icon as MyIcon } from "znaki". - Add dimensions to SVGs that lack them. Resolve duplicate source names or opt into
allowOverrides.
MIT.