# Model Selector

Model Selector [#model-selector]

The **Model Selector** renders a device-aware list of models grouped by category, with backend filter chips (WebGPU / ONNX / WASM / LiteRT) that show live counts, per-model badges (vision, tool-calling, cached), a download affordance for uncached models, and a delete-from-cache affordance for cached ones. Models that cannot run on the current device — e.g. a WebGPU-only model on a device without WebGPU — are visibly de-emphasized while their "Requires WebGPU" reason stays at full contrast. It emits `onSelect(modelId)` and owns no selection state.

The backend-filter row wraps at narrow widths and only appears when **two or more** backends are actually present — a single-backend catalog (e.g. a CLIP embedding pair) hides the row rather than surfacing disabled zero-count chips. The filter chips form a labelled `role="group"` and expose their pressed state via `aria-pressed`; the active model row is marked `aria-current`; the download/delete controls carry `aria-label`s and expand to a ≥44px touch target on mobile.

Preview [#preview]

```tsx
'use client';

import { useState } from 'react';
import { useCapabilities } from '@localmode/react';

import { ModelSelector, type SelectableModel } from '@/components/model-selector';

const FULL_MODELS: SelectableModel[] = [
  { id: 'llama-3.2-1b', name: 'Llama 3.2 1B', backend: 'webgpu', category: 'Chat', size: '1.2 GB', tools: true, cached: true },
  { id: 'qwen3-0.6b', name: 'Qwen3 0.6B', backend: 'litert', category: 'Chat', size: '0.6 GB', tools: true },
  { id: 'gemma-4-e2b', name: 'Gemma 4 E2B', backend: 'webgpu', category: 'Vision', size: '2.4 GB', vision: true },
  { id: 'bge-small', name: 'bge-small-en-v1.5', backend: 'onnx', category: 'Embedding', size: '34 MB', cached: true },
  { id: 'whisper-base', name: 'Whisper Base', backend: 'wasm', category: 'Speech', size: '74 MB' },
];

// A single-backend CLIP catalog — the selector hides the backend-filter row
// entirely instead of showing three disabled zero-count chips.
const CLIP_MODELS: SelectableModel[] = [
  { id: 'clip-vit', name: 'CLIP ViT-B/32', backend: 'onnx', category: 'Multimodal', size: '340 MB', cached: true },
  { id: 'siglip', name: 'SigLIP Base', backend: 'onnx', category: 'Multimodal', size: '812 MB' },
];

/**
 * Demo for ModelSelector. Reads real WebGPU support from useCapabilities so the
 * WebGPU-only models de-emphasize on devices without it (their "Requires WebGPU"
 * reason stays at full contrast). The catalog toggle shows how a single-backend
 * catalog auto-hides the filter row. Selection + callbacks are wired to local
 * state; no model is actually downloaded or deleted here.
 */
export default function ModelSelectorDemo() {
  const { capabilities } = useCapabilities();
  const [catalog, setCatalog] = useState<'full' | 'clip'>('full');
  const [selectedId, setSelectedId] = useState('bge-small');
  const [lastAction, setLastAction] = useState<string | null>(null);

  const models = catalog === 'full' ? FULL_MODELS : CLIP_MODELS;

  return (
    <div className="flex flex-col gap-2">
      <div
        role="group"
        aria-label="Catalog"
        className="inline-flex w-fit rounded-lg border border-border bg-muted p-1 text-sm"
      >
        <button
          type="button"
          aria-pressed={catalog === 'full'}
          onClick={() => {
            setCatalog('full');
            setSelectedId('bge-small');
          }}
          className={catalog === 'full' ? 'rounded-md bg-background px-3 py-1 font-medium shadow-sm' : 'px-3 py-1 text-muted-foreground'}
        >
          Multi-backend
        </button>
        <button
          type="button"
          aria-pressed={catalog === 'clip'}
          onClick={() => {
            setCatalog('clip');
            setSelectedId('clip-vit');
          }}
          className={catalog === 'clip' ? 'rounded-md bg-background px-3 py-1 font-medium shadow-sm' : 'px-3 py-1 text-muted-foreground'}
        >
          CLIP (single backend)
        </button>
      </div>

      <ModelSelector
        models={models}
        selectedId={selectedId}
        hasWebGPU={Boolean(capabilities?.features.webgpu)}
        onSelect={(id) => {
          setSelectedId(id);
          setLastAction(`selected ${id}`);
        }}
        onDownload={(id) => setLastAction(`download ${id}`)}
        onDelete={(id) => setLastAction(`delete ${id}`)}
      />
      {lastAction && (
        <p className="text-xs text-muted-foreground">Last action: {lastAction}</p>
      )}
    </div>
  );
}
```

Installation [#installation]

```bash
npx shadcn@latest add @localmode/ui/local-first/model-selector
```

Data source & dependencies [#data-source--dependencies]

**Data source:** renders the model list + `hasWebGPU` device-fit you pass and emits `onSelect(modelId)` — works with any backend. Recommended producer: `useCapabilities` (device-fit) and your `useModelRecommendations` data from `@localmode/react` (on-device, optional).

* `lucide-react` — icons
* `clsx` + `tailwind-merge` — via the shared `cn()` util (installed automatically as a registry dependency)

Files installed [#files-installed]

* `model-selector.tsx` — the `ModelSelector` component
* `lib/utils.ts` — the `cn()` helper (if not already present)

Props [#props]

**ModelSelector**

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `models` | `array` | — | **Required.** The models to list. |
| `selectedId` | `string` | — | Currently selected model id (for the active ring). |
| `hasWebGPU` | `boolean` | — | Whether the current device supports WebGPU. Models whose backend is `webgpu` are de-emphasized when this is false. |
| `busyIds` | `object` | — | Set of model ids whose download/delete is in progress. |
| `onSelect` | `function` | — | Fired when a fit, available model is activated. |
| `onDownload` | `function` | — | Fired when the download affordance on an uncached model is activated. |
| `onDelete` | `function` | — | Fired when the delete-from-cache affordance on a cached model is activated. |

**SelectableModel**

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `id` | `string` | — | **Required.** Stable model identifier passed to `onSelect`. |
| `name` | `string` | — | **Required.** Display name. |
| `backend` | `ModelBackend` | — | **Required.** Backend the model runs on (drives filter chips and device-fit). |
| `category` | `string` | — | **Required.** Category used for grouping (e.g. "Chat", "Vision"). |
| `size` | `string` | — | Human-readable size (e.g. "1.2 GB"). |
| `vision` | `boolean` | — | Whether the model supports image input. |
| `tools` | `boolean` | — | Whether the model supports tool / function calling. |
| `cached` | `boolean` | — | Whether the model is already cached on-device. |

Examples [#examples]

Bound to recommendations + capabilities [#bound-to-recommendations--capabilities]

```tsx
import { useCapabilities } from '@localmode/react';
import { ModelSelector } from '@/components/model-selector';

export function Sidebar({ models, onPick }) {
  const { capabilities } = useCapabilities();
  return (
    <ModelSelector
      models={models}
      hasWebGPU={Boolean(capabilities?.features.webgpu)}
      onSelect={onPick}
      onDownload={(id) => prefetch(id)}
      onDelete={(id) => evict(id)}
    />
  );
}
```

Customization [#customization]

The de-emphasis (a muted model name, with the amber "Requires WebGPU" reason kept at full — AA — contrast rather than dimmed by opacity) and the hint itself are derived from the `hasWebGPU` prop — extend `isUnfit` in the copied file to gate on other backends (e.g. WASM-only). The filter row is auto-hidden for single-backend catalogs (`presentBackends.length > 1`); drop that guard if you always want the row. Badge icons use `lucide-react`; the active filter chip uses `bg-primary`. Styled entirely with shadcn/ui CSS-variable utilities so it inherits your theme — because you own the copied file, every class and threshold is yours to change.