Headless, accessible UI primitives for React Native.
Anvil gives you the hard parts of building interactive components — state
management, gestures, and correct accessibilityRole/accessibilityState —
without imposing any visual style. You bring the style, Anvil brings the
behavior. Think of it as Radix UI for React
Native.
- Headless — no rendered styles, no theme to fight. Every primitive
accepts your
styleand exposes its state (open,selected,expanded, ...) via render-props so you decide what it looks like. - Accessible by default — the correct
accessibilityRoleandaccessibilityState/accessibilityValueare wired up for you on every primitive, including screen-reader-operable actions onSliderand proper focus-visible-equivalent modal semantics on overlays. - No extra native dependencies — overlays use React Native's own
Modal,SliderusesPanResponder. Nothing to link, no config plugins, works in a plain Expo managed app. - TypeScript-first — every prop, render-prop, and imperative ref is
fully typed;
.d.tsfiles ship in the package. - Hardened for real usage — root-level
disabled, imperative refs (open()/toggle()/getValue()/...), and dev-mode-onlyconsole.errorwarnings that catch common mistakes (mismatched controlled/uncontrolled usage, duplicate item values, out-of-range values) before they ship — all stripped from production builds. - Touch-first, deliberately — built for phone/tablet touchscreens. Keyboard and D-pad navigation are explicitly out of scope; see each primitive's accessibility notes for what is covered for screen reader users.
| Primitive | What it's for |
|---|---|
Accordion |
Expand/collapse one or many labeled sections |
Tabs |
Switch between panels sharing the same space |
ToggleGroup |
A row of options, single- or multi-select |
Collapsible |
One standalone expand/collapse panel |
Separator |
A decorative (or semantic) dividing line |
AspectRatio |
Constrain a child to a fixed width/height ratio |
Label |
A pressable, nativeID-bearing label for a control |
Popover |
Floating, anchored content triggered by a press |
Dialog |
A centered, blocking modal |
AlertDialog |
A Dialog that can't be dismissed by accident, for destructive confirmations |
Menu |
A floating list of one-shot actions |
ContextMenu |
A Menu triggered by long-press, anchored at the touch point |
Select |
A floating list of choosable, stateful options |
Checkbox |
A boolean (or indeterminate) toggle |
Switch |
A boolean on/off control |
RadioGroup |
Exactly one selection among several options |
Slider |
Drag (or single- or multi-thumb range) to pick a numeric value |
Toggle |
A single pressed/not-pressed button |
Progress |
A determinate or indeterminate progress indicator |
Toast |
A non-blocking, auto-dismissing notification |
VisuallyHidden |
Content hidden visually but readable by screen readers |
PinInput |
A verification-code input backed by one real, hidden TextInput |
npm install anvil-native- React 18 or newer (
Dialog/AlertDialoguse theuseIdhook). - Any recent React Native version — Anvil only uses stable, built-in APIs
(
Modal,PanResponder,Pressable, theaspectRatiostyle). - Works in Expo (managed workflow) out of the box — no native code, no config plugins, nothing to prebuild for.
import { Accordion } from 'anvil-native';
import { Pressable, Text, View } from 'react-native';
function FAQ() {
return (
<Accordion.Root type="single">
<Accordion.Item value="what-is-anvil">
<Accordion.Trigger>
{({ expanded }) => (
<View style={{ flexDirection: 'row', padding: 16 }}>
<Text style={{ flex: 1 }}>What is Anvil?</Text>
<Text>{expanded ? '−' : '+'}</Text>
</View>
)}
</Accordion.Trigger>
<Accordion.Content>
<Text style={{ padding: 16, paddingTop: 0 }}>
A headless, accessible primitives library for React Native.
</Text>
</Accordion.Content>
</Accordion.Item>
</Accordion.Root>
);
}Accordion.Root accepts type="single" (one item open at a time, with an
optional collapsible prop) or type="multiple" (any number of items open),
and supports both controlled (value/onValueChange) and uncontrolled
(defaultValue) usage. Accordion.Trigger's accessibilityState.expanded is
kept in sync automatically, and Accordion.Content unmounts when closed
unless you pass forceMount (useful when animating height yourself). Pass
disabled on an Accordion.Item to disable just that item, or on
Accordion.Root to disable the whole group at once.
Imperative control. Attach a ref to Accordion.Root to open/close items
from outside without owning the state yourself:
import { useRef } from 'react';
import { Accordion, type AccordionHandle } from 'anvil-native';
function Example() {
const accordionRef = useRef<AccordionHandle>(null);
// accordionRef.current?.open('section-1')
// accordionRef.current?.close('section-1')
// accordionRef.current?.toggle('section-1')
// accordionRef.current?.getValue() // -> string[]
return <Accordion.Root ref={accordionRef} type="single">{/* ... */}</Accordion.Root>;
}In controlled mode, open/close/toggle call your onValueChange instead
of mutating anything internally — same as pressing a trigger would.
Dev-mode checks. In development, Accordion.Root warns (via
console.error, once) if you switch between controlled and uncontrolled
usage after the first render, if type changes after mount, or if two
Accordion.Items share the same value. These checks are stripped in
production builds.
See the example app for a fully styled demo.
import { Tabs } from 'anvil-native';
import { Text, View } from 'react-native';
function ProfileTabs() {
return (
<Tabs.Root defaultValue="profile">
<Tabs.List style={{ flexDirection: 'row' }}>
<Tabs.Trigger value="profile">
{({ selected }) => (
<Text style={{ fontWeight: selected ? '700' : '400' }}>Profile</Text>
)}
</Tabs.Trigger>
<Tabs.Trigger value="settings">
{({ selected }) => (
<Text style={{ fontWeight: selected ? '700' : '400' }}>Settings</Text>
)}
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="profile">
<Text>Profile content</Text>
</Tabs.Content>
<Tabs.Content value="settings">
<Text>Settings content</Text>
</Tabs.Content>
</Tabs.Root>
);
}Tabs.List gets accessibilityRole="tablist" and each Tabs.Trigger gets
accessibilityRole="tab" with accessibilityState.selected kept in sync.
Like Accordion, it supports controlled (value/onValueChange) and
uncontrolled (defaultValue) usage — note that without either, no tab starts
selected and no Tabs.Content renders until one is picked. Pass disabled
on a Tabs.Trigger to disable just that tab, or on Tabs.Root to disable
the whole group at once.
Imperative control. Attach a ref to Tabs.Root to switch tabs from
outside without owning the state yourself:
import { useRef } from 'react';
import { Tabs, type TabsHandle } from 'anvil-native';
function Example() {
const tabsRef = useRef<TabsHandle>(null);
// tabsRef.current?.select('settings')
// tabsRef.current?.getValue() // -> string | null
return <Tabs.Root ref={tabsRef}>{/* ... */}</Tabs.Root>;
}Dev-mode checks. In development, Tabs.Root warns (via console.error,
once) if you switch between controlled and uncontrolled usage after the
first render, or if two Tabs.Triggers share the same value. Stripped in
production builds.
import { ToggleGroup } from 'anvil-native';
import { Text, View } from 'react-native';
function TextAlignPicker({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}) {
return (
<ToggleGroup.Root
type="single"
value={value}
onValueChange={(next) => next && onChange(next)}
style={{ flexDirection: 'row' }}
>
{['left', 'center', 'right'].map((option) => (
<ToggleGroup.Item key={option} value={option}>
{({ selected }) => (
<View style={{ padding: 8, opacity: selected ? 1 : 0.5 }}>
<Text>{option}</Text>
</View>
)}
</ToggleGroup.Item>
))}
</ToggleGroup.Root>
);
}ToggleGroup.Root accepts type="single" (accessibilityRole="radiogroup"
on the root, "radio" on each item) or type="multiple"
(accessibilityRole="checkbox" on each item), each item's
accessibilityState.checked kept in sync, and the same controlled/
uncontrolled support as the other primitives.
Note that type="single" behaves like a toggle, not a strict radio group:
pressing the already-selected item deselects it (onValueChange fires with
null). If you need "always exactly one selected," ignore the null in your
own onValueChange handler, as the example above does implicitly by only
calling onChange when next is truthy — or reach for RadioGroup (below),
which enforces that at the primitive level instead of leaving it to you.
Pass disabled on a ToggleGroup.Item to disable just that item, or on
ToggleGroup.Root to disable the whole group at once.
Imperative control. Attach a ref to ToggleGroup.Root to select/deselect
items from outside without owning the state yourself:
import { useRef } from 'react';
import { ToggleGroup, type ToggleGroupHandle } from 'anvil-native';
function Example() {
const groupRef = useRef<ToggleGroupHandle>(null);
// groupRef.current?.select('bold')
// groupRef.current?.deselect('bold')
// groupRef.current?.toggle('bold')
// groupRef.current?.getValue() // -> string[]
return <ToggleGroup.Root ref={groupRef} type="multiple">{/* ... */}</ToggleGroup.Root>;
}Dev-mode checks. In development, ToggleGroup.Root warns (via
console.error, once) if you switch between controlled and uncontrolled
usage after the first render, if type changes after mount, or if two
ToggleGroup.Items share the same value. Stripped in production builds.
If you just need one standalone pressed/not-pressed button — not a group —
that's Toggle (below), not ToggleGroup.
import { Popover } from 'anvil-native';
import { Text, View } from 'react-native';
function InfoPopover() {
return (
<Popover.Root>
<Popover.Trigger>
<Text>?</Text>
</Popover.Trigger>
<Popover.Content side="bottom" align="start" style={{ padding: 16, backgroundColor: 'white' }}>
<Text>Some helpful info.</Text>
<Popover.Close>
<Text>Close</Text>
</Popover.Close>
</Popover.Content>
</Popover.Root>
);
}Popover.Content renders inside React Native's own Modal (no extra native
dependencies), positioned relative to Popover.Trigger — or to
Popover.Anchor instead, if you want the floating content anchored to a
different element than the one that opens it (use one or the other, not
both). Position it with side ('top' | 'bottom' | 'left' | 'right',
default 'bottom'), align ('start' | 'center' | 'end', default
'center'), and sideOffset/alignOffset. With avoidCollisions (default
true), it automatically flips to the opposite side and clamps its
cross-axis position so it never renders off-screen — the render-prop form
({ side } shown above) tells you which side it actually landed on, handy
for pointing an arrow/caret at the trigger.
Pressing the backdrop closes the popover by default; set
closeOnOutsidePress={false} to require an explicit Popover.Close (or
imperative .close()) instead. Supports controlled (open/onOpenChange)
and uncontrolled (defaultOpen) usage, and disabled on Popover.Root
disables the trigger.
Imperative control.
import { useRef } from 'react';
import { Popover, type PopoverHandle } from 'anvil-native';
function Example() {
const popoverRef = useRef<PopoverHandle>(null);
// popoverRef.current?.open()
// popoverRef.current?.close()
// popoverRef.current?.toggle()
// popoverRef.current?.isOpen() // -> boolean
return <Popover.Root ref={popoverRef}>{/* ... */}</Popover.Root>;
}Dev-mode checks. In development, Popover.Root warns (via
console.error, once) if you switch between controlled and uncontrolled
open usage after the first render. Stripped in production builds.
import { Dialog } from 'anvil-native';
import { Text, View } from 'react-native';
function DeleteDialog() {
return (
<Dialog.Root>
<Dialog.Trigger>
<Text>Delete</Text>
</Dialog.Trigger>
<Dialog.Content>
<Dialog.Overlay style={{ backgroundColor: 'rgba(0,0,0,0.4)' }} />
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }} pointerEvents="box-none">
<View style={{ backgroundColor: 'white', padding: 20, borderRadius: 16 }}>
<Dialog.Title>Delete this item?</Dialog.Title>
<Dialog.Description>This can't be undone.</Dialog.Description>
<Dialog.Close>
<Text>Cancel</Text>
</Dialog.Close>
</View>
</View>
</Dialog.Content>
</Dialog.Root>
);
}Unlike Popover, Dialog.Content isn't anchored or positioned relative to
the trigger — it's a centered, blocking overlay, so you lay out the panel
yourself (as shown above) inside Dialog.Content, typically with
Dialog.Overlay (a styleable, closes-on-press-by-default backdrop — pass
closeOnPress={false} to require an explicit close action instead) as its
first child and a centering wrapper with pointerEvents="box-none" so taps
outside your panel still reach the overlay underneath.
Dialog.Title and Dialog.Description aren't just semantic labels — they
register themselves with Dialog.Content so it gets a matching
accessibilityLabelledBy (Android) / aria-describedby, so a screen reader
entering the dialog announces the right name and description automatically.
Supports the same controlled (open/onOpenChange) / uncontrolled
(defaultOpen), disabled, imperative ref (DialogHandle — open/
close/toggle/isOpen), and dev-mode controlled/uncontrolled warning as
Popover.
For destructive confirmations ("delete this?") where dismissing by accident
would be a real problem, reach for AlertDialog (below) instead — same
shape, safer defaults.
import { Menu } from 'anvil-native';
import { Text, View } from 'react-native';
function RowMenu() {
return (
<Menu.Root>
<Menu.Trigger>
<Text>⋮</Text>
</Menu.Trigger>
<Menu.Content align="start" style={{ backgroundColor: 'white', paddingVertical: 8 }}>
<Menu.Label>Actions</Menu.Label>
<Menu.Item onSelect={() => console.log('edit')}>
<Text>Edit</Text>
</Menu.Item>
<Menu.Separator style={{ height: 1, backgroundColor: '#ccc' }} />
<Menu.Item onSelect={() => console.log('delete')}>
<Text>Delete</Text>
</Menu.Item>
</Menu.Content>
</Menu.Root>
);
}Menu is what Popover becomes once you add selectable actions: Menu.Content
is anchored and positioned relative to Menu.Trigger exactly like
Popover.Content (same side/align/sideOffset/alignOffset/
avoidCollisions/closeOnOutsidePress props, same auto-flip-and-clamp
behavior), and gets accessibilityRole="menu". Menu.Item gets
accessibilityRole="menuitem", calls its onSelect and then closes the menu
when pressed — pass closeOnSelect={false} to keep it open (handy for an
item that itself opens a submenu or a confirmation). Menu.Separator and
Menu.Label are unstyled structural helpers for grouping items. Supports the
same disabled, imperative ref (MenuHandle), and dev-mode
controlled/uncontrolled warning as Popover.
If you need a list of choosable, stateful options instead of one-shot
actions (e.g. "sort by: name/date/size" with a persisted current value),
that's Select's job, not Menu's — see below. If you need the menu
triggered by a long-press on arbitrary content instead of a dedicated
trigger button, that's ContextMenu (see below), not Menu.
import { Select } from 'anvil-native';
import { Text } from 'react-native';
const FRUITS = [
{ value: 'apple', label: 'Apple' },
{ value: 'banana', label: 'Banana' },
];
function FruitSelect() {
return (
<Select.Root defaultValue="apple">
<Select.Trigger>
<Select.Value placeholder="Pick a fruit" />
</Select.Trigger>
<Select.Content align="start" style={{ backgroundColor: 'white', paddingVertical: 8 }}>
{FRUITS.map((fruit) => (
<Select.Item key={fruit.value} value={fruit.value}>
<Select.ItemText>{fruit.label}</Select.ItemText>
</Select.Item>
))}
</Select.Content>
</Select.Root>
);
}Select combines Popover's floating, anchored Content (same
side/align/sideOffset/alignOffset/avoidCollisions/
closeOnOutsidePress props and auto-flip-and-clamp behavior) with a
persisted value, the same way ToggleGroup does for inline groups.
Select.Trigger gets accessibilityRole="combobox"; Select.Item gets
accessibilityState={{ selected }} and, by default, selecting it closes the
menu (closeOnSelect={false} to keep it open). Supports controlled
(value/onValueChange) and uncontrolled (defaultValue) selection,
independently controlled/uncontrolled open state
(open/onOpenChange/defaultOpen), root-level disabled, and an
imperative ref (SelectHandle — open/close/toggle/isOpen/
getValue/setValue).
Select.Value needs to know the label of whichever item is currently
selected, which it reads from Select.ItemText — wrap each item's visible
label in Select.ItemText (a plain string child) rather than putting text
directly in Select.Item, or Select.Value won't have anything to display
and dev builds will warn you about it.
Dev-mode checks. In development, Select.Root warns if you switch
between controlled and uncontrolled value (or open) usage after the
first render, if two sibling Select.Items share the same value, or if
Select.ItemText doesn't receive a plain string child.
import { Checkbox } from 'anvil-native';
import { Text, View } from 'react-native';
function AgreeCheckbox() {
return (
<Checkbox.Root>
{({ checked }) => (
<>
<View style={{ width: 20, height: 20, borderWidth: 1 }}>
<Checkbox.Indicator>
<Text>{checked === 'indeterminate' ? '−' : '✓'}</Text>
</Checkbox.Indicator>
</View>
<Text>I agree</Text>
</>
)}
</Checkbox.Root>
);
}Checkbox.Root is itself the pressable element (accessibilityRole="checkbox")
— there's no separate Trigger. checked is boolean | 'indeterminate', for
the classic "select all" case where only some of a group's items are checked;
pressing an indeterminate checkbox always moves it to true (never back to
false), matching how indeterminate checkboxes behave everywhere else.
Checkbox.Indicator renders its children only while checked isn't false
— pass forceMount to keep it mounted (e.g. to drive your own enter/exit
animation) and read checked yourself to decide what to show.
Supports controlled (checked/onCheckedChange) and uncontrolled
(defaultChecked) usage, disabled, and an imperative ref (CheckboxHandle
— toggle/setChecked/getChecked).
Dev-mode checks. In development, Checkbox.Root warns (via
console.error, once) if you switch between controlled and uncontrolled
checked usage after the first render. Stripped in production builds.
import { Switch } from 'anvil-native';
import { View } from 'react-native';
function NotificationsSwitch() {
return (
<Switch.Root>
{({ checked }) => (
<View style={{ width: 44, height: 26, borderRadius: 13, backgroundColor: checked ? 'black' : '#ccc' }}>
<Switch.Thumb
style={{
width: 22,
height: 22,
borderRadius: 11,
backgroundColor: 'white',
alignSelf: checked ? 'flex-end' : 'flex-start',
}}
/>
</View>
)}
</Switch.Root>
);
}Switch is Checkbox's boolean-only sibling: same shape (Switch.Root is
the pressable itself, accessibilityRole="switch"), but checked is always
boolean — no 'indeterminate', since a physical on/off switch has no
third state. Unlike Checkbox.Indicator, Switch.Thumb always renders (in
both states) since a switch's thumb moves rather than appearing/
disappearing — read checked off Switch.Root's render-prop (as shown
above) or off Switch.Thumb's own render-prop to position/color it
yourself.
Supports controlled (checked/onCheckedChange) and uncontrolled
(defaultChecked) usage, disabled, and an imperative ref (SwitchHandle
— toggle/setChecked/getChecked).
Dev-mode checks. In development, Switch.Root warns (via
console.error, once) if you switch between controlled and uncontrolled
checked usage after the first render. Stripped in production builds.
import { RadioGroup } from 'anvil-native';
import { Text, View } from 'react-native';
const SHIPPING_OPTIONS = [
{ value: 'standard', label: 'Standard' },
{ value: 'express', label: 'Express' },
];
function ShippingPicker({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}) {
return (
<RadioGroup.Root value={value} onValueChange={(next) => next && onChange(next)}>
{SHIPPING_OPTIONS.map((option) => (
<RadioGroup.Item key={option.value} value={option.value}>
<View style={{ width: 20, height: 20, borderRadius: 10, borderWidth: 1 }}>
<RadioGroup.Indicator>
<View style={{ flex: 1, margin: 4, borderRadius: 6, backgroundColor: 'black' }} />
</RadioGroup.Indicator>
</View>
<Text>{option.label}</Text>
</RadioGroup.Item>
))}
</RadioGroup.Root>
);
}RadioGroup is ToggleGroup's type="single" counterpart with strict radio
semantics baked in, instead of left to the consumer: RadioGroup.Root gets
accessibilityRole="radiogroup", each RadioGroup.Item gets
accessibilityRole="radio", and pressing an already-selected item is always
a no-op — there's no onValueChange(null) to filter out, because the value
can never be cleared from the UI. RadioGroup.Indicator works exactly like
Checkbox.Indicator: it only renders while its RadioGroup.Item is
selected (pass forceMount to keep it mounted for your own animation).
Supports controlled (value/onValueChange) and uncontrolled
(defaultValue) selection, disabled (on an item, or on the whole
RadioGroup.Root), and an imperative ref (RadioGroupHandle —
select/getValue, plus clear() for the programmatic-only case of
resetting the selection, e.g. a "reset form" button).
Dev-mode checks. In development, RadioGroup.Root warns if you switch
between controlled and uncontrolled value usage after the first render, or
if two sibling RadioGroup.Items share the same value.
import { AlertDialog } from 'anvil-native';
import { Text, View } from 'react-native';
function DeleteAccountAlert() {
return (
<AlertDialog.Root>
<AlertDialog.Trigger>
<Text>Delete account</Text>
</AlertDialog.Trigger>
<AlertDialog.Content>
<AlertDialog.Overlay style={{ backgroundColor: 'rgba(0,0,0,0.4)' }} />
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }} pointerEvents="box-none">
<View style={{ backgroundColor: 'white', padding: 20, borderRadius: 16 }}>
<AlertDialog.Title>Delete your account?</AlertDialog.Title>
<AlertDialog.Description>This is permanent.</AlertDialog.Description>
<AlertDialog.Cancel>
<Text>Cancel</Text>
</AlertDialog.Cancel>
<AlertDialog.Action onPress={() => deleteAccount()}>
<Text>Delete</Text>
</AlertDialog.Action>
</View>
</View>
</AlertDialog.Content>
</AlertDialog.Root>
);
}AlertDialog is Dialog with the defaults flipped for destructive
confirmations: AlertDialog.Overlay's closeOnPress defaults to false
(vs. true on Dialog.Overlay), and AlertDialog.Content's
closeOnRequestClose (the Android hardware back button) also defaults to
false — so it can't be dismissed by an accidental tap outside or a back
press, only by an explicit Cancel or Action. Everything else about
layout and structure (you build the panel yourself inside Content, same
Title/Description accessibility linking) matches Dialog exactly.
AlertDialog.Cancel and AlertDialog.Action both close the dialog by
default when pressed — Action additionally accepts closeOnPress={false}
for confirm actions that do async work (e.g. an API call) and need to stay
open, perhaps with a loading state, until you close it yourself via an
imperative AlertDialogHandle.close().
Supports the same controlled (open/onOpenChange) / uncontrolled
(defaultOpen), disabled, imperative ref (AlertDialogHandle), and
dev-mode controlled/uncontrolled warning as Dialog.
import { Progress } from 'anvil-native';
import { View } from 'react-native';
function DownloadProgress({ percent }: { percent: number }) {
return (
<Progress.Root value={percent} style={{ height: 8, borderRadius: 4, backgroundColor: '#eee' }}>
<Progress.Indicator style={{ flex: 1 }}>
{({ percentage }) => (
<View style={{ height: '100%', width: `${percentage}%`, backgroundColor: 'black' }} />
)}
</Progress.Indicator>
</Progress.Root>
);
}Progress is display-only — there's no internal state, no Trigger, no
imperative ref: value is a number you own and pass in directly (or null
for an indeterminate progress bar with an unknown duration, e.g. while
syncing). Progress.Root computes percentage (value expressed as
0-100, clamped, or null while indeterminate) and hands it to both its own
render-prop and Progress.Indicator's, so you can size the filled portion
yourself — Anvil doesn't render or animate anything for you.
Progress.Root gets accessibilityRole="progressbar" with a proper
accessibilityValue (min/max/now, plus a text — defaulting to a
rounded percentage, override via getValueLabel) and
accessibilityState={{ busy: true }} while indeterminate, so screen readers
announce it correctly with no extra work.
Dev-mode checks. In development, Progress.Root warns (via
console.error, once) if value falls outside the 0..max range, or if
max isn't greater than 0.
import { Slider } from 'anvil-native';
function Volume({ value, onChange }: { value: number; onChange: (value: number) => void }) {
return (
<Slider.Root value={[value]} onValueChange={([next]) => onChange(next)}>
<Slider.Track style={{ height: 4, borderRadius: 2, backgroundColor: '#eee' }}>
<Slider.Range style={{ height: 4, borderRadius: 2, backgroundColor: 'black' }} />
<Slider.Thumb
style={{ width: 20, height: 20, borderRadius: 10, backgroundColor: 'white', borderWidth: 1 }}
/>
</Slider.Track>
</Slider.Root>
);
}Slider uses PanResponder (built into React Native, no extra native
dependency) rather than a gesture library, so dragging a Slider.Thumb
works out of the box. value is an array — one entry per thumb — so a
plain slider is [value] and a range slider is [low, high]:
<Slider.Root value={[20, 70]} onValueChange={setRange} min={0} max={100}>
<Slider.Track>
<Slider.Range />
<Slider.Thumb index={0} />
<Slider.Thumb index={1} />
</Slider.Track>
</Slider.Root>Each Slider.Thumb takes an explicit index into value (defaulting to
0, the common single-thumb case) rather than inferring it from render
order — more verbose than some slider APIs, but it can't silently break if
a thumb is ever conditionally rendered. Multi-thumb values are always
clamped against their neighbors, so thumbs can never cross each other,
whether you're dragging or setting value programmatically.
Slider.Track measures its own width (via onLayout) to convert drag
pixels into a value delta; Slider.Thumb positions itself along the track
based on value/min/max and its own measured width, so it's centered
correctly without you doing any math. Slider.Range is the optional filled
portion between the track's start and the value (or between two thumbs, for
a range).
Slider.Thumb also gets accessibilityRole="adjustable" with a proper
accessibilityValue, and responds to the increment/decrement accessibility
actions VoiceOver/TalkBack expose for that role — so it's fully operable by
screen reader users via swipe-up/down, not just by dragging.
Supports controlled (value/onValueChange) and uncontrolled
(defaultValue), min/max/step, disabled, onValueCommit (fires
once when a drag or accessibility adjustment ends — handy for expensive
side effects you don't want firing on every intermediate move), and an
imperative ref (SliderHandle — getValue/setValue).
Scope note: horizontal only for now (vertical sliders are uncommon on touch-first mobile UIs); tapping the track to jump the thumb straight to that position isn't implemented, only dragging the thumb itself.
Dev-mode checks. In development, Slider.Root warns if you switch
between controlled and uncontrolled value usage after the first render,
if min >= max, if step isn't greater than 0, or if the initial
value/defaultValue entries aren't in ascending order.
import { Separator } from 'anvil-native';
<Separator style={{ height: 1, backgroundColor: '#ccc' }} />The simplest primitive in Anvil: a View that's hidden from assistive
technology by default (decorative, since most separators are purely
visual dividers between sections). Pass decorative={false} if this one
actually carries meaning that should be announced.
import { Collapsible } from 'anvil-native';
import { Text } from 'react-native';
function ShowMore() {
return (
<Collapsible.Root>
<Collapsible.Trigger>
{({ open }) => <Text>{open ? 'Show less' : 'Show more'}</Text>}
</Collapsible.Trigger>
<Collapsible.Content>
<Text>The extra detail that was hidden.</Text>
</Collapsible.Content>
</Collapsible.Root>
);
}A single expand/collapse panel that isn't part of a group — everything
Accordion.Item gives you, without needing an Accordion.Root around it.
Same controlled (open/onOpenChange) / uncontrolled (defaultOpen)
support, disabled, forceMount on Content, imperative ref
(CollapsibleHandle), and dev-mode controlled/uncontrolled warning as the
other primitives.
import { AspectRatio } from 'anvil-native';
import { Image } from 'react-native';
<AspectRatio ratio={16 / 9}>
<Image source={{ uri: '...' }} style={{ flex: 1 }} />
</AspectRatio>A thin wrapper around React Native's own aspectRatio style — mostly worth
using for the self-documenting API and the dev-mode check, since RN already
supports aspectRatio natively (no padding-bottom-hack workaround needed,
unlike the CSS story AspectRatio originally solved on the web).
Dev-mode checks. Warns (via console.error, once) if ratio isn't
greater than 0.
import { Label } from 'anvil-native';
import { useRef } from 'react';
import { Checkbox, type CheckboxHandle } from 'anvil-native';
function AgreeToTerms() {
const checkboxRef = useRef<CheckboxHandle>(null);
return (
<>
<Checkbox.Root ref={checkboxRef}>{/* ... */}</Checkbox.Root>
<Label onPress={() => checkboxRef.current?.toggle()}>
I agree to the terms
</Label>
</>
);
}React Native has no <label for> — the OS-level link between a label and
its control (accessibilityLabelledBy) has to be set on the control
itself, not inferred from nearby markup the way HTML does. Label can't
change that, but it gives you the two pieces you actually need: a stable
nativeID (pass it to your control's accessibilityLabelledBy, or read it
off the render-prop: <Label>{({ id }) => ...}</Label>), and a pressable
Text you can wire an onPress onto so tapping the label text also
activates the control — a bigger, more forgiving tap target than the
control alone, which is exactly what Dialog.Title already does for you
internally. Label makes that same pattern available in your own
compositions.
import { ContextMenu } from 'anvil-native';
import { Text, View } from 'react-native';
function RowWithContextMenu() {
return (
<ContextMenu.Root>
<ContextMenu.Trigger>
<View>
<Text>Long-press me</Text>
</View>
</ContextMenu.Trigger>
<ContextMenu.Content style={{ backgroundColor: 'white', paddingVertical: 8 }}>
<ContextMenu.Item onSelect={() => console.log('edit')}>
<Text>Edit</Text>
</ContextMenu.Item>
<ContextMenu.Item onSelect={() => console.log('delete')}>
<Text>Delete</Text>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Root>
);
}ContextMenu is Menu triggered by a long-press instead of a tap, and
anchored at the touch point instead of the trigger's bounding box —
everything else (Item/Separator/Label, side/align/sideOffset/
alignOffset/avoidCollisions/closeOnOutsidePress, the auto-flip-and-
clamp behavior, accessibilityRole="menu"/"menuitem") is identical to
Menu. The imperative ref (ContextMenuHandle) is the one real API
difference: open takes a point ({ x, y }, e.g. from a
GestureResponderEvent's pageX/pageY) instead of nothing, since there's
no trigger view to measure — there's also no toggle, since a long-press
gesture only ever means "open," never "open or close depending on state."
Dev-mode checks. Same controlled/uncontrolled open warning as Menu.
import { Toast } from 'anvil-native';
import { Text, View } from 'react-native';
import { useRef } from 'react';
import type { ToastHandle } from 'anvil-native';
function SaveButton() {
const toastRef = useRef<ToastHandle>(null);
return (
<Toast.Provider>
<Text onPress={() => toastRef.current?.open()}>Save</Text>
<Toast.Viewport
pointerEvents="box-none"
style={{ position: 'absolute', top: 0, left: 0, right: 0 }}
>
<Toast.Root ref={toastRef} style={{ backgroundColor: 'black', padding: 16 }}>
<Toast.Title style={{ color: 'white' }}>Saved</Toast.Title>
<Toast.Description style={{ color: '#ccc' }}>
Your changes were saved.
</Toast.Description>
</Toast.Root>
</Toast.Viewport>
</Toast.Provider>
);
}React Native has no portal API, so unlike Anvil's other overlays Toast
doesn't use Modal — a Modal would block touches to the rest of your app,
which is exactly wrong for a non-blocking notification. Toast.Viewport is
just a positioning container: place it wherever you want toasts to appear —
top or bottom, it's entirely your style — and render your Toast.Roots
into it; there's no built-in queue, the same way Dialog doesn't manage
"only one dialog at a time" for you — track your own array of active toasts
and .map() over it, same as any other list of stateful items.
Placement matters. For the Viewport to actually float in a fixed screen
position instead of scrolling away with the rest of your content, it needs
to be a sibling of your ScrollView (or whatever scrolls), not a child of
it — e.g. both nested inside one root View, with the Viewport
absolutely-positioned (position: 'absolute', top: 0 or bottom: 0,
left: 0, right: 0) and pointerEvents="box-none" so it doesn't swallow
touches to the content underneath it when no toast is showing.
Toast.Root auto-dismisses after duration (default 5000ms, or
Toast.Provider's defaultDuration if you don't set one; pass
duration={Infinity} to disable auto-dismiss). The dismiss timer restarts
if duration changes while open. Toast.Viewport sets
accessibilityLiveRegion="polite" so Android's TalkBack announces new
toasts automatically; iOS has no equivalent view prop, so Toast.Root
calls AccessibilityInfo.announceForAccessibility itself once Title/
Description's text is available.
Supports controlled (open/onOpenChange) and uncontrolled (defaultOpen)
usage, an imperative ref (ToastHandle — open/close/toggle/
isOpen), and Toast.Action/Toast.Close (both close by default when
pressed; Action additionally accepts closeOnPress={false} for actions
like "Undo" that need to do work before the toast disappears).
Dev-mode checks. Same controlled/uncontrolled open warning as the
other overlay primitives.
import { Toggle } from 'anvil-native';
import { Text } from 'react-native';
function BoldButton({
pressed,
onPressedChange,
}: {
pressed: boolean;
onPressedChange: (pressed: boolean) => void;
}) {
return (
<Toggle.Root pressed={pressed} onPressedChange={onPressedChange}>
{({ pressed: isPressed }) => (
<Text style={{ fontWeight: 'bold', opacity: isPressed ? 1 : 0.5 }}>B</Text>
)}
</Toggle.Root>
);
}A single pressed/not-pressed button — the one-off counterpart to
ToggleGroup (which manages a set of options). Toggle.Root gets
accessibilityRole="togglebutton" with accessibilityState.selected
tracking pressed (React Native's accessibilityState has no dedicated
"pressed" field; selected is the closest native-role match, the same
choice other RN UI libraries make for this role).
Supports controlled (pressed/onPressedChange) and uncontrolled
(defaultPressed) usage, disabled, and an imperative ref (ToggleHandle
— toggle/setPressed/getPressed).
Dev-mode checks. Same controlled/uncontrolled warning (on pressed) as
Checkbox/Switch.
VisuallyHidden
import { VisuallyHidden } from 'anvil-native';
import { Pressable, Text } from 'react-native';
function CloseIconButton({ onPress }: { onPress: () => void }) {
return (
<Pressable onPress={onPress}>
<Text>✕</Text>
<VisuallyHidden>
<Text>Close</Text>
</VisuallyHidden>
</Pressable>
);
}Renders children off-screen and zero-size instead of not rendering them
at all — unlike conditionally omitting them, assistive technology still
reads them. The canonical use case: an icon-only button that needs a real
text label for screen reader users, without that label showing up next to
the icon visually.
import { PinInput } from 'anvil-native';
import { Text, View } from 'react-native';
function VerificationCode({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}) {
return (
<PinInput.Root
value={value}
onValueChange={onChange}
length={6}
accessibilityLabel="Verification code"
style={{ flexDirection: 'row', gap: 8 }}
>
{[0, 1, 2, 3, 4, 5].map((index) => (
<PinInput.Slot key={index} index={index}>
{({ char, active }) => (
<View style={{ width: 40, height: 48, borderWidth: 1, borderColor: active ? 'black' : '#ccc' }}>
<Text>{char ?? ''}</Text>
</View>
)}
</PinInput.Slot>
))}
</PinInput.Root>
);
}A verification-code input (SMS, 2FA) that looks like length separate
boxes but is actually backed by a single, real (visually hidden)
TextInput — the standard, robust way to build this: one focusable field
handles the keyboard, cursor, and paste/autofill correctly, while
PinInput.Slots are purely decorative (hidden from accessibility; the
TextInput is what screen readers interact with, via the accessibilityLabel
you pass to PinInput.Root) and just render whichever character lands at
their index, plus whether they're active (the slot that would receive
the next typed character). Tapping a Slot focuses the real input.
The hidden TextInput sets textContentType="oneTimeCode" (iOS) and
autoComplete="sms-otp" (Android, when type="numeric", the default) so
the OS's native "autofill code from SMS" suggestion works correctly — a
real, easy-to-get-wrong detail this primitive gets right for you.
type="numeric" (the default) also filters out non-digit input; pass
type="text" to allow anything. Input is always truncated to length.
onComplete fires once, exactly when the value transitions to length
characters — handy for auto-submitting.
Supports controlled (value/onValueChange) and uncontrolled
(defaultValue) usage, disabled, and an imperative ref (PinInputHandle
— focus/blur/clear/getValue/setValue).
Dev-mode checks. In development, warns if you switch between controlled
and uncontrolled usage, if length isn't greater than 0, if a Slot's
index is out of range for length, or if two Slots share the same
index.
MIT
Made with create-react-native-library