Icons and packages
A component from a package works in any page. An icon from lucide-react does better than that: the build turns it into inline SVG, so the page ships no React for it.
A component from a package
Import a component from a package and use it in a static or native page. The build prints external-component for it, and the component is rendered as a React island: the island is the package’s own export, not a file of yours. The page around it stays static or native.
import { Chart } from 'fancy-charts' // a package, not a file of yours
export default function Report({ rows }: { rows: number[] }) {
return (
<section>
<h1>Totals</h1>
<Chart kind="bar" values={rows} /> {/* JSON props: literals and props paths */}
</section>
)
}The server renders it in the worker and the browser hydrates it from a chunk that imports the export. Each package export is one island, however many pages use it. The package must be installed in your project, as it is for any other import.
A package component takes the same props as any island: plain JSON values, either literals or values from your props. A function, a value that changes with state, or JSX children is refused; render those inside a React component of your own instead.
warning external-component Fallback.tsx:4:10 <Heart> comes from the package lucide-react; it renders as a React islandlucide-react icons
An icon imported from lucide-react (a named export, an alias such as Edit, or a deep import of one icon) is not an island. The build renders it once and writes the SVG into the page.
import { Heart, Plus } from 'lucide-react'
export default function Toolbar({ big, tone }: { big: number; tone: string }) {
return (
<p>
<Heart size={16} color="red" strokeWidth={1.5} className="love" />
<Plus />
<Heart size={big} color={tone} /> {/* values that change: lowered to attributes */}
</p>
)
}The build renders each icon with react-dom/server, using your project’s own react and lucide-react. The markup is therefore what React itself would print for the installed version, character for character.
What the build writes
A static page carries the SVG and nothing else: no worker job, no script, no island.
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="red" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-heart love" aria-hidden="true"><path d="M2 9.5a5.5 5.5 0 0 1 9.591-3.676…"></path></svg>Values that change
size, color, strokeWidth and className may be values that change: a prop, a value from state, a loop item. They become attributes of the SVG, computed like any other attribute: in a static page by the server, in a native page by the server for the first paint and by a binding afterwards.
<svg xmlns="http://www.w3.org/2000/svg"
{% if (n if n is defined and n is not none else "24") | present %}
width="{{ (n if n is defined and n is not none else "24") | attr_str | e }}"{% endif %}
x-bind-width="_c1" … x-bind-height="_c2" viewBox="0 0 24 24" fill="none"
{% if (c … else "currentColor") | present %} stroke="{{ … }}"{% endif %}
x-bind-stroke="_c3" stroke-width="2" … class="lucide lucide-heart" aria-hidden="true">size sets both width and height; color sets stroke; className is added after the icon’s own classes. In the browser the bindings keep the SVG in step with the state.
React’s defaults stay
When a value is undefined (or null), the icon is what React would render without that prop: 24 pixels, currentColor, a stroke width of 2, or the values of an enclosing LucideProvider. The build renders the icon a second time with those props left out and uses that markup as the fallback for each attribute.
Try it
The demo is plain JSX with the same rule: pick “undefined” and the attribute falls back to the default.
<Heart size={16} color={undefined} strokeWidth={undefined} />
width="16" height="16" stroke="currentColor" stroke-width="2"LucideProvider
A provider with constant props is applied at build, and its icons render with its values. A prop on the icon itself still wins.
import { Heart, LucideProvider, Plus } from 'lucide-react'
export default function Provided() {
return (
<LucideProvider size={32} color="green" strokeWidth={3}>
<Heart /> {/* 32px, green, stroke 3: applied at build */}
<Plus size={10} /> {/* 10px: its own prop wins */}
</LucideProvider>
)
}A provider whose props change at run time is not applied: its icons stay islands, and the build says so.
When an icon stays an island
warning icon-provider-dynamic DynamicProvider.tsx:5:5 LucideProvider prop `size` is not a constant (line 5:5); the icons under it render as React islands
warning icon-dynamic-prop Fallback.tsx:4:10 icon prop `aria-label` is not a constant and cannot be lowered to an attribute; the icon renders as a React island| What you wrote | What happens |
|---|---|
A prop other than size, color, strokeWidth, className that changes (an aria-label from props) | The icon is an island; icon-dynamic-prop names the prop. Make it a literal to get inline SVG. |
A LucideProvider with a prop that changes | The icons under it are islands; icon-provider-dynamic. |
The icon itself chosen at run time (const Icon = open ? Minus : Plus, or an icon from a map) | An island. Choosing between icons is not inlined yet (ledger row F126); use two icons and show one. |
| An icon the build cannot render (a lucide-react without that export) | An island, with icon-render in the build output. |
size combined with absoluteStrokeWidth when the size changes | An island, with icon-render: the stroke width would depend on the size. |
Versions
brust has no copy of lucide-react: the icons come from the version your project installs, so new icons and options appear as soon as you upgrade. The tests run against lucide-react 1.55.0, including nonScalingStroke, an alias import and LucideProvider.