Any icon library

Add the Vite plugin once. The icons you already import — Iconify, your own SVG files, unplugin-icons, a hand-rolled icon map — come out optically centered, with no per-icon work and nothing shipped to the browser.

One plugin, every icon

Every other page on this site documents the optical-center directive — a marker you write on a container or an <svg>. This page documents the opposite: the icons you don't mark. The Vite plugin reads your build graph, recognises icon SVG wherever it appears, and bakes the perceptual shift straight into the asset. You write your normal code.

vite.config.ts ts
import { defineConfig } from 'vite';
import opticalCenter from 'optical-center/vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  // This line is the entire setup. Nothing else changes.
  plugins: [opticalCenter(), react()],
});

No custom plugin to write, no precompute script, no committed offset file, no runtime library. The detection is by shape, not by package name — so a brand-new icon library, or one you built yourself this afternoon, is handled the same way as Iconify.

The one rule

There is exactly one thing that decides whether an icon is corrected automatically:

This is the natural boundary of a zero-runtime tool. Everything below is just that rule applied to the shapes icons actually ship in. When an icon can't be reached, the build prints a one-line summary saying so — it is never corrected silently and never skipped silently.

Compatibility at a glance

Where your icons come from What you write Correction
Iconify offline — @iconify/json + addCollection your normal import Automatic
Iconify single-icon — @iconify/icons-* your normal import Automatic
Local .svg files — svgr, ?raw, ?url your normal import Automatic
Your own icon-data module your normal export Automatic
unplugin-icons — ~icons/* your normal import Automatic
Hand-written inline <svg> optical-center="auto" on the svg Directive
Component libraries — lucide-react, react-icons, Heroicons optical-center: auto on the wrapper Directive
Runtime / remote — <img src>, Iconify API optical-center: auto on the wrapper Slot only

The first five rows are this page. The last three are the directive patterns — reach for them when the pixels aren't reachable, or when you want to centre the slot rather than the asset.

Iconify

Iconify is the common case, so it's first. Whether you register a whole collection or import one icon at a time, the geometry arrives as data — which is exactly what the plugin can read.

A whole collection

The idiomatic offline setup: import a set from @iconify/json and register it. You write this exactly as you would without optical-center.

icons.ts ts
import { addCollection } from '@iconify/react';
import mdi from '@iconify/json/json/mdi.json';

addCollection(mdi);
HomeButton.tsx tsx
import { Icon } from '@iconify/react';

export const HomeButton = () => (
  <button>
    <Icon icon="mdi:home" />
  </button>
);

What happens: the plugin intercepts mdi.json as Vite loads it, recognises the Iconify collection shape, and bakes the per-icon shift into each icon body. <Icon> then renders the corrected geometry. Nothing runs in the browser — the JSON that reaches the bundle is already corrected.

Single-icon modules

Importing one icon at a time — the @iconify/icons-* packages — works the same way, and only the icons you actually import get measured.

HomeButton.tsx tsx
import { Icon } from '@iconify/react';
import home from '@iconify/icons-mdi/home';

export const HomeButton = () => <Icon icon={home} />;

Dynamic icon names

Because the whole registered set is corrected at build time, the icon name can be fully dynamic at runtime and still render centered — the case that defeats every static-analysis approach.

IconGrid.tsx tsx
import { Icon } from '@iconify/react';

// `name` is unknown until runtime — still corrected, because the whole
// collection was baked at build time.
export const Cell = ({ name }: { name: string }) => (
  <Icon icon={`mdi:${name}`} />
);

Local SVG files

SVG files you import — as a svgr component, as a raw string, or as a URL — are corrected at the source. The plugin runs before svgr compiles, so by the time your component (or your ?raw string) exists, its viewBox already carries the shift.

Toolbar.tsx tsx
import PlayIcon from './icons/play.svg';      // svgr → a React component
import rawStop from './icons/stop.svg?raw';     // the raw SVG string

export const Toolbar = () => (
  <>
    <PlayIcon />
    <span dangerouslySetInnerHTML={{ __html: rawStop }} />
  </>
);

Works for a single file or a whole folder via import.meta.glob. Decorative artwork that happens to be an .svg — a logo, an illustration — can be opted out so it isn't nudged.

Your own icon data

The detection is structural, so a home-grown icon set — a plain module that maps names to SVG markup — is recognised with no adapter and no registration. This is the case that proves the plugin isn't tied to any one library: it has never heard of your module, and it works anyway.

my-icons.ts ts
// Not Iconify. Not even JSON. Just your strings.
export const icons = {
  play: '<path d="M8 5v14l11-7z" />',
  stop: '<rect x="6" y="6" width="12" height="12" />',
  next: '<path d="M5 4l10 8-10 8V4zM17 4h2v16h-2z" />',
};

What happens: the plugin parses the module's static export, sees string values that are SVG markup, and bakes the shift into each one. Because you own the source, this is the lowest-friction case of all — there's nothing to wire up.

unplugin-icons

unplugin-icons serves icons through virtual ~icons/* modules. The optical-center plugin sees the SVG those modules produce and corrects the viewBox before it reaches your component.

vite.config.ts ts
import Icons from 'unplugin-icons/vite';
import opticalCenter from 'optical-center/vite';

export default defineConfig({
  // Order doesn't matter — optical-center corrects what unplugin emits.
  plugins: [opticalCenter(), Icons({ compiler: 'jsx' })],
});
Home.tsx tsx
import IconHome from '~icons/mdi/home';

export const Home = () => <IconHome />;

Icon component libraries

Libraries like lucide-react, react-icons, and Heroicons don't ship icon data you import — they ship React components that draw the SVG when they render. There's no asset in the build graph to rewrite, so these use the directive: mark the wrapper and the build centres the slot.

PlayButton.tsx tsx
import { Play } from 'lucide-react';

export const PlayButton = () => (
  <button>
    <div optical-center="auto">
      <Play />
    </div>
  </button>
);

Same end result, reached a different way — the icon lands on its optical centre instead of its bounding-box centre. See the CSS class and Tailwind patterns for the CSS-side equivalents.

Runtime & remote icons

When the SVG only exists at runtime — fetched from the Iconify API, loaded from a remote URL, or set as an <img src> — the build genuinely cannot see the pixels. There is nothing to measure, so the icon's own mass can't be corrected. The honest fallback is to centre the slot it sits in:

RemoteIcon.tsx tsx
// styles.css
//   .icon-slot { optical-center: auto; }

export const RemoteIcon = ({ url }: { url: string }) => (
  <span className="icon-slot">
    <img src={url} alt="" />
  </span>
);

This centres the box the icon occupies, which is the best a build-time tool can do without the pixels. The build summary flags these so you know which icons fell back to slot-centering.

Opt out & control

Automatic means automatic, but you stay in charge of the edges. To skip a specific asset — a logo, an illustration, an icon you've already hand-tuned — add the ?optical=off query to its import:

Brand.tsx tsx
import logo from './brand/logo.svg?optical=off&raw';

To scope the whole pass — exclude a dependency, or narrow it to your own folders — pass include / exclude to the plugin:

vite.config.ts ts
opticalCenter({
  // Default is "everything reachable". Narrow it if you need to.
  iconData: {
    exclude: ['**/brand/**', /node_modules\/some-illustration-set/],
  },
});

How the shift is baked in

Same model as everywhere else in optical-center — rasterise once, find the optical centre, ship a flat result — applied per icon at build time. Three details make it safe to do automatically:

  • Detected by shape, not name. A value is treated as an icon only if it carries SVG markup and corroborating geometry; payloads that merely happen to have a body field (HTTP responses, CMS records) are rejected. Anything ambiguous is left untouched.
  • Wrapped, not reshaped. The shift is applied as an inner <g transform="translate(…)"> around the icon body rather than by editing its left/top fields. That's what lets a corrected Iconify icon still respond correctly to a runtime hFlip or rotate prop — the translate rides inside the renderer's own transform instead of being overwritten by it.
  • Measured once, cached on disk. Each unique icon body is rasterised a single time across your whole project and stored under node_modules/.cache. Repeat builds reuse it; identical icons shared across collections collapse to one measurement.

In vite build the pass completes before anything is emitted, so production output is fully corrected and flat. In vite dev a large set is served immediately and corrected in the background, then hot-reloaded into place — the dev server never blocks while a collection is measured.