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.tsts
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.
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.tsts
import { addCollection } from '@iconify/react';import mdi from '@iconify/json/json/mdi.json';addCollection(mdi);
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.tsxtsx
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.tsxtsx
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.tsxtsx
import PlayIcon from './icons/play.svg'; // svgr → a React componentimport rawStop from './icons/stop.svg?raw'; // the raw SVG stringexport 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.tsts
// 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.tsts
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.tsxtsx
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.tsxtsx
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:
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.tsxtsx
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.tsts
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.