Icons
RootNative components accept icons in three forms — a string name, a pre-rendered React element, or a render function. A string name resolves through the theme's iconResolver. Pass mdiResolver from @rootnative/components/mdi for MaterialDesignIcons, or plug in any icon library (Lucide, SF Symbols, custom SVGs) globally via the same prop, or per-call by passing an element/function.
This makes the library design-system agnostic: an Apple HIG app can use SF Symbols, a brand-driven app can ship custom SVGs, and an MD3 app gets MaterialDesignIcons with one prop.
The IconSource type
Every icon prop on every component (leadingIcon, trailingIcon, icon, selectedIcon, …) accepts an IconSource:
import type { IconSource } from '@rootnative/core'
type IconSource =
| string // resolved via the theme's iconResolver (e.g. mdiResolver)
| ReactElement // pre-rendered icon — caller sizes/colors it
| ((props: { size: number; color?: string }) => ReactNode) // render function
Pick the form that matches your situation:
| Form | When to use |
|---|---|
String ("check") | You pass mdiResolver, or your own global iconResolver that maps names. Easiest, theme-aware. |
ReactElement (<Check size={18} color="#fff" />) | One-off icon from any library. You pass size and color yourself. |
Render function (({ size, color }) => <Check {...} />) | Reusable wrapper that needs the component's resolved size and color. |
MaterialDesignIcons — mdiResolver
Install @react-native-vector-icons/material-design-icons (and expo-font on Expo), pass mdiResolver to ThemeProvider, and every string icon name resolves to an MDI glyph:
import { Button, IconButton } from '@rootnative/components'
import { mdiResolver } from '@rootnative/components/mdi'
import { ThemeProvider } from '@rootnative/core'
<ThemeProvider iconResolver={mdiResolver}>
<Button leadingIcon="plus">Add</Button>
<IconButton icon="heart-outline" accessibilityLabel="Favorite" />
</ThemeProvider>
@rootnative/components/mdi is the only module in the library that imports @react-native-vector-icons/material-design-icons, and the root entry never loads it. So the package is an optional peer: an app that passes another resolver, or no string icon names, does not install it, and a bundler without Expo needs no alias for it.
The package is the one Expo recommends in place of @expo/vector-icons (Expo blog). The root import loads the font at run time through expo-font, which works in Expo Go, in a development build and on web. A development build can embed the font instead: pass the /static export to createVectorIconsResolver from @rootnative/icons, and add the package to the plugins array of app.json. Do not keep @expo/vector-icons installed next to it: both register the same font, and the glyphs render as ? or as empty squares. npx @react-native-vector-icons/codemod rewrites app code that still imports the old package.
import { MaterialDesignIcons } from '@react-native-vector-icons/material-design-icons/static'
import { createVectorIconsResolver } from '@rootnative/icons'
const resolver = createVectorIconsResolver({ IconSet: MaterialDesignIcons })
Without a resolver, a string icon name renders nothing, and the library logs one warning per process that names this subpath. The library ships no default in the shared code on purpose, because a static import there would reach every app.
Per-call: pass any icon as a ReactElement
You don't need to configure anything to use a different icon library — just pass the element directly:
import { Button } from '@rootnative/components'
import { Check, ArrowRight } from 'lucide-react-native'
<Button leadingIcon={<Check size={18} color="#fff" />}>
Save
</Button>
<Button trailingIcon={<ArrowRight size={18} color="#fff" />}>
Continue
</Button>
You're responsible for the size and color in this form — the component won't override what you pass.
Per-call: pass a render function for theme-aware icons
If you want the icon to receive the component's resolved size and color (so it picks up iconSize, contentColor, disabled state, variant defaults, etc.), pass a function:
import { Button } from '@rootnative/components'
import { Check } from 'lucide-react-native'
<Button leadingIcon={({ size, color }) => <Check size={size} color={color} />}>
Save
</Button>
This is the form to use when you want consistent sizing/coloring without hard-coding values.
Note on component references. Don't pass an icon component reference directly (
leadingIcon={Check}) — wrap it in a function. The component reference would be called as a plain function, which bypasses React's hook rules.
Global: configure an iconResolver
If you'd rather keep using string names but route them to a different library, register a resolver on ThemeProvider. This is the cleanest approach when one icon library powers your whole app.
You have two paths:
- Use a built-in adapter from
@rootnative/icons. Recommended for Lucide, Phosphor, and@react-native-vector-icons/*— handles sizing, coloring, MDI-name compatibility, and missing-icon warnings for you. - Hand-roll a resolver. A plain
(name, { size, color }) => ReactNodefunction. Use this for SF Symbols, custom SVG sprites, or any other source.
Library adapters: @rootnative/icons
@rootnative/icons ships pre-built resolver factories so you don't have to figure out the resolver shape yourself:
npm install @rootnative/icons
| Helper | For |
|---|---|
createLucideResolver({ icons }) | Lucide (lucide-react-native) |
createPhosphorResolver({ icons }) | Phosphor (phosphor-react-native) |
createVectorIconsResolver({ IconSet }) | Any @react-native-vector-icons/* set (Ionicons, FontAwesome7, …), or the MDI /static export |
withLegacyMdiFallback(resolver) | Wrap any custom resolver to add MDI-name compatibility |
Each adapter accepts the icons it can render and returns an IconResolver you pass straight to ThemeProvider. Lucide and Phosphor adapters declare lucide-react-native / phosphor-react-native as optional peer deps — install only the one you actually use.
Lucide
import { ThemeProvider } from '@rootnative/core'
import { createLucideResolver } from '@rootnative/icons'
import { Check, Search, ArrowRight, Heart } from 'lucide-react-native'
const resolver = createLucideResolver({
icons: {
check: Check,
search: Search,
'arrow-right': ArrowRight,
heart: Heart,
},
mdiCompat: true, // accept legacy MDI names like "magnify", "delete"
strokeWidth: 1.75, // optional — Lucide default is 2
})
export default function App() {
return (
<ThemeProvider iconResolver={resolver}>
{/* String names render through Lucide */}
</ThemeProvider>
)
}
mdiCompat: true enables a built-in alias map so magnify → search, pencil → pencil, delete → trash-2, dots-vertical → more-vertical, etc. — useful when migrating an MDI codebase to Lucide without rewriting every call site. You can also pass an object to extend or suppress entries (mdiCompat: { magnify: 'binoculars', pencil: null }).
When a name isn't found, the resolver console.warns once per missing name. Override with onMissing: 'silent' to mute, or pass another IconResolver to chain.
Phosphor
import { ThemeProvider } from '@rootnative/core'
import { createPhosphorResolver } from '@rootnative/icons'
import { Check, MagnifyingGlass, ArrowRight } from 'phosphor-react-native'
const resolver = createPhosphorResolver({
icons: { Check, MagnifyingGlass, ArrowRight },
weight: 'regular', // 'thin' | 'light' | 'regular' | 'bold' | 'fill' | 'duotone'
mdiCompat: true, // legacy MDI names → PascalCase Phosphor names
})
<ThemeProvider iconResolver={resolver}>{children}</ThemeProvider>
Phosphor exports each glyph in PascalCase (e.g. MagnifyingGlass, DotsThreeVertical). The built-in MDI map maps magnify → MagnifyingGlass, dots-vertical → DotsThreeVertical, delete → Trash, etc.
@react-native-vector-icons/*
Use this for a vector-icon set other than mdiResolver, or to register a small alias map at the resolver level. Do not pass an @expo/vector-icons set. Expo is retiring that package, and its sets are classes, so TypeScript rejects them as IconSet (TS2322). Use the @react-native-vector-icons/* package of the same icon family:
import { Ionicons } from '@react-native-vector-icons/ionicons'
import { createVectorIconsResolver } from '@rootnative/icons'
const resolver = createVectorIconsResolver({
IconSet: Ionicons,
aliases: {
check: 'checkmark',
close: 'close',
'arrow-right': 'arrow-forward',
},
})
Names you don't alias are forwarded to the icon set verbatim — the set itself decides what to render for unknown glyphs.
Custom resolver + MDI compatibility
If you're rolling your own resolver (SF Symbols, SVG sprites, hand-rolled mappings), wrap it with withLegacyMdiFallback to add MDI-name compatibility without re-implementing the alias logic:
import { withLegacyMdiFallback } from '@rootnative/icons'
const baseResolver: IconResolver = (name, { size, color }) => {
const Svg = mySvgIcons[name]
return Svg ? <Svg width={size} height={size} fill={color} /> : null
}
// Pass-through unknown names through the lucide-flavored MDI map.
// `target` can also be 'phosphor' or your own Record<string, string>.
const resolver = withLegacyMdiFallback(baseResolver, { target: 'lucide' })
The base resolver is always tried first with the original name; the alias map is only consulted when the base returns null. The first call with each legacy name emits a one-time console.warn so you know which call sites still need to be migrated. Pass warn: false to suppress.
The maps behind target are exported too, as plain Readonly<Record<string, string>> objects — mdiToLucideAliases and mdiToPhosphorAliases from @rootnative/icons. Read them when you want to know whether a name is covered before relying on it, or to build a variation:
import { mdiToLucideAliases } from '@rootnative/icons'
mdiToLucideAliases.magnify // 'search'
mdiToLucideAliases.close // 'x'
// Same policy, one entry changed.
const resolver = withLegacyMdiFallback(baseResolver, {
target: { ...mdiToLucideAliases, magnify: 'binoculars' },
})
Both are deliberately small and hand-curated — the names that turn up in real usage plus the library's own system icons — not a complete MDI index. A name that isn't in the map isn't a bug; extend the map for it.
Manual resolver (no adapter)
You can skip the adapter and write the resolver inline. The shape is just (name, { size, color }) => ReactNode:
import { ThemeProvider } from '@rootnative/core'
import type { IconResolver } from '@rootnative/core'
import { Check, Plus, ArrowRight, Heart, HeartOff } from 'lucide-react-native'
const icons: Record<string, React.ComponentType<{ size?: number; color?: string }>> = {
check: Check,
plus: Plus,
'arrow-right': ArrowRight,
heart: Heart,
'heart-outline': HeartOff,
}
const lucideResolver: IconResolver = (name, { size, color }) => {
const Icon = icons[name]
return Icon ? <Icon size={size} color={color} /> : null
}
<ThemeProvider iconResolver={lucideResolver}>{children}</ThemeProvider>
Now any string name passed to a component flows through your resolver:
<Button leadingIcon="check">Save</Button>
<IconButton icon="heart" accessibilityLabel="Like" />
You can return null from the resolver to render nothing for unknown names, or fall back to a default icon.
useIconResolver — read the resolver in your own components
Components you write can accept string icon names the same way the library's do. useIconResolver() returns the resolver registered on the nearest ThemeProvider, or null when none was set:
import { useIconResolver, useTheme } from '@rootnative/core'
import { Row } from '@rootnative/components/layout'
import { Typography } from '@rootnative/components/typography'
function Badge({ icon, label }: { icon: string; label: string }) {
const theme = useTheme()
const iconResolver = useIconResolver()
return (
<Row align="center" gap="xs">
{iconResolver?.(icon, { size: 16, color: theme.colors.onSurface })}
<Typography variant="labelSmall">{label}</Typography>
</Row>
)
}
null means "no resolver configured", which is a real state and not an error — the library's own components render nothing for a string name at that point and log one warning. Decide what your component does: render nothing the same way, or require a resolver. A resolver can also return null for a name it doesn't know, so handle both.
SF Symbols on iOS (Apple HIG)
For Apple HIG-style apps, pair the resolver with expo-symbols:
import { ThemeProvider } from '@rootnative/core'
import type { IconResolver } from '@rootnative/core'
import { SymbolView } from 'expo-symbols'
const symbolMap: Record<string, string> = {
check: 'checkmark',
plus: 'plus',
'arrow-right': 'arrow.right',
heart: 'heart.fill',
'heart-outline': 'heart',
}
const sfResolver: IconResolver = (name, { size, color }) => {
const symbol = symbolMap[name]
if (!symbol) return null
return (
<SymbolView
name={symbol}
size={size}
tintColor={color}
resizeMode="scaleAspectFit"
/>
)
}
<ThemeProvider iconResolver={sfResolver}>
{/* String names render as SF Symbols on iOS */}
</ThemeProvider>
SymbolView only renders on iOS — wrap the resolver in a platform check or a fallback library if you target Android/web too.
Custom SVGs
Same pattern with react-native-svg:
import { ThemeProvider } from '@rootnative/core'
import type { IconResolver } from '@rootnative/core'
import { CheckSvg, PlusSvg } from './my-icons'
const svgResolver: IconResolver = (name, { size, color }) => {
if (name === 'check') return <CheckSvg width={size} height={size} fill={color} />
if (name === 'plus') return <PlusSvg width={size} height={size} fill={color} />
return null
}
<ThemeProvider iconResolver={svgResolver}>
{/* String names render your SVGs */}
</ThemeProvider>
Mixing forms
Per-call elements and functions always take precedence over the resolver — you can register a resolver for the common case and still drop in one-off icons:
<ThemeProvider iconResolver={lucideResolver}>
{/* Uses the resolver */}
<Button leadingIcon="check">Save</Button>
{/* Bypasses the resolver — explicit element wins */}
<Button leadingIcon={<CustomBrandLogo size={18} />}>
Branded action
</Button>
</ThemeProvider>
Components that use string icons internally
A few components render system icons of their own (for example, the checkmark inside a Checkbox, or the close button on an input Chip). Those names also flow through the configured iconResolver, so a Lucide-only resolver that doesn't map them will leave those icons missing.
The system names that components rely on:
| Component | System icon names | Rendered when | Override prop |
|---|---|---|---|
AppBar | chevron-left / chevron-right on iOS, arrow-left / arrow-right elsewhere | canGoBack | — |
Checkbox | check | Always (the mark) | checkIcon |
Chip | check, close | variant="filter" and selected; onClose given | — |
Dialog | close | variant="fullscreen" | closeIcon |
Snackbar | close | showCloseIcon on the entry | closeIcon on the entry |
The AppBar pair mirrors under RTL and splits by platform, so a resolver that maps only what it saw on one device will still lose the back arrow on the other — map all four.
You have two ways to handle this when adopting a custom resolver:
- Map the system names in your resolver. Include
check,closeand the back-arrow set alongside your other mappings — easiest path, and themdiCompatalias maps already carry all six. - Override per-component with an explicit
IconSource, where the component offers a prop for it (the right-hand column above).CheckboxexposescheckIcon?: IconSource:
import { Checkbox } from '@rootnative/components'
import { Check } from 'lucide-react-native'
<Checkbox
value={checked}
checkIcon={({ size, color }) => <Check size={size} color={color} />}
onValueChange={setChecked}
/>
Sizing and color reference
Each component decides its own default icon size based on its variant or size prop. The general rule, which holds library-wide: size comes from an explicit iconSize prop where one exists and is otherwise derived from the component's size or variant, and color is whatever the component resolved for its content — the variant default, then contentColor (or a narrower icon-color prop where the component has one), then the disabled treatment, which nothing overrides. Check the component's own page for its numbers.
Two worked examples rather than an exhaustive list:
| Component | Source of size | Source of color |
|---|---|---|
Button | iconSize prop (default 18) | Resolved label color (variant default → contentColor → disabled treatment) |
IconButton | Derived from size prop — small: 18, medium: 24, large: 28 | Variant + state default → iconColor → contentColor |
The color may be undefined when the component lets the icon library inherit from a parent text style — handle that in your resolver by passing it through unchanged, since most icon libraries treat undefined as "use the default".
TypeScript
All three types live in @rootnative/core:
import type { IconResolver, IconRenderProps, IconSource } from '@rootnative/core'
@rootnative/icons re-exports IconResolver and IconRenderProps for convenience, plus its own helper types:
import type {
LucideResolverOptions,
PhosphorResolverOptions,
PhosphorIconWeight,
VectorIconsResolverOptions,
WithLegacyMdiFallbackOptions,
} from '@rootnative/icons'
Use these when you build wrapper components or shared resolvers.