Skip to main content

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:

FormWhen 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:

  1. 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.
  2. Hand-roll a resolver. A plain (name, { size, color }) => ReactNode function. 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
HelperFor
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:

ComponentSystem icon namesRendered whenOverride prop
AppBarchevron-left / chevron-right on iOS, arrow-left / arrow-right elsewherecanGoBack—
CheckboxcheckAlways (the mark)checkIcon
Chipcheck, closevariant="filter" and selected; onClose given—
Dialogclosevariant="fullscreen"closeIcon
SnackbarcloseshowCloseIcon on the entrycloseIcon 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:

  1. Map the system names in your resolver. Include check, close and the back-arrow set alongside your other mappings — easiest path, and the mdiCompat alias maps already carry all six.
  2. Override per-component with an explicit IconSource, where the component offers a prop for it (the right-hand column above). Checkbox exposes checkIcon?: 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:

ComponentSource of sizeSource of color
ButtoniconSize prop (default 18)Resolved label color (variant default → contentColor → disabled treatment)
IconButtonDerived from size prop — small: 18, medium: 24, large: 28Variant + 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.