Model Selector
The searchable model picker, composed on the command atom. Port-adapted per the PRD verdict: a dropdown on web becomes a bottom sheet with search, sized for one-handed use — same selection contract (value is the model id your route receives), different presentation. Provider grouping survives as section headings; the provider doubles as a filter keyword. ModelSelectorShortcut (⌘ hints) is dropped — there is no ⌘ on a phone — and the remote models.dev SVG logo is a declared substitution (themed mark, consumer-supplied raster source; modelSelectorLogoUrl keeps the URL derivation as data).
- v0.2.1
- component
- nativewind
- uniwind
- runs in Expo Go
Install
npx @react-native-reusables/cli@latest add https://rnraielements.com/r/nativewind/model-selector.jsonnpx @react-native-reusables/cli@latest add https://rnraielements.com/r/uniwind/model-selector.jsonDependencies
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 |
https://reactnativereusables.com/r/uniwind/button.json |
https://reactnativereusables.com/r/uniwind/icon.json |
https://reactnativereusables.com/r/uniwind/text.json |
https://rnraielements.com/r/uniwind/command.json |
https://rnraielements.com/r/uniwind/item.json |
AI SDK peers:ai@^7.0.89@ai-sdk/react@^4.0.92
Source
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 };
/**
* 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 — 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
| Name | Type | Default | Description |
|---|---|---|---|
| models | readonly ModelSelectorModel[] | — | — |
| value | string | undefined | — | — |
| onValueChange | (value: string) => void | — | — |
| open | boolean | undefined | — | — |
| onOpenChange | ((open: boolean) => void) | undefined | — | — |
| placeholder | string | undefined | 'Search models…' | — |
| emptyTitle | string | undefined | 'No models found' | — |
| emptyDescription | string | undefined | 'Try a different search.' | — |
| title | string | undefined | 'Select a model' | The sheet's screen-reader title. Defaults to "Select a model". |
| className | string | undefined | — | Passed to the sheet content — height and padding adjustments. |
| children | React.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>
)