Skip to content
UI Kit
Accent color

Model picker

Five ways to choose the model behind an AI feature. A compact dropdown, cards with speed, depth, context and cost, a comparison table, the automatic choice explained as a route, and defaults per task with a cost limit.

One of 3 components in AI setup

5 variants

Variants

Dropdown

A compact switch for the composer, with auto choice, speed and cost per model and a locked model that says which plan it needs. Arrow keys and Escape work.

Open in Studio

Model cards

Every model as a card with speed and reasoning as meters, context, cost and what it is good for. Cards are a radio group, the chosen one carries a ring.

Open in Studio

Comparison table

Models as columns with meters, context, cost and abilities, the best value of each row marked. Phones show one model at a time with a sliding switch.

Open in Studio

Auto routing

Example requests on the left, models on the right and a line between them that shows which model the auto mode picks and why.

Open in Studio

Defaults per task

A default model for each kind of task and a limit for the cost of one request, saved together. Changes enable save and reset.

Open in Studio

Details

What Model picker brings along, read from the registry and the source.

Category
AI setup
Area
App
Variants
5
Version
1.0.1 · since 1.1.0
File
components/ui/model-picker.tsx
Brings along
utilskiticonrevealmotion
npm packages
None
Languages
English and German, 37 texts of its own
In collections
AI product

Props

The ModelPickerProps interface from components/ui/model-picker.tsx, with the comments from the code.

  • variantoptional
    ModelPickerVariant

    dropdowncardscompare-tableauto-routingtask-defaults

  • titleoptional
    string
  • descriptionoptional
    string
  • modelsrequired
    AiModelOption[]
  • defaultValueoptional
    string

    Selected model id

  • defaultOpenoptional
    boolean

    dropdown: start open

  • autooptional
    boolean

    dropdown: offer an automatic choice

  • routesoptional
    ModelRoute[]

    auto-routing

  • tasksoptional
    ModelTask[]

    task-defaults

  • maxCostoptional
    { value: number; min: number; max: number; unit: string }

    task-defaults: credit limit per request, with its range

  • onChangeoptional
    (id: string) => void

Built-in texts

Model picker writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 37 texts
modelEnglish ModelGerman Modell
chooseEnglish Choose a modelGerman Modell wählen
autoEnglish AutoGerman Automatisch
autoNoteEnglish Picks a model for each requestGerman Wählt für jede Anfrage ein Modell
speedEnglish SpeedGerman Tempo
depthEnglish ReasoningGerman Denktiefe
contextEnglish ContextGerman Kontext
costEnglish Cost per messageGerman Kosten je Nachricht
costShortEnglish CostGerman Kosten
useEnglish Use this modelGerman Dieses Modell nutzen
usingEnglish In useGerman In Nutzung
compareEnglish Compare modelsGerman Modelle vergleichen
lockedEnglish Needs {plan}German Braucht {plan}
filesEnglish FilesGerman Dateien
imagesEnglish ImagesGerman Bilder
webEnglish Web searchGerman Websuche
codeEnglish CodeGerman Code
voiceEnglish VoiceGerman Sprache
euEnglish Processed in the EUGerman Verarbeitung in der EU
yesEnglish YesGerman Ja
noEnglish NoGerman Nein
bestEnglish Best in this rowGerman Bester Wert der Zeile
taskEnglish RequestGerman Anfrage
routedToEnglish Goes toGerman Geht an
overrideEnglish Auto chooses by the kind of request. You can pick a model by hand at any time.German Automatisch wählt nach der Art der Anfrage. Du kannst jederzeit selbst ein Modell wählen.
defaultsEnglish Default model per taskGerman Standardmodell je Aufgabe
limitEnglish Most a single request may costGerman Höchstens je Anfrage
limitNoteEnglish Requests above the limit ask before they run.German Anfragen über der Grenze fragen nach, bevor sie laufen.
savedEnglish SavedGerman Gespeichert
saveEnglish Save defaultsGerman Standards speichern
resetEnglish ResetGerman Zurücksetzen
levelEnglish {n} of 5German {n} von 5
costLevelEnglish Cost {n} of 3German Kosten {n} von 3
estimateEnglish Speeds and costs are typical values and vary with the request.German Tempo und Kosten sind typische Werte und hängen von der Anfrage ab.
typicalEnglish Typical valuesGerman Typische Werte
startWithEnglish New chat with {name}German Neuer Chat mit {name}
modelsEnglish ModelsGerman Modelle

Accessibility

Read from the source of the component.

  • Model picker handles the keys Escape, Enter, Arrow up, Arrow down, Arrow left, Arrow right, Tab and Space itself.
  • Sets aria-activedescendant, aria-checked, aria-controls, aria-disabled, aria-expanded, aria-haspopup, aria-label, aria-live, aria-pressed and aria-selected, so screen readers announce purpose and state.
  • Uses the roles img, listbox, option, radio, radiogroup, tab, tablist and tabpanel.
  • Announces changes through a live region without moving focus.
  • Every field has a label, visible or for screen readers.
  • Adds text for screen readers where icons and graphics stand alone.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Rises in briefly the first time it comes into view (kit-rise, useInView).
  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 969 lines
components/ui/model-picker.tsx
'use client';

import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useLocale, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Stagger } from '@/components/ui/motion';

export type ModelPickerVariant = 'dropdown' | 'cards' | 'compare-table' | 'auto-routing' | 'task-defaults';

export type ModelAbility = 'files' | 'images' | 'web' | 'code' | 'voice' | 'eu';

export interface AiModelOption {
  id: string;
  name: string;
  description: string;
  /** 1 to 5 */
  speed: number;
  /** 1 to 5, how far it reasons */
  depth: number;
  /** As shown, e.g. "200k tokens" */
  context: string;
  /** As shown, e.g. "1 credit" */
  cost: string;
  /** 1 to 3, drawn as coins */
  costLevel: number;
  /** Small labels, e.g. "New" */
  tags?: string[];
  abilities?: ModelAbility[];
  /** cards: what it is good at */
  bestFor?: string[];
  /** Plan needed to use it, shown instead of the select */
  locked?: string;
}

export interface ModelRoute {
  id: string;
  /** An example request */
  task: string;
  /** Short name of the kind of task */
  kind: string;
  icon?: IconName;
  /** Model id */
  model: string;
  reason: string;
}

export interface ModelTask {
  id: string;
  label: string;
  icon?: IconName;
  description?: string;
  /** Model id */
  model: string;
}

export interface ModelPickerProps {
  variant?: ModelPickerVariant;
  title?: string;
  description?: string;
  models: AiModelOption[];
  /** Selected model id */
  defaultValue?: string;
  /** dropdown: start open */
  defaultOpen?: boolean;
  /** dropdown: offer an automatic choice */
  auto?: boolean;
  /** auto-routing */
  routes?: ModelRoute[];
  /** task-defaults */
  tasks?: ModelTask[];
  /** task-defaults: credit limit per request, with its range */
  maxCost?: { value: number; min: number; max: number; unit: string };
  onChange?: (id: string) => void;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

const TEXTS = {
  en: {
    model: 'Model',
    choose: 'Choose a model',
    auto: 'Auto',
    autoNote: 'Picks a model for each request',
    speed: 'Speed',
    depth: 'Reasoning',
    context: 'Context',
    cost: 'Cost per message',
    costShort: 'Cost',
    
    
    
    use: 'Use this model',
    using: 'In use',
    compare: 'Compare models',
    locked: 'Needs {plan}',
    files: 'Files',
    images: 'Images',
    web: 'Web search',
    code: 'Code',
    voice: 'Voice',
    eu: 'Processed in the EU',
    yes: 'Yes',
    no: 'No',
    best: 'Best in this row',
    
    task: 'Request',
    routedTo: 'Goes to',
    
    override: 'Auto chooses by the kind of request. You can pick a model by hand at any time.',
    defaults: 'Default model per task',
    limit: 'Most a single request may cost',
    limitNote: 'Requests above the limit ask before they run.',
    saved: 'Saved',
    save: 'Save defaults',
    reset: 'Reset',
    level: '{n} of 5',
    costLevel: 'Cost {n} of 3',
    estimate: 'Speeds and costs are typical values and vary with the request.',
    typical: 'Typical values',
    startWith: 'New chat with {name}',
    models: 'Models',
  },
  de: {
    model: 'Modell',
    choose: 'Modell wählen',
    auto: 'Automatisch',
    autoNote: 'Wählt für jede Anfrage ein Modell',
    speed: 'Tempo',
    depth: 'Denktiefe',
    context: 'Kontext',
    cost: 'Kosten je Nachricht',
    costShort: 'Kosten',
    
    
    
    use: 'Dieses Modell nutzen',
    using: 'In Nutzung',
    compare: 'Modelle vergleichen',
    locked: 'Braucht {plan}',
    files: 'Dateien',
    images: 'Bilder',
    web: 'Websuche',
    code: 'Code',
    voice: 'Sprache',
    eu: 'Verarbeitung in der EU',
    yes: 'Ja',
    no: 'Nein',
    best: 'Bester Wert der Zeile',
    
    task: 'Anfrage',
    routedTo: 'Geht an',
    
    override: 'Automatisch wählt nach der Art der Anfrage. Du kannst jederzeit selbst ein Modell wählen.',
    defaults: 'Standardmodell je Aufgabe',
    limit: 'Höchstens je Anfrage',
    limitNote: 'Anfragen über der Grenze fragen nach, bevor sie laufen.',
    saved: 'Gespeichert',
    save: 'Standards speichern',
    reset: 'Zurücksetzen',
    level: '{n} von 5',
    costLevel: 'Kosten {n} von 3',
    estimate: 'Tempo und Kosten sind typische Werte und hängen von der Anfrage ab.',
    typical: 'Typische Werte',
    startWith: 'Neuer Chat mit {name}',
    models: 'Modelle',
  },
};

type Texts = (typeof TEXTS)['en'];
type P = ModelPickerProps & { t: Texts };
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
const useIso = typeof window === 'undefined' ? useEffect : useLayoutEffect;

const CSS =
  '@keyframes mpk-up{from{opacity:0;transform:translateY(8px)}}@keyframes mpk-menu{from{opacity:0;transform:translateY(-6px) scale(.98)}}@keyframes mpk-bar{from{transform:scaleX(0)}}@keyframes mpk-flow{to{stroke-dashoffset:-24}}' +
  '@media (prefers-reduced-motion:no-preference){.mpk-up{animation:mpk-up .45s var(--ease-kit) var(--mpk-d,0ms) both}.mpk-menu{transform-origin:0 0;animation:mpk-menu .22s var(--ease-kit) both}.mpk-bar{transform-origin:left;animation:mpk-bar .9s var(--ease-kit) var(--mpk-d,0ms) both}.mpk-flow{stroke-dasharray:6 6;animation:mpk-flow .9s linear infinite}}';

function Style() {
  return (
    <style href="kit-model-picker" precedence="default">
      {CSS}
    </style>
  );
}

function useGlide<T extends HTMLElement>(key: unknown) {
  const ref = useRef<T>(null);
  const before = useRef<DOMRect | null>(null);
  if (typeof window !== 'undefined' && ref.current) before.current = ref.current.querySelector('[data-glide]')?.getBoundingClientRect() ?? null;
  useIso(() => {
    const m = ref.current?.querySelector<HTMLElement>('[data-glide]');
    const a = before.current;
    if (!m || !a || prefersReducedMotion()) return;
    const b = m.getBoundingClientRect();
    if (!b.width || (Math.abs(a.left - b.left) < 1 && Math.abs(a.top - b.top) < 1)) return;
    m.animate([{ transformOrigin: '0 0', transform: `translate(${a.left - b.left}px, ${a.top - b.top}px) scale(${a.width / b.width}, ${a.height / b.height})` }, { transformOrigin: '0 0', transform: 'none' }], { duration: 420, easing: 'cubic-bezier(0.2, 0.7, 0.2, 1)' });
  }, [key]);
  return ref;
}

/* ------------------------------------------------------------------ */
/* Shared parts                                                        */
/* ------------------------------------------------------------------ */

const ABILITY: Record<ModelAbility, IconName> = { files: 'paperclip', images: 'image', web: 'globe', code: 'code', voice: 'mic', eu: 'shield' };

function Stage({ title, description, children, className, wide }: { title?: string; description?: string; children: React.ReactNode; className?: string; wide?: boolean }) {
  return (
    <section className={cn('kit-glow relative overflow-clip bg-surface py-12 sm:py-16 lg:py-20', className)}>
      <div className="kit-container">
        <div className={cn('mx-auto w-full', wide ? 'max-w-[68rem]' : 'max-w-[48rem]')}>
          {(title || description) && (
            <Reveal className="mb-6 sm:mb-8">
              {title && <h2 className="text-balance text-[22px] font-semibold leading-tight tracking-[-0.02em] text-foreground sm:text-[26px]">{title}</h2>}
              {description && <p className="mt-2 max-w-[38rem] text-pretty text-[14.5px] leading-relaxed text-muted-foreground">{description}</p>}
            </Reveal>
          )}
          {children}
        </div>
      </div>
    </section>
  );
}

/** Five short segments, filled to the level */
function Meter({ value, label, t, delay = 0 }: { value: number; label: string; t: Texts; delay?: number }) {
  return (
    <span role="img" aria-label={`${label}, ${fill(t.level, { n: value })}`} className="flex gap-0.5">
      {[1, 2, 3, 4, 5].map((n) => (
        <span key={n} className="h-1.5 w-3.5 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)]">
          {n <= value && <span className="mpk-bar block h-full rounded-full bg-primary" style={{ '--mpk-d': `${delay + n * 60}ms` } as React.CSSProperties} />}
        </span>
      ))}
    </span>
  );
}

function Coins({ level, t }: { level: number; t: Texts }) {
  return (
    <span role="img" aria-label={fill(t.costLevel, { n: level })} className="inline-flex items-center gap-px">
      {[1, 2, 3].map((n) => (
        <Icon key={n} name="coins" size={13} className={n <= level ? 'text-warning-foreground' : 'text-[color-mix(in_oklab,var(--foreground)_18%,transparent)]'} />
      ))}
    </span>
  );
}

function Tag({ children, tone = 'primary' }: { children: React.ReactNode; tone?: 'primary' | 'muted' }) {
  return (
    <span className={cn('inline-flex h-5 items-center rounded-md px-1.5 text-[11px] font-medium', tone === 'primary' ? 'bg-[color-mix(in_oklab,var(--primary)_15%,var(--card))] text-[color-mix(in_oklab,var(--primary)_72%,var(--foreground))]' : 'bg-[color-mix(in_oklab,var(--foreground)_7%,transparent)] text-muted-foreground')}>
      {children}
    </span>
  );
}

function ModelMark({ model, size = 36 }: { model: AiModelOption; size?: number }) {
  // A small drawn mark: rings grow with the reasoning depth, the dot with speed
  const r = round(size / 2 - 2);
  return (
    <svg aria-hidden viewBox={`0 0 ${size} ${size}`} width={size} height={size} className="shrink-0">
      <rect width={size} height={size} rx={round(size * 0.28)} style={{ fill: 'color-mix(in oklab, var(--primary) 12%, var(--card))' }} />
      {Array.from({ length: Math.max(1, Math.min(4, Math.round(model.depth / 1.4))) }, (_, i) => (
        <circle key={i} cx={size / 2} cy={size / 2} r={round(r * (0.32 + i * 0.2))} fill="none" stroke="var(--primary)" strokeOpacity={round(0.85 - i * 0.18)} strokeWidth="1.3" />
      ))}
      <circle cx={round(size / 2 + r * 0.32 * Math.cos(model.speed))} cy={round(size / 2 - r * 0.32 * Math.sin(model.speed))} r="2.2" fill="var(--primary)" />
    </svg>
  );
}

/* ------------------------------------------------------------------ */
/* dropdown                                                            */
/* ------------------------------------------------------------------ */

function Dropdown({ t, title, description, models, defaultValue, defaultOpen, auto = true, onChange, className }: P) {
  const [value, setValue] = useState(defaultValue ?? (auto ? 'auto' : models[0]?.id));
  const [open, setOpen] = useState(!!defaultOpen);
  const [active, setActive] = useState(0);
  const id = useId();
  const btn = useRef<HTMLButtonElement>(null);
  const list = useRef<HTMLDivElement>(null);
  const options = [...(auto ? [{ id: 'auto' } as const] : []), ...models];
  const glide = useGlide<HTMLDivElement>(`${active}-${open}`);
  const current = models.find((m) => m.id === value);

  useEffect(() => {
    if (!open) return;
    setActive(Math.max(0, options.findIndex((o) => o.id === value)));
    const away = (e: PointerEvent) => !btn.current?.parentElement?.contains(e.target as Node) && setOpen(false);
    const esc = (e: KeyboardEvent) => e.key === 'Escape' && (setOpen(false), btn.current?.focus());
    document.addEventListener('pointerdown', away);
    document.addEventListener('keydown', esc);
    return () => {
      document.removeEventListener('pointerdown', away);
      document.removeEventListener('keydown', esc);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [open]);

  const pick = (i: number) => {
    const o = options[i];
    if (!o || ('locked' in o && o.locked)) return;
    setValue(o.id);
    onChange?.(o.id);
    setOpen(false);
    btn.current?.focus();
  };
  const keys = (e: React.KeyboardEvent) => {
    if (!open && (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ')) {
      e.preventDefault();
      setOpen(true);
      requestAnimationFrame(() => list.current?.focus());
      return;
    }
    if (!open) return;
    if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
      e.preventDefault();
      setActive((a) => (a + (e.key === 'ArrowDown' ? 1 : -1) + options.length) % options.length);
    } else if (e.key === 'Enter' || e.key === ' ') {
      e.preventDefault();
      pick(active);
    } else if (e.key === 'Escape' || e.key === 'Tab') {
      setOpen(false);
      if (e.key === 'Escape') btn.current?.focus();
    }
  };

  return (
    <Stage title={title} description={description} className={className}>
      <Reveal effect="scale" delay={80}>
        <div className="rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)]">
          <div className="relative flex items-center gap-2 border-b border-border p-2.5 sm:p-3">
            <button
              ref={btn}
              type="button"
              aria-haspopup="listbox"
              aria-expanded={open}
              aria-controls={id}
              onClick={() => (setOpen((o) => !o), requestAnimationFrame(() => list.current?.focus()))}
              onKeyDown={keys}
              className="inline-flex h-11 items-center gap-2 rounded-lg border border-border bg-background pl-2 pr-2.5 text-[14px] font-medium text-foreground transition-colors hover:bg-accent sm:h-10"
            >
              {current ? <ModelMark model={current} size={26} /> : <span className="grid size-[26px] place-items-center rounded-[7px] bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-primary"><Icon name="sparkle" size={15} /></span>}
              <span className="sr-only">{`${t.model}, `}</span>
              {current?.name ?? t.auto}
              <Icon name="chevrons-up-down" size={15} className="text-muted-foreground" />
            </button>
            <span className="min-w-0 flex-1 truncate text-[13px] text-muted-foreground max-sm:hidden">{current ? current.description : t.autoNote}</span>
            {open && (
              <div
                ref={(el) => {
                  (list as React.MutableRefObject<HTMLDivElement | null>).current = el;
                  (glide as React.MutableRefObject<HTMLDivElement | null>).current = el;
                }}
                id={id}
                role="listbox"
                tabIndex={-1}
                aria-label={t.choose}
                aria-activedescendant={`${id}-${options[active]?.id}`}
                onKeyDown={keys}
                className="mpk-menu absolute left-0 top-[calc(100%+0.5rem)] z-20 w-[min(26rem,calc(100vw-3rem))] overflow-hidden rounded-2xl border border-border bg-card p-1.5 shadow-[var(--highlight),var(--shadow-lg)] outline-none"
              >
                {options.map((o, i) => {
                  const on = o.id === value;
                  const m = o.id === 'auto' ? null : (o as AiModelOption);
                  return (
                    <div
                      key={o.id}
                      id={`${id}-${o.id}`}
                      role="option"
                      aria-selected={on}
                      aria-disabled={m?.locked ? true : undefined}
                      onPointerMove={() => setActive(i)}
                      onClick={() => pick(i)}
                      className={cn('relative flex min-h-14 cursor-pointer items-center gap-3 rounded-xl px-2.5 py-2.5', m?.locked && 'cursor-not-allowed')}
                    >
                      {i === active && <span data-glide aria-hidden className="absolute inset-0 rounded-xl bg-accent" />}
                      <span className={cn('relative', m?.locked && 'opacity-50')}>
                        {m ? <ModelMark model={m} size={34} /> : <span className="grid size-[34px] place-items-center rounded-[10px] bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-primary"><Icon name="sparkle" size={18} /></span>}
                      </span>
                      <span className={cn('relative min-w-0 flex-1', m?.locked && 'opacity-60')}>
                        <span className="flex items-center gap-1.5">
                          <span className="text-[14px] font-medium text-foreground">{m?.name ?? t.auto}</span>
                          {m?.tags?.map((tg) => <Tag key={tg}>{tg}</Tag>)}
                        </span>
                        <span className="mt-0.5 block text-pretty text-[12.5px] leading-snug text-muted-foreground">{m ? (m.locked ? fill(t.locked, { plan: m.locked }) : m.description) : t.autoNote}</span>
                      </span>
                      {m && !m.locked && (
                        <span className="relative hidden shrink-0 flex-col items-end gap-1 sm:flex">
                          <Meter value={m.speed} label={t.speed} t={t} />
                          <Coins level={m.costLevel} t={t} />
                        </span>
                      )}
                      {m?.locked && <Icon name="lock" size={15} className="relative shrink-0 text-muted-foreground" />}
                      <Icon name="check" size={16} className={cn('relative shrink-0 text-primary', !on && 'invisible')} />
                    </div>
                  );
                })}
                <div className="mt-1 flex items-center justify-between gap-3 border-t border-border px-2.5 pb-1 pt-2.5 text-[12px] text-muted-foreground">
                  <span className="flex items-center gap-3">
                    <span className="flex items-center gap-1.5">
                      <span className="flex gap-0.5">
                        <span className="h-1.5 w-2 rounded-full bg-primary" />
                        <span className="h-1.5 w-2 rounded-full bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)]" />
                      </span>
                      {t.speed}
                    </span>
                    <span className="flex items-center gap-1">
                      <Icon name="coins" size={13} className="text-warning-foreground" />
                      {t.costShort}
                    </span>
                  </span>
                  <span>{t.typical}</span>
                </div>
              </div>
            )}
          </div>
          <div className="grid min-h-[27rem] place-items-center px-6 py-10 text-center">
            <div key={value} className="mpk-up">
              <span className="mx-auto grid w-fit place-items-center">{current ? <ModelMark model={current} size={48} /> : <span className="grid size-12 place-items-center rounded-[14px] bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-primary"><Icon name="sparkle" size={24} /></span>}</span>
              <p className="mt-4 text-[17px] font-semibold text-foreground">{fill(t.startWith, { name: current?.name ?? t.auto })}</p>
              <p className="mx-auto mt-1 max-w-[22rem] text-pretty text-[13.5px] leading-relaxed text-muted-foreground">{current ? current.description : t.autoNote}</p>
            </div>
          </div>
        </div>
      </Reveal>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* cards                                                               */
/* ------------------------------------------------------------------ */

function Cards({ t, title, description, models, defaultValue, onChange, className }: P) {
  const [value, setValue] = useState(defaultValue ?? models[0]?.id);
  const refs = useRef<(HTMLButtonElement | null)[]>([]);
  const choose = (id: string) => (setValue(id), onChange?.(id));
  return (
    <Stage title={title} description={description} className={className} wide>
      <Stagger role="radiogroup" aria-label={t.choose} step={90} className={cn('grid grid-cols-1 gap-3 sm:grid-cols-2', models.length >= 4 ? 'lg:grid-cols-4' : 'lg:grid-cols-3')}>
        {models.map((m, i) => {
          const on = m.id === value;
          return (
            <button
              key={m.id}
              ref={(el) => {
                refs.current[i] = el;
              }}
              type="button"
              role="radio"
              aria-checked={on}
              aria-disabled={m.locked ? true : undefined}
              tabIndex={on ? 0 : -1}
              onClick={() => !m.locked && choose(m.id)}
              onKeyDown={(e) => {
                const d = ['ArrowRight', 'ArrowDown'].includes(e.key) ? 1 : ['ArrowLeft', 'ArrowUp'].includes(e.key) ? -1 : 0;
                if (!d) return;
                e.preventDefault();
                let n = i;
                do n = (n + d + models.length) % models.length;
                while (models[n].locked && n !== i);
                choose(models[n].id);
                refs.current[n]?.focus();
              }}
              className={cn(
                'group relative flex h-full flex-col rounded-2xl border bg-card p-4 text-left shadow-[var(--highlight),var(--shadow-sm)] transition-[border-color,box-shadow,transform] duration-300 ease-[var(--ease-kit)] sm:p-5',
                on ? 'border-[color-mix(in_oklab,var(--primary)_60%,var(--border))] shadow-[var(--highlight),var(--shadow-md),0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)]' : 'border-border hover:-translate-y-0.5 hover:border-border-strong hover:shadow-[var(--highlight),var(--shadow-md)] motion-reduce:hover:translate-y-0',
                m.locked && 'cursor-not-allowed',
              )}
            >
              <span className="flex items-start justify-between gap-2">
                <ModelMark model={m} size={40} />
                <span className={cn('grid size-6 place-items-center rounded-full border transition-colors duration-300', on ? 'border-primary bg-primary text-primary-foreground' : 'border-border-strong text-transparent')}>
                  <Icon name={m.locked ? 'lock' : 'check'} size={13} strokeWidth={2.2} className={m.locked ? 'text-muted-foreground' : undefined} />
                </span>
              </span>
              <span className="mt-4 flex flex-wrap items-center gap-1.5">
                <span className="text-[16px] font-semibold tracking-[-0.01em] text-foreground">{m.name}</span>
                {m.tags?.map((tg) => <Tag key={tg}>{tg}</Tag>)}
              </span>
              <span className="mt-1 text-pretty text-[13.5px] leading-relaxed text-muted-foreground sm:min-h-[2.75rem]">{m.description}</span>
              <span className="mt-4 grid grid-cols-[auto_minmax(0,1fr)] items-center gap-x-3 gap-y-2.5 border-t border-border pt-4 text-[12.5px]">
                <span className="text-muted-foreground">{t.speed}</span>
                <span className="justify-self-end">
                  <Meter value={m.speed} label={t.speed} t={t} delay={i * 90} />
                </span>
                <span className="text-muted-foreground">{t.depth}</span>
                <span className="justify-self-end">
                  <Meter value={m.depth} label={t.depth} t={t} delay={i * 90 + 120} />
                </span>
                <span className="text-muted-foreground">{t.context}</span>
                <span className="justify-self-end tabular-nums text-foreground">{m.context}</span>
                <span className="text-muted-foreground">{t.costShort}</span>
                <span className="flex items-center gap-2 justify-self-end tabular-nums text-foreground">
                  {m.cost}
                  <Coins level={m.costLevel} t={t} />
                </span>
              </span>
              {!!m.bestFor?.length && (
                <span className="mt-4 flex flex-wrap gap-1.5">
                  {m.bestFor.map((b) => (
                    <Tag key={b} tone="muted">
                      {b}
                    </Tag>
                  ))}
                </span>
              )}
              <span className="mt-auto flex items-center gap-1.5 pt-4 text-[12.5px] font-medium">
                {m.locked ? (
                  <span className="text-muted-foreground">{fill(t.locked, { plan: m.locked })}</span>
                ) : on ? (
                  <span className="text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]">{t.using}</span>
                ) : (
                  <span className="text-muted-foreground group-hover:text-foreground">{t.use}</span>
                )}
              </span>
            </button>
          );
        })}
      </Stagger>
      <p className="mt-4 text-[12.5px] text-muted-foreground">{t.estimate}</p>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* compare-table                                                       */
/* ------------------------------------------------------------------ */

function CompareTable({ t, title, description, models, defaultValue, onChange, className }: P) {
  const [value, setValue] = useState(defaultValue ?? models[0]?.id);
  const [phone, setPhone] = useState(defaultValue ?? models[0]?.id);
  const glide = useGlide<HTMLDivElement>(phone);
  const all: ModelAbility[] = ['files', 'images', 'web', 'code', 'voice', 'eu'];
  const abilities = all.filter((a) => models.some((m) => m.abilities?.includes(a)));
  const max = (k: 'speed' | 'depth') => Math.max(...models.map((m) => m[k]));
  const minCost = Math.min(...models.map((m) => m.costLevel));
  const rows: { label: string; cell: (m: AiModelOption, i: number) => React.ReactNode; best?: (m: AiModelOption) => boolean }[] = [
    { label: t.speed, cell: (m, i) => <Meter value={m.speed} label={t.speed} t={t} delay={i * 80} />, best: (m) => m.speed === max('speed') },
    { label: t.depth, cell: (m, i) => <Meter value={m.depth} label={t.depth} t={t} delay={i * 80 + 120} />, best: (m) => m.depth === max('depth') },
    { label: t.context, cell: (m) => <span className="tabular-nums">{m.context}</span> },
    { label: t.cost, cell: (m) => (
        <span className="inline-flex items-center gap-2 tabular-nums">
          {m.cost}
          <Coins level={m.costLevel} t={t} />
        </span>
      ), best: (m) => m.costLevel === minCost },
    ...abilities.map((a) => ({
      label: t[a],
      cell: (m: AiModelOption) =>
        m.abilities?.includes(a) ? (
          <span className="inline-flex items-center gap-1.5 text-success-foreground">
            <Icon name="check" size={15} strokeWidth={2.2} />
            <span className="sr-only">{t.yes}</span>
          </span>
        ) : (
          <span className="text-subtle-foreground">
            <Icon name="minus" size={15} />
            <span className="sr-only">{t.no}</span>
          </span>
        ),
    })),
  ];
  const pm = models.find((m) => m.id === phone) ?? models[0];

  return (
    <Stage title={title} description={description} className={className} wide>
      <Reveal effect="scale" delay={80}>
        {/* Phones: one model at a time, picked with a segmented control */}
        <div className="md:hidden">
          <div ref={glide} role="tablist" aria-label={t.choose} className="grid gap-1 rounded-xl bg-[color-mix(in_oklab,var(--foreground)_5%,transparent)] p-1" style={{ gridTemplateColumns: `repeat(${models.length}, minmax(0,1fr))` }}>
            {models.map((m, i) => (
              <button
                key={m.id}
                type="button"
                role="tab"
                aria-selected={m.id === phone}
                tabIndex={m.id === phone ? 0 : -1}
                onClick={() => setPhone(m.id)}
                onKeyDown={(e) => {
                  const d = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
                  if (!d) return;
                  e.preventDefault();
                  const n = (i + d + models.length) % models.length;
                  setPhone(models[n].id);
                  (e.currentTarget.parentElement?.children[n] as HTMLElement | undefined)?.focus();
                }}
                className={cn('relative min-h-11 truncate rounded-lg px-1.5 text-[12.5px] font-medium', m.id === phone ? 'text-foreground' : 'text-muted-foreground')}>
                {m.id === phone && <span data-glide aria-hidden className="absolute inset-0 rounded-lg bg-card shadow-[var(--highlight),var(--shadow-sm)]" />}
                <span className="relative">{m.name.split(' ').slice(-2).join(' ')}</span>
              </button>
            ))}
          </div>
          {pm && (
            <div key={pm.id} role="tabpanel" className="mpk-up mt-3 overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
              <div className="flex items-center gap-3 border-b border-border p-4">
                <ModelMark model={pm} size={36} />
                <div className="min-w-0 flex-1">
                  <p className="text-[15px] font-semibold text-foreground">{pm.name}</p>
                  <p className="text-[12.5px] text-muted-foreground">{pm.description}</p>
                </div>
              </div>
              <dl className="divide-y divide-border">
                {rows.map((r) => (
                  <div key={r.label} className="flex min-h-11 items-center justify-between gap-3 px-4 py-2 text-[13px]">
                    <dt className="text-muted-foreground">{r.label}</dt>
                    <dd className="flex items-center gap-2 text-foreground">
                      {r.best?.(pm) && <span className="size-1.5 rounded-full bg-primary" title={t.best} />}
                      {r.cell(pm, 0)}
                    </dd>
                  </div>
                ))}
              </dl>
              <div className="border-t border-border p-3">
                <button type="button" onClick={() => (setValue(pm.id), onChange?.(pm.id))} disabled={!!pm.locked} className={cn('inline-flex h-11 w-full items-center justify-center gap-1.5 rounded-lg text-[13.5px] font-medium', value === pm.id ? 'bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))] text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]' : 'bg-primary text-primary-foreground disabled:opacity-50')}>
                  {value === pm.id && <Icon name="check" size={15} />}
                  {pm.locked ? fill(t.locked, { plan: pm.locked }) : value === pm.id ? t.using : t.use}
                </button>
              </div>
            </div>
          )}
        </div>
        {/* Tablet and desktop: the full table */}
        <div className="hidden overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)] md:block">
          <table className="w-full table-fixed border-collapse text-left text-[13.5px]">
            <caption className="sr-only">{t.compare}</caption>
            <colgroup>
              <col className="w-[22%] lg:w-[20%]" />
              {models.map((m) => (
                <col key={m.id} />
              ))}
            </colgroup>
            <thead>
              <tr className="border-b border-border">
                <th scope="col" className="p-4 align-bottom text-[12px] font-medium text-muted-foreground">
                  {models.length} {t.models}
                </th>
                {models.map((m) => (
                  <th key={m.id} scope="col" className={cn('relative p-4 align-top font-normal transition-colors duration-300', value === m.id && 'bg-[color-mix(in_oklab,var(--primary)_6%,var(--card))]')}>
                    {value === m.id && <span aria-hidden className="absolute inset-x-0 top-0 h-0.5 bg-primary" />}
                    <ModelMark model={m} size={32} />
                    <span className="mt-2.5 flex flex-wrap items-center gap-1.5">
                      <span className="text-[14.5px] font-semibold text-foreground">{m.name}</span>
                      {m.tags?.map((tg) => <Tag key={tg}>{tg}</Tag>)}
                    </span>
                    <span className="mt-0.5 block text-pretty text-[12.5px] leading-snug text-muted-foreground">{m.description}</span>
                  </th>
                ))}
              </tr>
            </thead>
            <tbody>
              {rows.map((r, ri) => (
                <tr key={r.label} className="mpk-up border-b border-border last:border-0" style={{ '--mpk-d': `${200 + ri * 50}ms` } as React.CSSProperties}>
                  <th scope="row" className="px-4 py-3 text-[13px] font-normal text-muted-foreground">
                    {r.label}
                  </th>
                  {models.map((m, i) => (
                    <td key={m.id} className={cn('px-4 py-3 text-foreground transition-colors duration-300', value === m.id && 'bg-[color-mix(in_oklab,var(--primary)_6%,var(--card))]')}>
                      <span className="flex items-center gap-2">
                        {r.cell(m, i)}
                        {r.best?.(m) && (
                          <span className="size-1.5 shrink-0 rounded-full bg-primary" title={t.best}>
                            <span className="sr-only">{t.best}</span>
                          </span>
                        )}
                      </span>
                    </td>
                  ))}
                </tr>
              ))}
              <tr>
                <td className="p-4" />
                {models.map((m) => (
                  <td key={m.id} className={cn('p-4 pt-2 transition-colors duration-300', value === m.id && 'bg-[color-mix(in_oklab,var(--primary)_6%,var(--card))]')}>
                    <button
                      type="button"
                      disabled={!!m.locked}
                      aria-pressed={value === m.id}
                      onClick={() => (setValue(m.id), onChange?.(m.id))}
                      className={cn('inline-flex h-11 w-full items-center justify-center gap-1.5 rounded-lg text-[13px] font-medium transition-colors lg:h-9', value === m.id ? 'bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]' : 'border border-border bg-background text-foreground hover:bg-accent disabled:opacity-50')}
                    >
                      {value === m.id && <Icon name="check" size={14} />}
                      {m.locked ? fill(t.locked, { plan: m.locked }) : value === m.id ? t.using : t.use}
                    </button>
                  </td>
                ))}
              </tr>
            </tbody>
          </table>
        </div>
      </Reveal>
      <p className="mt-4 flex items-center gap-2 text-[12.5px] text-muted-foreground">
        <span className="size-1.5 rounded-full bg-primary" aria-hidden />
        {t.best} · {t.estimate}
      </p>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* auto-routing                                                        */
/* ------------------------------------------------------------------ */

function AutoRouting({ t, title, description, models, routes = [], className }: P) {
  const [sel, setSel] = useState(routes[0]?.id);
  const route = routes.find((r) => r.id === sel) ?? routes[0];
  const target = models.findIndex((m) => m.id === route?.model);
  const tabs = useRef<(HTMLButtonElement | null)[]>([]);
  const glide = useGlide<HTMLDivElement>(sel);
  // Rows of both columns share one height, so the line can be drawn from row to row
  const ROW = 64;
  const GAP = 10;
  const yOf = (i: number) => i * (ROW + GAP) + ROW / 2;
  const H = Math.max(routes.length, models.length) * (ROW + GAP) - GAP;

  return (
    <Stage title={title} description={description} className={className} wide>
      <Reveal effect="scale" delay={80}>
        <div className="rounded-2xl border border-border bg-card p-3 shadow-[var(--highlight),var(--shadow-md)] sm:p-5">
          <div className="grid grid-cols-1 gap-4 md:grid-cols-[minmax(0,1fr)_5rem_minmax(0,0.8fr)] md:gap-0 lg:grid-cols-[minmax(0,1fr)_8rem_minmax(0,0.8fr)]">
            <div>
              <p className="mb-2 px-1 text-[12px] font-medium text-subtle-foreground">{t.task}</p>
              <div ref={glide} role="radiogroup" aria-label={t.task} className="space-y-2.5 md:space-y-[10px]">
                {routes.map((r, i) => {
                  const on = r.id === route?.id;
                  return (
                    <button
                      key={r.id}
                      ref={(el) => {
                        tabs.current[i] = el;
                      }}
                      type="button"
                      role="radio"
                      aria-checked={on}
                      tabIndex={on ? 0 : -1}
                      onClick={() => setSel(r.id)}
                      onKeyDown={(e) => {
                        const d = ['ArrowDown', 'ArrowRight'].includes(e.key) ? 1 : ['ArrowUp', 'ArrowLeft'].includes(e.key) ? -1 : 0;
                        if (!d) return;
                        e.preventDefault();
                        const n = (i + d + routes.length) % routes.length;
                        setSel(routes[n].id);
                        tabs.current[n]?.focus();
                      }}
                      className={cn('relative flex w-full items-center gap-3 rounded-xl border px-3 text-left transition-colors duration-300 md:h-16', on ? 'border-[color-mix(in_oklab,var(--primary)_50%,var(--border))]' : 'border-border hover:border-border-strong')}
                      style={{ minHeight: ROW }}
                    >
                      {on && <span data-glide aria-hidden className="absolute inset-0 rounded-xl bg-[color-mix(in_oklab,var(--primary)_8%,var(--card))]" />}
                      <span className={cn('relative grid size-9 shrink-0 place-items-center rounded-lg border border-border bg-background', on ? 'text-primary' : 'text-muted-foreground')}>
                        <Icon name={r.icon ?? 'chat'} size={17} />
                      </span>
                      <span className="relative min-w-0 flex-1 py-2">
                        <span className="block text-[12px] font-medium text-muted-foreground">{r.kind}</span>
                        <span className="block text-[13.5px] leading-snug text-foreground md:truncate">{r.task}</span>
                      </span>
                    </button>
                  );
                })}
              </div>
            </div>
            <svg aria-hidden viewBox={`0 0 100 ${H}`} preserveAspectRatio="none" className="hidden self-end md:block" style={{ height: H }}>
              {routes.map((r, i) =>
                models.map((m, j) => {
                  if (r.model !== m.id) return null;
                  const on = r.id === route?.id;
                  const y1 = yOf(i);
                  const y2 = yOf(j);
                  return <path key={`${r.id}-${m.id}`} d={`M0 ${y1} C50 ${y1} 50 ${y2} 100 ${y2}`} fill="none" vectorEffect="non-scaling-stroke" stroke={on ? 'var(--primary)' : 'var(--border-strong)'} strokeWidth={on ? 2 : 1} className={on ? 'mpk-flow' : undefined} style={{ transition: 'stroke .3s' }} />;
                }),
              )}
            </svg>
            <div>
              <p className="mb-2 px-1 text-[12px] font-medium text-subtle-foreground max-md:hidden">{t.model}</p>
              <ul className="hidden space-y-[10px] md:block">
                {models.map((m, j) => {
                  const on = j === target;
                  return (
                    <li key={m.id} className={cn('flex items-center gap-3 rounded-xl border px-3 transition-[border-color,background-color,opacity] duration-300', on ? 'border-[color-mix(in_oklab,var(--primary)_50%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_8%,var(--card))]' : 'border-border opacity-70')} style={{ height: ROW }}>
                      <ModelMark model={m} size={32} />
                      <span className="min-w-0">
                        <span className="block truncate text-[13.5px] font-medium text-foreground">{m.name}</span>
                        <span className="flex items-center gap-2">
                          <Meter value={m.speed} label={t.speed} t={t} />
                          <Coins level={m.costLevel} t={t} />
                        </span>
                      </span>
                    </li>
                  );
                })}
              </ul>
            </div>
          </div>
          {route && models[target] && (
            <div key={route.id} className="mpk-up mt-4 flex items-start gap-3 rounded-xl border border-border bg-background/60 p-3.5 sm:p-4" aria-live="polite">
              <span className="max-md:hidden">
                <Icon name="route" size={18} className="mt-0.5 text-primary" />
              </span>
              <span className="md:hidden">
                <ModelMark model={models[target]} size={34} />
              </span>
              <p className="text-pretty text-[13.5px] leading-relaxed text-muted-foreground">
                <span className="font-medium text-foreground">
                  {t.routedTo} {models[target].name}.
                </span>{' '}
                {route.reason}
              </p>
            </div>
          )}
        </div>
      </Reveal>
      <p className="mt-4 flex items-start gap-2 text-[12.5px] text-muted-foreground">
        <Icon name="info" size={14} className="mt-px shrink-0" />
        {t.override}
      </p>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* task-defaults                                                       */
/* ------------------------------------------------------------------ */

function TaskDefaults({ t, title, description, models, tasks = [], maxCost, className }: P) {
  const locale = useLocale();
  const [chosen, setChosen] = useState<Record<string, string>>(() => Object.fromEntries(tasks.map((k) => [k.id, k.model])));
  const [limit, setLimit] = useState(maxCost?.value ?? 0);
  const [saved, setSaved] = useState(false);
  const uid = useId();
  const dirty = tasks.some((k) => chosen[k.id] !== k.model) || limit !== (maxCost?.value ?? 0);
  useEffect(() => setSaved(false), [chosen, limit]);
  const nf = (n: number) => n.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US');
  const share = maxCost ? (limit - maxCost.min) / (maxCost.max - maxCost.min) : 0;

  return (
    <Stage title={title} description={description} className={className}>
      <Reveal effect="scale" delay={80}>
        <form
          onSubmit={(e) => {
            e.preventDefault();
            setSaved(true);
          }}
          className="overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)]"
        >
          <p className="border-b border-border px-4 py-3 text-[13px] font-medium text-foreground sm:px-5">{t.defaults}</p>
          <Stagger as="ul" step={60} className="divide-y divide-border">
            {tasks.map((k) => {
              const m = models.find((x) => x.id === chosen[k.id]);
              const fid = `${uid}-task-${k.id}`;
              return (
                <li key={k.id} className="grid grid-cols-[auto_minmax(0,1fr)] items-center gap-x-3 gap-y-2.5 px-4 py-3.5 sm:grid-cols-[auto_minmax(0,1fr)_15rem] sm:px-5">
                  <span aria-hidden className="grid size-10 place-items-center rounded-xl border border-border bg-background text-muted-foreground">
                    <Icon name={k.icon ?? 'sparkle'} size={18} />
                  </span>
                  <span className="min-w-0">
                    <label htmlFor={fid} className="block text-[14px] font-medium text-foreground">
                      {k.label}
                    </label>
                    {k.description && <span className="block text-[12.5px] text-muted-foreground">{k.description}</span>}
                  </span>
                  <span className="relative col-span-2 sm:col-span-1">
                    <span aria-hidden className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2">{m && <ModelMark model={m} size={26} />}</span>
                    <select
                      id={fid}
                      value={chosen[k.id]}
                      onChange={(e) => setChosen((c) => ({ ...c, [k.id]: e.target.value }))}
                      className="h-11 w-full appearance-none rounded-lg border border-border bg-background pl-10 pr-9 text-[13.5px] text-foreground transition-colors hover:border-border-strong sm:h-10"
                    >
                      {models.map((o) => (
                        <option key={o.id} value={o.id} disabled={!!o.locked}>
                          {o.locked ? `${o.name} · ${fill(t.locked, { plan: o.locked })}` : `${o.name} · ${o.cost}`}
                        </option>
                      ))}
                    </select>
                    <Icon name="chevrons-up-down" size={15} className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
                  </span>
                </li>
              );
            })}
          </Stagger>
          {maxCost && (
            <div className="border-t border-border px-4 py-4 sm:px-5">
              <div className="flex items-baseline justify-between gap-3">
                <label htmlFor={`${uid}-max-cost`} className="text-[14px] font-medium text-foreground">
                  {t.limit}
                </label>
                <span className="text-[15px] font-semibold tabular-nums text-foreground">
                  {nf(limit)} <span className="text-[13px] font-normal text-muted-foreground">{maxCost.unit}</span>
                </span>
              </div>
              <div className="relative mt-3 h-11">
                <div aria-hidden className="absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)]">
                  <div className="h-full rounded-full bg-primary" style={{ width: `calc(${round(share, 3)} * (100% - 20px) + 10px)` }} />
                </div>
                <input
                  id={`${uid}-max-cost`}
                  type="range"
                  min={maxCost.min}
                  max={maxCost.max}
                  value={limit}
                  onChange={(e) => setLimit(Number(e.target.value))}
                  className="relative h-11 w-full cursor-pointer appearance-none bg-transparent [&::-moz-range-thumb]:size-5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-primary [&::-moz-range-thumb]:bg-card [&::-webkit-slider-thumb]:size-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-primary [&::-webkit-slider-thumb]:bg-card [&::-webkit-slider-thumb]:shadow-[var(--shadow-sm)]"
                />
              </div>
              <div className="flex justify-between text-[11.5px] tabular-nums text-subtle-foreground">
                <span>{nf(maxCost.min)}</span>
                <span>{nf(maxCost.max)}</span>
              </div>
              <p className="mt-2 text-[12.5px] text-muted-foreground">{t.limitNote}</p>
            </div>
          )}
          <div className="flex items-center justify-end gap-2 border-t border-border bg-surface/60 px-4 py-3 sm:px-5">
            <span className="mr-auto text-[12.5px] text-muted-foreground" aria-live="polite">
              {saved && (
                <span className="mpk-up inline-flex items-center gap-1.5 text-success-foreground">
                  <Icon name="check-circle" size={15} />
                  {t.saved}
                </span>
              )}
            </span>
            <button
              type="button"
              disabled={!dirty}
              onClick={() => (setChosen(Object.fromEntries(tasks.map((k) => [k.id, k.model]))), setLimit(maxCost?.value ?? 0))}
              className="inline-flex h-11 items-center rounded-lg px-3.5 text-[13px] font-medium text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40 sm:h-9"
            >
              {t.reset}
            </button>
            <button type="submit" disabled={!dirty} className="inline-flex h-11 items-center rounded-lg bg-primary px-4 text-[13px] font-medium text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.18),var(--shadow-sm)] transition-colors hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))] disabled:bg-[color-mix(in_oklab,var(--foreground)_8%,var(--card))] disabled:text-subtle-foreground disabled:shadow-none sm:h-9">
              {t.save}
            </button>
          </div>
        </form>
      </Reveal>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */

/**
 * Choosing the model behind an AI feature: a compact dropdown, cards with
 * speed, depth, context and cost, a comparison table, the automatic choice
 * explained with a route from request to model, and defaults per task.
 */
export function ModelPicker({ variant = 'dropdown', labels, ...props }: ModelPickerProps) {
  const t = useTexts(TEXTS, labels);
  const p: P = { ...props, t };
  const body = (() => {
    switch (variant) {
      case 'cards':
        return <Cards {...p} />;
      case 'compare-table':
        return <CompareTable {...p} />;
      case 'auto-routing':
        return <AutoRouting {...p} />;
      case 'task-defaults':
        return <TaskDefaults {...p} />;
      default:
        return <Dropdown {...p} />;
    }
  })();
  return (
    <>
      <Style />
      {body}
    </>
  );
}

Frequent questions

How do I install Model picker?

With aiw add @ai-website/model-picker. The command puts components/ui/model-picker.tsx into your project, the code is yours from then on. It brings along icon, reveal and motion. It needs no further npm packages.

Which variants does Model picker have?

5 variants, Dropdown, Model cards, Comparison table, Auto routing and Defaults per task. You pick one with the variant prop, for example variant="cards".

Can I translate the texts of Model picker?

Yes. Model picker carries 37 texts of its own in English and German, such as “Model”, “Choose a model” and “Auto”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Model picker?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Model picker with aiw_components and install it with aiw_add @ai-website/model-picker. Name the variant in your prompt, for example variant="dropdown", and the assistant writes your content straight into the props.

Can I adjust Model picker without code?

Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching ModelPicker code to copy.

Often searched

Jump to

Copied to the clipboard