Install

npx @react-native-reusables/cli@latest add https://rnraielements.com/r/nativewind/model-selector.json

Dependencies

No required npm packages — this item installs through the registry dependencies below.

https://reactnativereusables.com/r/nativewind/button.json
https://reactnativereusables.com/r/nativewind/icon.json
https://reactnativereusables.com/r/nativewind/text.json
https://rnraielements.com/r/nativewind/command.json
https://rnraielements.com/r/nativewind/item.json

AI SDK peers:ai@^7.0.89@ai-sdk/react@^4.0.92

Source

model-selector.tsx
import { Button } from '@/registry/nativewind/components/ui/button'; import { Command } from '@/registry/nativewind/components/ui/command'; import { Icon } from '@/registry/nativewind/components/ui/icon'; import { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle, } from '@/registry/nativewind/components/ui/item'; import { Text } from '@/registry/nativewind/components/ui/text'; import { cn } from '@/registry/nativewind/lib/utils'; import { CheckIcon, ChevronsUpDownIcon, CpuIcon } from 'lucide-react-native'; import * as React from 'react'; import { Image, View } from 'react-native'; import { resolveModelLabel, toCommandItems, type ModelSelectorModel, } from './model-selector.logic'; import type { CommandItem } from '../ui/command.logic'; /** * ModelSelector — the searchable model picker (UC-CHAT: choose which model your * route receives), composed on the `command` atom per the build plan. * * THE PRD VERDICT IS PORT-ADAPTED, PURE-RNR: "A dropdown on web; on mobile the * correct pattern is a bottom sheet with search, sized for one-handed use. Same * selection contract, different presentation." Every clause, realized: * * - SAME SELECTION CONTRACT: `value` is the model id and `onValueChange` receives it — * exactly what the web's ModelSelectorItem.value/onSelect produce and what your * route consumes. `open`/`onOpenChange` stay controllable for programmatic open, * with an internal default so a trigger-only consumer never sees the state. * - BOTTOM SHEET WITH SEARCH: the command atom IS that surface — filter field, the * provider as a search keyword (typing "openai" finds GPT rows), an explicit empty * state, and a virtualized list. No autofocus: the web original's autofocused input * is a desktop idiom that slams the keyboard up over the list (KB port advice). * - PROVIDER GROUPING: ModelSelectorGroup headings survive as the command atom's * group headers, in first-appearance order (model-selector.logic's toCommandItems). * * THE WEB'S MODEL SELECTOR SHORTCUT IS DROPPED, ON THE RECORD: ModelSelectorShortcut * renders ⌘-key hints — there is no ⌘ on a phone (PRD port advice: "Drop * ModelSelectorShortcut entirely"). * * THE LOGO IS A DECLARED SUBSTITUTION (open-in-chat's brand-mark precedent): the web * derives a remote models.dev SVG per provider — RN Image cannot render SVG, and an * SVG element cannot receive className without the forbidden engine-specific * cssInterop. The row ships a themed CpuIcon in the logo slot; * ModelSelectorLogo accepts a consumer-supplied `source` (a bundled RASTER image — * PNG/data URI — not the upstream SVG URL), and modelSelectorLogoUrl keeps the web's * URL derivation as data for consumers who mirror the assets themselves. * * Composition: * <ModelSelector models={MODELS} value={value} onValueChange={setValue}> * <ModelSelectorTrigger /> * </ModelSelector> * The palette surface (search, groups, rows, empty state) is the command atom's — * there is no separate Content part to compose; the web's Input/List/Item/Empty parts * are absorbed into that substrate, and rows are data-driven because the list is a * FlatList (a composed-children API cannot virtualize). */ type ModelSelectorContextValue = { models: readonly ModelSelectorModel[]; value: string | undefined; open: boolean; setOpen: (open: boolean) => void; }; const ModelSelectorContext = React.createContext<ModelSelectorContextValue | null>(null); function useModelSelectorContext(): ModelSelectorContextValue { const ctx = React.useContext(ModelSelectorContext); if (!ctx) throw new Error('ModelSelector components must be used within ModelSelector'); return ctx; } type ModelSelectorProps = { models: readonly ModelSelectorModel[]; value: string | undefined; onValueChange: (value: string) => void; open?: boolean; onOpenChange?: (open: boolean) => void; placeholder?: string; emptyTitle?: string; emptyDescription?: string; /** The sheet's screen-reader title. Defaults to "Select a model". */ title?: string; /** Passed to the sheet content — height and padding adjustments. */ className?: string; children?: React.ReactNode; }; function ModelSelector({ models, value, onValueChange, open: openProp, onOpenChange, placeholder = 'Search models…', emptyTitle = 'No models found', emptyDescription = 'Try a different search.', title = 'Select a model', className, children, }: ModelSelectorProps) { // Controlled when `open` is supplied, internal otherwise — the web's open/ // onOpenChange wires a ⌘K that does not exist here, but programmatic open is a // real consumer need (settings screens, toolbars). const [internalOpen, setInternalOpen] = React.useState(false); const open = openProp ?? internalOpen; const setOpen = React.useCallback( (next: boolean) => { if (openProp === undefined) setInternalOpen(next); onOpenChange?.(next); }, [openProp, onOpenChange], ); const items = React.useMemo(() => toCommandItems(models), [models]); const contextValue = React.useMemo<ModelSelectorContextValue>( () => ({ models, value, open, setOpen }), [models, value, open, setOpen], ); function renderRow({ item, selected }: { item: CommandItem; selected: boolean }) { return ( <Item variant={selected ? 'muted' : 'default'} onPress={() => { onValueChange(item.value); setOpen(false); }} > <ItemMedia> <ModelSelectorLogo provider={item.group ?? ''} /> </ItemMedia> <ItemContent> <ItemTitle>{item.label}</ItemTitle> {item.description ? <ItemDescription>{item.description}</ItemDescription> : null} </ItemContent> {/* The web check is CONSUMER-RENDERED TRAILING (model-selector.tsx:302-306): selected rows render a CheckIcon at ml-auto size-4, unselected rows a same-size placeholder div — the reserved 16px slot keeps rows from shifting on selection change. Port: Icon wrapper (never a raw lucide element) vs an equal-size empty View. */} {selected ? ( <Icon as={CheckIcon} size={16} className="ml-auto" /> ) : ( <View className="ml-auto size-4" /> )} </Item> ); } return ( <ModelSelectorContext.Provider value={contextValue}> <Command open={open} onOpenChange={setOpen} items={items} value={value} onSelect={(next) => { onValueChange(next); setOpen(false); }} renderItem={renderRow} placeholder={placeholder} emptyTitle={emptyTitle} emptyDescription={emptyDescription} title={title} className={className} > {children} </Command> </ModelSelectorContext.Provider> ); } type ModelSelectorTriggerProps = { /** Overrides the default outline button (web: children on the trigger). */ children?: React.ReactNode; className?: string; /** Button size passthrough — the composer's in-row chip renders size="sm". */ size?: 'default' | 'sm' | 'lg'; }; function ModelSelectorTrigger({ children, className, size }: ModelSelectorTriggerProps) { const { models, value, setOpen, open } = useModelSelectorContext(); const label = resolveModelLabel(models, value); return ( children ?? ( <Button variant="outline" size={size} onPress={() => setOpen(!open)} accessibilityLabel={`Select model, current: ${label}`} accessibilityState={{ expanded: open }} className={cn('justify-between gap-2', className)} > <Icon as={CpuIcon} size={16} className="text-muted-foreground" /> <Text numberOfLines={1} className="flex-1 text-left"> {label} </Text> <Icon as={ChevronsUpDownIcon} size={16} className="shrink-0 text-muted-foreground" /> </Button> ) ); } type ModelSelectorLogoProps = { provider: string; /** A bundled RASTER image source (PNG/data URI) — the upstream models.dev SVG does not render in RN Image. */ source?: { uri: string }; className?: string; }; /** * The row's provider mark. With no consumer `source` it ships the themed CpuIcon — * the declared substitution for the web's remote SVG (see header). The derivation * stays available as data: modelSelectorLogoUrl(provider). */ function ModelSelectorLogo({ provider, source, className }: ModelSelectorLogoProps) { if (source) { return ( <Image source={source} resizeMode="contain" className={cn('size-4', className)} // Belt-and-braces numeric sizing (attachments.tsx hardening rule) — as a // FALLBACK only (review m2): an explicit consumer className wins over the // 16px default instead of being silently overridden by the inline style. style={className ? undefined : { width: 16, height: 16 }} accessibilityLabel={`${provider} logo`} /> ); } return <Icon as={CpuIcon} size={16} className={cn('text-muted-foreground', className)} />; } export { ModelSelector, ModelSelectorLogo, ModelSelectorTrigger }; export type { ModelSelectorModel };
model-selector.logic.ts
/** * ModelSelector — pure logic. Zero react-native imports, so the Vitest tier owns it (see * the header of sources.logic.ts for why component files themselves cannot load under * Node). * * The web original's model list is data the caller owns (models.dev-shaped entries); * this file holds everything the component derives from that list — the palette rows * (with the provider as BOTH the section heading and a filter keyword), the trigger's * selected label, and the logo URL derivation. */ import type { CommandItem } from '../ui/command.logic'; /** One model the caller offers. `id` is what your route receives — the web contract. */ export type ModelSelectorModel = { id: string; name: string; /** Grouping heading AND the logo key — the web groups by provider with headings. */ provider: string; description?: string; /** Extra filter terms beyond name, provider and description. */ keywords?: string; }; /** * The web original's logo derivation, byte-parity: ModelSelectorLogo renders * `https://models.dev/logos/{provider}.svg`. The SVG itself does NOT ship here — * React Native Image cannot render SVG, and a raw react-native-svg element cannot * receive className without the engine-specific cssInterop the styling contract * forbids in registry source (open-in-chat's brand-mark precedent). The derivation is * preserved as data for consumers who fetch their own raster mirror; the component's * default mark is a themed icon. */ export function modelSelectorLogoUrl(provider: string): string { return `https://models.dev/logos/${provider}.svg`; } /** * The palette rows: value = model id, label = name, group = provider, keywords carry * the provider (so typing "openai" filters to OpenAI's rows even when a model name * never mentions it). Order preserved — the caller's list order IS the display order, * within and across groups. */ export function toCommandItems(models: readonly ModelSelectorModel[]): CommandItem[] { return models.map((m) => ({ value: m.id, label: m.name, description: m.description, keywords: [m.provider, m.keywords].filter(Boolean).join(' ') || undefined, group: m.provider, })); } /** * The trigger's label: the selected model's name, the caller's fallback (or the web * trigger's own "Select model") when the value matches nothing. No-throw — a stale * value (a model the list no longer carries) degrades to the placeholder. */ export function resolveModelLabel( models: readonly ModelSelectorModel[], value: string | undefined, fallback = 'Select model', ): string { if (!value) return fallback; return models.find((m) => m.id === value)?.name ?? fallback; }
command.logic.ts
/** * Command — pure logic. Zero react-native imports, so the Vitest tier owns it (see the * header of sources.logic.ts for why component files themselves cannot load under * Node). * * Extracted when model-selector needed provider grouping and voice-selector needed * custom rows: the palette's filter and its flat-item → render-rows transform are the * two behaviors those selectors build on, and both are data-in/data-out. The component * file consumes them; tests pin them. */ /** One selectable entry in the palette. `group` names the section heading it sits under. */ export type CommandItem = { value: string; label: string; description?: string; /** Extra match terms beyond label and description — model-selector puts the provider here. */ keywords?: string; /** Section heading. Ungrouped items render without a header, in list order. */ group?: string; }; /** A row of the palette's list: either a section heading or a selectable item. */ export type CommandRow = | { kind: 'header'; key: string; label: string } | { kind: 'item'; key: string; item: CommandItem; selected: boolean }; /** * The palette filter — the web original's cmdk match (case-insensitive substring * across label, description and keywords). A blank query matches everything; a * whitespace query is blank. Order is preserved — the caller's list order IS the * display order. */ export function filterCommandItems<T extends CommandItem>( items: readonly T[], query: string, ): T[] { const q = query.trim().toLowerCase(); if (!q) return [...items]; return items.filter((i) => `${i.label} ${i.description ?? ''} ${i.keywords ?? ''}`.toLowerCase().includes(q), ); } /** * Interleave section headings into the flat item list: a group's heading renders the * first time one of its items appears, so groups keep the caller's first-appearance * order and interleaved groups each get exactly one header. Runs AFTER filtering, so * a group whose items all failed the filter renders no header at all. * * `selected` is resolved here, not at render time, so the memoized row array changes * identity when the selection changes — the FlatList's re-render trigger. */ export function buildCommandRows( items: readonly CommandItem[], value?: string, ): CommandRow[] { const rows: CommandRow[] = []; const seen = new Set<string>(); for (const item of items) { const group = item.group?.trim(); if (group && !seen.has(group)) { seen.add(group); rows.push({ kind: 'header', key: `header:${group}`, label: group }); } rows.push({ kind: 'item', key: item.value, item, selected: item.value === value }); } return rows; }

Props

NameTypeDefaultDescription
modelsreadonly ModelSelectorModel[]——
valuestring | undefined——
onValueChange(value: string) => void——
openboolean | undefined——
onOpenChange((open: boolean) => void) | undefined——
placeholderstring | undefined'Search models…'—
emptyTitlestring | undefined'No models found'—
emptyDescriptionstring | undefined'Try a different search.'—
titlestring | undefined'Select a model'The sheet's screen-reader title. Defaults to "Select a model".
classNamestring | undefined—Passed to the sheet content — height and padding adjustments.
childrenReact.ReactNode——

Theming

Themed via text-muted-foreground.

Usage

() => ( <ModelSelector models={[ { id: MODEL_ID, name: 'Fable 5.1', provider: 'Anthropic', description: 'Frontier coding model' }, { id: 'claude-haiku-4-5-20251001', name: 'Haiku 4.5', provider: 'Anthropic', description: 'Fast and cheap' }, { id: 'gpt-5.6-sol', name: 'GPT-5.6 Sol', provider: 'OpenAI', description: 'Codex-tuned' }, ]} value={MODEL_ID} onValueChange={() => {}} > <ModelSelectorTrigger /> </ModelSelector> )