Button
Buttons help people take actions and make choices with a single tap. Follows the Material Design 3 Button specification.
Usage
import { Button } from '@rootnative/components'
import { mdiResolver } from '@rootnative/components/mdi'
import { ThemeProvider } from '@rootnative/core'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { View } from 'react-native'
export default function App() {
return (
<SafeAreaProvider>
<ThemeProvider iconResolver={mdiResolver}>
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', padding: 16 }}>
<Button variant="filled" onPress={() => console.log('pressed')}>
Save
</Button>
</View>
</ThemeProvider>
</SafeAreaProvider>
)
}
Variants
| Variant | Use case |
|---|---|
filled | Primary action, highest emphasis |
elevated | Elevated surface, medium emphasis |
outlined | Secondary action, medium emphasis |
text | Low emphasis, often in dialogs |
tonal | Secondary container fill |
import { Button } from '@rootnative/components'
import { mdiResolver } from '@rootnative/components/mdi'
import { ThemeProvider } from '@rootnative/core'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { View } from 'react-native'
export default function App() {
return (
<SafeAreaProvider>
<ThemeProvider iconResolver={mdiResolver}>
<View style={{ flex: 1, justifyContent: 'center', padding: 16, gap: 12 }}>
<Button variant="filled" onPress={() => {}}>Filled</Button>
<Button variant="elevated" onPress={() => {}}>Elevated</Button>
<Button variant="outlined" onPress={() => {}}>Outlined</Button>
<Button variant="text" onPress={() => {}}>Text</Button>
<Button variant="tonal" onPress={() => {}}>Tonal</Button>
</View>
</ThemeProvider>
</SafeAreaProvider>
)
}
With Icons
leadingIcon and trailingIcon accept three forms — a string name (resolved via the theme's iconResolver, for example mdiResolver for MaterialDesignIcons), a pre-rendered React element from any icon library, or a render function that receives the resolved { size, color }. See the Icons guide for full details, including how to register a global iconResolver for Lucide, SF Symbols, or custom SVGs.
import { Button } from '@rootnative/components'
import { mdiResolver } from '@rootnative/components/mdi'
import { ThemeProvider } from '@rootnative/core'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { View } from 'react-native'
export default function App() {
return (
<SafeAreaProvider>
<ThemeProvider iconResolver={mdiResolver}>
<View style={{ flex: 1, justifyContent: 'center', padding: 16, gap: 12 }}>
<Button leadingIcon="plus" onPress={() => {}}>Add Item</Button>
<Button trailingIcon="arrow-right" onPress={() => {}}>Continue</Button>
</View>
</ThemeProvider>
</SafeAreaProvider>
)
}
Bring your own icon library
Pass any element directly — useful for one-off icons or when you want full control over sizing and color:
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>
Or use a render function to receive the icon size and color the button resolved
from its size, variant and current state, so the icon tracks them without
hardcoding:
<Button leadingIcon={({ size, color }) => <Check size={size} color={color} />}>
Save
</Button>
iconSize and contentColor override what the function receives, if you need
to depart from the resolved values:
<Button
leadingIcon={({ size, color }) => <Check size={size} color={color} />}
iconSize={20}
contentColor="#fff"
>
Save
</Button>
Disabled
import { Button } from '@rootnative/components'
import { mdiResolver } from '@rootnative/components/mdi'
import { ThemeProvider } from '@rootnative/core'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { View } from 'react-native'
export default function App() {
return (
<SafeAreaProvider>
<ThemeProvider iconResolver={mdiResolver}>
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', padding: 16 }}>
<Button disabled onPress={() => {}}>Disabled</Button>
</View>
</ThemeProvider>
</SafeAreaProvider>
)
}
Loading
Set loading while the action runs. The button shows an indeterminate
CircularProgress in place of the leading icon, at the icon size and in the
label color. It ignores presses, shows no hover or press state, and sets
aria-busy. It keeps its colors and its focus, so a keyboard user stays on it.
MD3 has no loading button, so this is a library addition.
import { Button } from '@rootnative/components'
import { mdiResolver } from '@rootnative/components/mdi'
import { ThemeProvider } from '@rootnative/core'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { useState } from 'react'
import { View } from 'react-native'
export default function App() {
const [isSaving, setIsSaving] = useState(false)
const save = () => {
setIsSaving(true)
setTimeout(() => setIsSaving(false), 2000)
}
return (
<SafeAreaProvider>
<ThemeProvider iconResolver={mdiResolver}>
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', padding: 16 }}>
<Button loading={isSaving} onPress={save} style={{ width: 160 }}>
Save
</Button>
</View>
</ThemeProvider>
</SafeAreaProvider>
)
}
With no leadingIcon, the spinner adds the icon and its gap, so the button
gets wider while it loads. A width in style keeps the size, as above. Pass
disabled as well if the button must also look disabled.
A button as a link
A button that opens a page must be a link on the web. A Button with an
onPress that calls router.push or Linking.openURL renders a <button>.
A crawler does not click a <button>, so it does not find the page.
Put the button inside Expo Router's <Link asChild>. Link gives the button
role="link" and href. That role replaces the button role, so the button
renders an <a href> on the web. A crawler follows it, and a visitor can copy
it or open it with a modifier key. On native, Link handles the press: it
navigates to a route, or opens an external URL with Linking.
import { Button } from '@rootnative/components'
import { Link } from 'expo-router'
<Link href="/docs" asChild>
<Button variant="text">Docs</Button>
</Link>
Open a link in a new tab
On the web, Link opens an external URL in the same tab. Do not use
<Link target="_blank"> for a new tab: under asChild, Link gives target
to the button as a plain prop, and react-native-web drops it. Give the button
hrefAttrs. react-native-web writes it onto the <a>, and Expo Router then
lets the browser open the new tab.
<Link href="https://github.com/rootnative" asChild>
<Button hrefAttrs={{ target: '_blank', rel: 'noopener' }}>GitHub</Button>
</Link>
hrefAttrs is web only, and native ignores it. @rootnative/components
declares it on the React Native ViewProps type, so you do not need a type
augmentation.
Props
| Prop | Type | Default | Required | Description |
|---|---|---|---|---|
children | string | - | Yes | Text label rendered inside the button. |
variant | enum | filled | No | Visual variant. Controls background, border, and text color. |
size | enum | small | No | MD3 Expressive size — one of `'extraSmall' | 'small' | 'medium' | 'large' | 'extraLarge'`. Sets container height, padding, icon size, and label typography. |
shape | enum | round | No | Container shape. `'round'` is a full pill; `'square'` rests at a size-dependent rounded corner. Both morph squarer on press. |
leadingIcon | IconSource | - | No | Icon rendered before the label. Accepts a string name (resolved via the theme's `iconResolver`, for example `mdiResolver`), a pre-rendered element, or a render function that receives `{ size, color }`. |
trailingIcon | IconSource | - | No | Icon rendered after the label. Accepts a string name (resolved via the theme's `iconResolver`, for example `mdiResolver`), a pre-rendered element, or a render function that receives `{ size, color }`. |
iconSize | number | - | No | Size of leading and trailing icons in dp. Used when resolving string icon names or invoking the render-function form. Pre-rendered elements are not resized. Defaults to the icon size for the current `size` (20 for `extraSmall` and `small`, 24 for `medium`, 32 for `large`, 40 for `extraLarge`). |
containerColor | string | - | No | Override the container (background) color. State-layer colors (hover, press) are derived automatically. |
contentColor | string | - | No | Override the content (label and icon) color. State-layer colors are derived automatically when no containerColor is set. |
labelStyle | StyleProp<TextStyle> | - | No | Additional style applied to the label text. |
loading | boolean | | No | Show an indeterminate `CircularProgress` in place of `leadingIcon`, at the icon size and in the label color. The button keeps its colors and its focus, ignores `onPress` and `onLongPress`, shows no hover or press state, and sets `aria-busy`. MD3 has no loading button, so this is a library addition. With no `leadingIcon`, the spinner adds the icon and its gap, so the button gets wider while it loads. Give it a `width` in `style` to keep the size. Pass `disabled` as well if the button must also look disabled. |
style | StyleProp<ViewStyle> | - | No | Style for the component. The keys that place or size it in its parent — `alignSelf`, `flex`, `flexGrow`, `flexShrink`, `flexBasis`, `width`, the margins, `position`, the insets and `zIndex` — go to the outer wrapper, so the focus ring and the shadow move with it. `minWidth` and `maxWidth` go with a `width`; alone, they style the container. The wrapper sets no `alignSelf`, so the parent's `alignItems` places the component. In a parent that stretches its children, the component keeps its own width at the start edge. Every other key styles the container. Static form only — the function form `(state) => style` is not supported because the component drives its container background through Reanimated. Use `containerColor` / `contentColor` for state-aware styling. |
hrefAttrs | { target?: string; rel?: string; download?: string | boolean; } | - | No | Web only. The `target`, `rel` and `download` of the `<a>` that a view with an `href` renders, for example under Expo Router's `<Link asChild>`. `target: '_blank'` opens the link in a new tab. Native ignores it. |
onKeyDown | (event: PressableKeyDownEvent) => void | - | No | - |