Icons
RootNative components accept icons in three forms — a string name, a pre-rendered React element, or a render function. By default, string names resolve through @expo/vector-icons/MaterialCommunityIcons, but you can plug in any icon library (Lucide, SF Symbols, custom SVGs) globally via the theme's iconResolver, 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 MaterialCommunityIcons out of the box without any extra setup.
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 iconResolver (MCI by default)
| 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're using MCI, or you've configured a 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. |
Default — MaterialCommunityIcons
With no resolver configured, string names resolve to MaterialCommunityIcons. This is the zero-config path — install @expo/vector-icons and pass any MCI name:
import { Button, IconButton } from '@rootnative/components'
<Button leadingIcon="plus">Add</Button>
<IconButton icon="heart-outline" accessibilityLabel="Favorite" />
@expo/vector-icons is only required if you actually pass a string icon. The library imports it lazily — components render fine without it as long as you don't pass string icons.
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@expo/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 @expo/vector-icons set (Ionicons, FontAwesome, …) |
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.
@expo/vector-icons
Use this when you want a different vector-icon set than the default MaterialCommunityIcons, or when you want to register a small alias map at the resolver level:
import { Ionicons } from '@expo/vector-icons'
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 fall back to @expo/vector-icons/MaterialCommunityIcons at that point. Decide what your component does: fall back the same way, render nothing, 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.