Skip to content
Star on GitHub
ConversationModel picker
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Model picker

Pick a model by speed, quality and cost, and how hard it thinks. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add model-picker
Model for this chat

Thinking

01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add model-picker

React

$npx designme add model-picker --react

The CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.

02

About

What it is for and what it does.

Model picker is the control a person uses to choose which model answers in a chat, and how hard it thinks first. Each model shows its speed, quality and cost as five-step meters with the real figures on hover, and the thinking effort sits underneath in a small dropdown. When the picked model can't take the request, the picker says why and offers a way out.

  • When people choose between models that trade speed, quality and cost, and should see those trade-offs before they pick.
  • When a model can think before it answers and people should set how much, with a note of what each level may spend.
  • When a model can be at capacity or unavailable, and you want to offer a wait or a close match instead of an error.
  • When the choice applies to one chat and people need a quiet confirmation of what is set.
03

Usage

Import it and drive it from your own state.

Import

import { ModelPicker } from '@/components/designme/model-picker/ModelPicker';

Example

The demo above as a React component, 64 lines you can copy.

'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useRef, useState } from 'react';
import { ModelPicker, type ModelOption } from './ModelPicker';
import { useStory, useWait } from '../lib/useScript';

/* ---------- Model picker: ask for Opus, find it at capacity, and take a close match on high thinking ---------- */
// the same three models and figures as Model compare: tokens a second, and a typical reply's price with no thinking
const BASE = [
  { id: 'opus', name: 'Opus 5.5', color: 'var(--dm-pl)', note: 'Deepest reasoning, slower', speed: 2, quality: 5, cost: 5, tps: 38, price: .021 },
  { id: 'sonnet', name: 'Sonnet 5.5', color: 'var(--dm-c1)', note: 'Balanced, the default for design work', speed: 4, quality: 4, cost: 2, tps: 74, price: .006 },
  { id: 'haiku', name: 'Haiku 5.5', color: 'var(--dm-pm)', note: 'Fastest, for quick edits', speed: 5, quality: 3, cost: 1, tps: 142, price: .001 }
];

export default function ModelPickerDemo() {
  const ref = useRef<HTMLDivElement>(null);
  const wait = useWait();
  const [value, setValue] = useState('sonnet');
  const [effort, setEffort] = useState('Medium');
  const [open, setOpen] = useState(true);
  const [opus, setOpus] = useState<{ status?: ModelOption['status']; text?: string }>({});
  // Opus is at capacity for this long, in seconds; 0 once you've waited it out
  const [busy, setBusy] = useState(20);
  const [offer, setOffer] = useState(false);
  const [note, setNote] = useState<string>();

  const set = (id: string, eff = effort) => { setValue(id); setOffer(false); setNote(`Set for this chat: ${BASE.find(m => m.id === id)!.name}, ${eff.toLowerCase()} thinking`); };
  // picking a model checks it can take the request first; a busy one offers a wait, or a close match that starts now
  const pick = async (id: string) => {
    if (id !== 'opus' || !busy) return set(id);
    setNote(undefined); setOpus({ status: 'checking', text: 'Checking it can take this chat' });
    await wait(1000);
    setOpus({ status: 'busy', text: `At capacity, about ${busy}s wait` }); setOffer(true);
  };
  const waitForOpus = async () => {
    setOffer(false);
    for (let w = busy; w > 0; w--) { setOpus({ status: 'busy', text: `Starts in ${w}s` }); await wait(1000); }
    setBusy(0); setOpus({}); set('opus');
  };

  const models: ModelOption[] = BASE.map(x => ({
    id: x.id, name: x.name, color: x.color, note: x.note, speed: x.speed, quality: x.quality, cost: x.cost,
    speedDetail: `${x.tps} tokens a second`, qualityDetail: `Quality ${x.quality} of 5`, costDetail: `$${x.price.toFixed(3)} a reply`,
    ...(x.id === 'opus' ? { status: opus.status, statusText: opus.text } : {})
  }));

  // the preview's story: the person asks for Opus, reads why it can't start, and takes the close match
  useStory(ref, async ({ wait, press }) => {
    await wait(5500); press('.dm-mpk-o');
    await wait(1000 + 2500 + 900); press('.dm-mpk-sga .dm-primary');
  });

  return (
    <div ref={ref} style={{ width: '100%' }}>
      <ModelPicker models={models} value={value} onValueChange={id => void pick(id)} effort={effort} onEffortChange={e => { setEffort(e); setNote(undefined); }}
        open={open} onOpenChange={setOpen} note={note}
        suggestion={offer ? {
          text: `Opus 5.5 is at capacity for about ${busy}s. Sonnet 5.5 on high thinking comes close on long reviews and starts now.`,
          actions: [{ label: 'Wait for Opus', onClick: () => void waitForOpus() }, { label: 'Use Sonnet on high', onClick: () => { setEffort('High'); set('sonnet', 'High'); } }]
        } : undefined} />
    </div>
  );
}
04

Props

Everything ModelPicker takes.

PropTypeWhat it does
modelsrequiredModelOption[]
valuestringThe picked model's id. Leave it out to let the list hold its own
defaultValuestring
onValueChange(id: string) => voidCalled when a row is picked, busy or not, so you can check the model first
effortsThinkingEffort[]Defaults to Off, Low, Medium and High
effortstring
defaultEffortstring
onEffortChange(effort: string) => void
suggestion{ text: ReactNode; actions: { label: string; onClick: () => void }[] }A way out when the picked model can't take the request: a short text and its buttons, the last one filled
notestringThe confirmation under the picker, like "Set for this chat: Sonnet 5.5, high thinking". Shown with a green check
labelReactNodeThe label beside the trigger. Defaults to "Model for this chat"
openbooleanWhether the list is open. Leave it out to let the trigger and Escape open and close it
defaultOpenboolean
onOpenChange(open: boolean) => void
classNamestring
05

Code

The full source, as the CLI installs it.

ModelPicker.tsxReact · 155 lines
'use client';
import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useControllable } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { enter, Fold, Spin, Swap, Tip } from '../lib/motion';
import { usePopover } from '../lib/popover';
import '../designme.css';
import './model-picker.css';

/** One model in the list */
export interface ModelOption {
  id: string;
  /** Like "Sonnet 5.5" */
  name: string;
  /** The letter in its coloured tile. Defaults to the first letter of the name */
  mark?: string;
  /** The tile's colour, like "var(--dm-c1)" */
  color?: string;
  /** The quiet line under the name, like "Balanced, the default for design work" */
  note?: string;
  /** Speed, quality and cost, each from 1 to 5. For cost, 5 is the most expensive */
  speed: number;
  quality: number;
  cost: number;
  /** What each meter shows on hover, like "74 tokens a second", "Quality 4 of 5", "$0.006 a reply" */
  speedDetail?: string;
  qualityDetail?: string;
  costDetail?: string;
  /** checking spins beside the name; busy turns the row amber. Both replace the note with `statusText` */
  status?: 'ready' | 'checking' | 'busy';
  /** Like "At capacity, about 20s wait" */
  statusText?: string;
}

/** A thinking effort: its label, and the line shown under "Thinking" when it is picked */
export interface ThinkingEffort {
  label: string;
  detail?: string;
}

export interface ModelPickerProps {
  models: ModelOption[];
  /** The picked model's id. Leave it out to let the list hold its own */
  value?: string;
  defaultValue?: string;
  /** Called when a row is picked, busy or not, so you can check the model first */
  onValueChange?: (id: string) => void;
  /** Defaults to Off, Low, Medium and High */
  efforts?: ThinkingEffort[];
  effort?: string;
  defaultEffort?: string;
  onEffortChange?: (effort: string) => void;
  /** A way out when the picked model can't take the request: a short text and its buttons, the last one filled */
  suggestion?: { text: ReactNode; actions: { label: string; onClick: () => void }[] };
  /** The confirmation under the picker, like "Set for this chat: Sonnet 5.5, high thinking". Shown with a green check */
  note?: string;
  /** The label beside the trigger. Defaults to "Model for this chat" */
  label?: ReactNode;
  /** Whether the list is open. Leave it out to let the trigger and Escape open and close it */
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  className?: string;
}

const EFFORTS: ThinkingEffort[] = [
  { label: 'Off', detail: 'Answers straight away' }, { label: 'Low', detail: 'Up to 2k thinking tokens' },
  { label: 'Medium', detail: 'Up to 8k thinking tokens' }, { label: 'High', detail: 'Up to 24k thinking tokens' }
];

const Meter = ({ value, tip }: { value: number; tip?: string }) => {
  const bars = <span className="dm-mpk-m" aria-hidden={tip ? undefined : true}>{[1, 2, 3, 4, 5].map(k => <i key={k} className={k <= value ? 'dm-on' : undefined} />)}</span>;
  return tip ? <Tip label={tip}>{bars}</Tip> : bars;
};

/** Choose the model for a chat by speed, quality and cost, and how hard it thinks, with what the next reply will take. */
export function ModelPicker({
  models, value, defaultValue, onValueChange, efforts = EFFORTS, effort, defaultEffort, onEffortChange, suggestion, note,
  label = 'Model for this chat', open, defaultOpen = true, onOpenChange, className
}: ModelPickerProps) {
  const [cur, setCur] = useControllable(value, defaultValue ?? models[0]?.id ?? '', onValueChange);
  const [eff, setEff] = useControllable(effort, defaultEffort ?? efforts[Math.min(2, efforts.length - 1)]?.label ?? '', onEffortChange);
  // thinking effort is a small dropdown under the list
  const effMenu = usePopover({ align: 'end' });
  const [isOpen, setOpen] = useControllable(open, defaultOpen, onOpenChange);
  const picked = models.find(m => m.id === cur) ?? models[0];
  const rows = useRef<(HTMLButtonElement | null)[]>([]);
  const trigger = useRef<HTMLButtonElement>(null);
  // the confirmation enters each time it changes
  const [noteKey, setNoteKey] = useState(0);
  useEffect(() => { if (note) setNoteKey(k => k + 1); }, [note]);

  const move = (e: KeyboardEvent) => {
    const k = ({ ArrowDown: 1, ArrowRight: 1, ArrowUp: -1, ArrowLeft: -1 } as Record<string, number>)[e.key]; if (!k) return;
    e.preventDefault();
    const i = (rows.current.findIndex(b => b === document.activeElement) + k + models.length) % models.length;
    rows.current[i]?.focus(); setCur(models[i].id);
  };

  return (
    <div data-dm="" className={cx('dm-mpk', className)} onKeyDown={e => { if (e.key === 'Escape' && isOpen) { setOpen(false); trigger.current?.focus(); } }}>
      <div className="dm-mpk-top">
        <span className="dm-muted dm-mpk-lb">{label}</span>
        <button ref={trigger} type="button" className="dm-mpk-tr" aria-haspopup="true" aria-expanded={isOpen} onClick={() => setOpen(!isOpen)}>
          <span className="dm-mpk-mk" style={{ ['--dm-mc' as string]: picked?.color }}>{picked?.mark ?? picked?.name[0]}</span>
          <Swap className="dm-mpk-nm">{picked?.name ?? ''}</Swap>
          <Swap className="dm-mpk-ef dm-muted">{eff}</Swap>
          <Icon name="chev-d" size="xs" />
        </button>
      </div>
      <Fold open={isOpen} className="dm-mpk-fold">
        <div className="dm-mpk-p dm-panel">
          <div className="dm-mpk-hd" aria-hidden="true"><span /><span /><span>Speed</span><span>Quality</span><span>Cost</span></div>
          <div className="dm-mpk-list" role="radiogroup" aria-label="Model" onKeyDown={move}>
            {models.map((m, i) => (
              <button key={m.id} ref={el => { rows.current[i] = el; }} type="button" role="radio" aria-checked={m.id === picked?.id} tabIndex={m.id === picked?.id ? 0 : -1}
                className={cx('dm-mpk-o', m.status === 'busy' && 'dm-busy')} onClick={() => setCur(m.id)}>
                <span className="dm-mpk-mk" style={{ ['--dm-mc' as string]: m.color }}>{m.mark ?? m.name[0]}</span>
                <span className="dm-t">
                  <b>{m.name}<span className="dm-ck">{m.status === 'checking' ? <Spin /> : <Icon name="check" size="xs" />}</span></b>
                  <Swap className="dm-d">{(m.status && m.status !== 'ready' && m.statusText) || m.note || ''}</Swap>
                </span>
                <Meter value={m.speed} tip={m.speedDetail} /><Meter value={m.quality} tip={m.qualityDetail} /><Meter value={m.cost} tip={m.costDetail} />
              </button>
            ))}
          </div>
          <Fold open={!!suggestion}>
            <div className="dm-mpk-sg" aria-live="polite">
              <p className="dm-mpk-sgt">{suggestion?.text}</p>
              <div className="dm-mpk-sga">
                {suggestion?.actions.map((a, i, all) => <button key={a.label} type="button" className={cx('dm-pill dm-sm', i === all.length - 1 ? 'dm-primary' : 'dm-ghost')} onClick={a.onClick}>{a.label}</button>)}
              </div>
            </div>
          </Fold>
          <div className="dm-mpk-th">
            <div className="dm-mpk-thh"><b><Icon name="brain" size="xs" />Thinking</b><Swap className="dm-muted dm-mpk-thd">{efforts.find(e => e.label === eff)?.detail ?? ''}</Swap></div>
            <button {...effMenu.trigger} type="button" className="dm-mpk-eb" aria-label="Thinking effort"><Swap className="dm-mpk-ev">{eff}</Swap><Icon name="chev-d" size="xs" /></button>
          </div>
        </div>
      </Fold>
      <div {...effMenu.pop} className={cx(effMenu.pop.className, 'dm-mpk-pop')} role="menu" aria-label="Thinking effort">
        {efforts.map(e => (
          <button key={e.label} type="button" className="dm-mi" role="menuitemradio" aria-checked={e.label === eff} onClick={() => { setEff(e.label); effMenu.closeSoon(); }}>
            <span>{e.label}{e.detail && <span className="dm-d">{e.detail}</span>}</span><span className="dm-ck"><Icon name="check" size="xs" /></span>
          </button>
        ))}
      </div>
      <p className="dm-mpk-note dm-muted" aria-live="polite">
        {note && <><Icon name="ok" size="xs" /><span key={noteKey} {...enter()}>{note}</span></>}
      </p>
    </div>
  );
}
model-picker.cssReact · 60 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Model picker ---------- */
/* a trigger that names the model and how hard it thinks, over the open list: speed, quality and cost as five-step meters
   with the real numbers on hover, and the thinking effort under them as a small dropdown */
.dm-mpk{position:relative;display:flex;flex-direction:column;gap:var(--dm-s-2);width:100%;max-width:420px;margin:0 auto}
.dm-mpk-top{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:0 2px}
.dm-mpk-lb{font-size:12.5px}
.dm-mpk-tr{display:inline-flex;align-items:center;gap:7px;height:32px;padding:0 10px 0 6px;border-radius:var(--dm-r-pill);background:var(--dm-surface);box-shadow:var(--dm-shadow);font-size:13px;font-weight:500;white-space:nowrap;transition:box-shadow .16s}
@media (hover:hover) and (pointer:fine){.dm-mpk-tr:hover{box-shadow:var(--dm-shadow-pop)}}
.dm-mpk-tr>svg{color:var(--dm-muted);transition:transform .32s var(--dm-ease)}
.dm-mpk-tr[aria-expanded="true"]>svg{transform:rotate(180deg)}
.dm-mpk-tr:focus-visible{outline:2px solid var(--dm-focus);outline-offset:2px}
.dm-mpk-ef{font-weight:400;font-size:12.5px}
/* each model's mark carries the letter colour that reads on it (--mt): on the accent fill that is --on-accent */
.dm-mpk-mk{width:20px;height:20px;border-radius:6px;display:grid;place-items:center;font-size:11px;font-weight:500;color:var(--dm-mt,var(--dm-on-ink));background:var(--dm-mc,var(--dm-fg2));flex:none;transition:background .32s}
.dm-mpk-p{padding:6px;display:flex;flex-direction:column}
.dm-mpk-hd,.dm-mpk-o{display:grid;grid-template-columns:20px minmax(0,1fr) repeat(3,34px);align-items:center;gap:10px}
.dm-mpk-hd{padding:var(--dm-s-1) 10px 2px;font-size:11px;color:var(--dm-muted)}
.dm-mpk-hd span{text-align:center}
.dm-mpk-o{width:100%;padding:var(--dm-s-2) 10px;border-radius:var(--dm-r-in);text-align:left;font-size:13px;transition:background .16s}
@media (hover:hover) and (pointer:fine){.dm-mpk-o:hover{background:var(--dm-row)}}
.dm-mpk-o[aria-checked="true"]{background:var(--dm-sel)}
.dm-mpk-o[aria-checked="true"] .dm-d{color:var(--dm-fg2)}
.dm-mpk-o:focus-visible{outline:2px solid var(--dm-focus);outline-offset:-2px}
.dm-mpk-o .dm-t{display:flex;flex-direction:column;min-width:0}
.dm-mpk-o .dm-t b{font-weight:500;display:flex;align-items:center;gap:6px}
.dm-mpk-o .dm-ck{display:grid;color:var(--dm-accent-fg,var(--dm-accent));opacity:0;transform:scale(.6);transition:opacity .16s,transform .32s var(--dm-ease)}
.dm-mpk-o[aria-checked="true"] .dm-ck,.dm-mpk-o .dm-ck:has(.dm-spin){opacity:1;transform:none}
.dm-mpk-o .dm-ck .dm-spin{width:10px;height:10px;color:var(--dm-muted)}
.dm-mpk-o .dm-d{font-size:12px;color:var(--dm-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-mpk-o.dm-busy .dm-d{color:var(--dm-warn)}
.dm-mpk-o.dm-busy .dm-mpk-mk{background:var(--dm-warn);color:var(--dm-on-ink)}
.dm-mpk-m{display:flex;gap:2px;height:8px}
.dm-mpk-m i{flex:1;border-radius:2px;background:var(--dm-rail);transition:background .32s}
.dm-mpk-m i.dm-on{background:var(--dm-fg2)}
.dm-mpk-o[aria-checked="true"]:not(.dm-busy) .dm-mpk-m i.dm-on{background:var(--dm-accent-mark,var(--dm-accent))}
/* the way out when a model can't take the request: wait for it, or a close match that starts now */
.dm-mpk-sg{margin:var(--dm-s-1) var(--dm-s-1) 2px;padding:10px var(--dm-s-3);border-radius:var(--dm-r-in);background:var(--dm-warn-bg);display:flex;flex-direction:column;gap:var(--dm-s-2)}
.dm-mpk-sgt{margin:0;font-size:12.5px;line-height:1.45}
.dm-mpk-sga{display:flex;justify-content:flex-end;flex-wrap:wrap;gap:6px}
/* on the amber notice the secondary button is white, not grey */
.dm-mpk-sg .dm-pill.dm-ghost{background:var(--dm-surface);color:var(--dm-fg);box-shadow:var(--dm-shadow)}
@media (hover:hover) and (pointer:fine){.dm-mpk-sg .dm-pill.dm-ghost:hover{box-shadow:var(--dm-shadow-pop)}}
.dm-mpk-th{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:var(--dm-s-2) var(--dm-s-3);padding:var(--dm-s-3) 10px 6px;margin:6px var(--dm-s-1) 0;border-top:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}
.dm-mpk-thh{display:flex;flex-direction:column;min-width:0;font-size:13px}
.dm-mpk-thh b{font-weight:500;display:flex;align-items:center;gap:6px}
.dm-mpk-thh b svg{color:var(--dm-muted)}
.dm-mpk-thd{font-size:12px}
.dm-mpk-eb{display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 var(--dm-s-2) 0 10px;border-radius:var(--dm-r-pill);background:var(--dm-chip);font-size:13px;transition:background .16s}
.dm-mpk-eb[aria-expanded="true"]{background:var(--dm-hover)}
@media (hover:hover) and (pointer:fine){.dm-mpk-eb:hover{background:var(--dm-hover)}}
.dm-mpk-eb>svg{color:var(--dm-muted);transition:transform .32s var(--dm-ease)}
.dm-mpk-eb[aria-expanded="true"]>svg{transform:rotate(180deg)}
.dm-mpk-pop{min-width:220px}
.dm-mpk-note{margin:0;padding:0 2px;font-size:12.5px;min-height:20px;display:flex;align-items:center;gap:6px}
.dm-mpk-note svg{color:var(--dm-ok);flex:none}
/* phones: the line under each name wraps, and the meters keep room for their labels */
/* on a phone the meters close up so a model's line keeps two or three words to a row */
@media (max-width:480px){.dm-mpk-hd,.dm-mpk-o{grid-template-columns:20px minmax(0,1fr) repeat(3,34px);gap:6px}.dm-mpk-hd{font-size:10.5px;padding-inline:6px}.dm-mpk-o{padding-inline:6px}.dm-mpk-o .dm-d{white-space:normal}}
model-picker.htmlHTML · 24 lines
<div class="card" data-c="model-picker">
  <div class="stage-in">
    <div class="mpk">
      <div class="mpk-top"><span class="muted mpk-lb">Model for this chat</span><button class="mpk-tr" aria-haspopup="true" aria-expanded="true"><span class="mpk-mk"></span><span class="mpk-nm"></span><span class="mpk-ef muted"></span><svg class="i xs"><use href="#i-chev-d"/></svg></button></div>
      <div class="fold mpk-fold"><div><div class="mpk-p panel">
        <div class="mpk-hd" aria-hidden="true"><span></span><span></span><span>Speed</span><span>Quality</span><span>Cost</span></div>
        <div class="mpk-list" role="radiogroup" aria-label="Model"></div>
        <div class="fold shut mpk-sgf"><div><div class="mpk-sg" aria-live="polite"><p class="mpk-sgt"></p><div class="mpk-sga"><button class="pill ghost sm mpk-wait">Wait for Opus</button><button class="pill primary sm mpk-use">Use Sonnet on high</button></div></div></div></div>
        <div class="mpk-th">
          <div class="mpk-thh"><b><svg class="i xs"><use href="#i-brain"/></svg>Thinking</b><span class="muted mpk-thd"></span></div>
          <button class="mpk-eb" aria-haspopup="menu" aria-expanded="false" aria-label="Thinking effort" data-pop=".mpk-pop" data-pop-align="end"><span class="mpk-ev">Medium</span><svg class="i xs"><use href="#i-chev-d"/></svg></button>
        </div>
      </div></div></div>
      <div class="pop mpk-pop" role="menu" aria-label="Thinking effort">
        <button class="mi" role="menuitemradio" aria-checked="false" data-close data-v="0"><span>Off<span class="d">Answers straight away</span></span><span class="ck"><svg class="i xs"><use href="#i-check"/></svg></span></button>
        <button class="mi" role="menuitemradio" aria-checked="false" data-close data-v="1"><span>Low<span class="d">Up to 2k thinking tokens</span></span><span class="ck"><svg class="i xs"><use href="#i-check"/></svg></span></button>
        <button class="mi" role="menuitemradio" aria-checked="true" data-close data-v="2"><span>Medium<span class="d">Up to 8k thinking tokens</span></span><span class="ck"><svg class="i xs"><use href="#i-check"/></svg></span></button>
        <button class="mi" role="menuitemradio" aria-checked="false" data-close data-v="3"><span>High<span class="d">Up to 24k thinking tokens</span></span><span class="ck"><svg class="i xs"><use href="#i-check"/></svg></span></button>
      </div>
      <p class="mpk-note muted" aria-live="polite"></p>
    </div>
  </div>
</div>
model-picker.cssHTML · 59 lines
/* ---------- Model picker ---------- */
/* a trigger that names the model and how hard it thinks, over the open list: speed, quality and cost as five-step meters
   with the real numbers on hover, and the thinking effort under them as a small dropdown */
.mpk{position:relative;display:flex;flex-direction:column;gap:var(--s-2);width:100%;max-width:420px;margin:0 auto}
.mpk-top{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:0 2px}
.mpk-lb{font-size:12.5px}
.mpk-tr{display:inline-flex;align-items:center;gap:7px;height:32px;padding:0 10px 0 6px;border-radius:var(--r-pill);background:var(--surface);box-shadow:var(--shadow);font-size:13px;font-weight:500;white-space:nowrap;transition:box-shadow .16s}
@media (hover:hover) and (pointer:fine){.mpk-tr:hover{box-shadow:var(--shadow-pop)}}
.mpk-tr>svg{color:var(--muted);transition:transform .32s var(--ease)}
.mpk-tr[aria-expanded="true"]>svg{transform:rotate(180deg)}
.mpk-tr:focus-visible{outline:2px solid var(--focus);outline-offset:2px}
.mpk-ef{font-weight:400;font-size:12.5px}
/* each model's mark carries the letter colour that reads on it (--mt): on the accent fill that is --on-accent */
.mpk-mk{width:20px;height:20px;border-radius:6px;display:grid;place-items:center;font-size:11px;font-weight:500;color:var(--mt,var(--on-ink));background:var(--mc,var(--fg2));flex:none;transition:background .32s}
.mpk-p{padding:6px;display:flex;flex-direction:column}
.mpk-hd,.mpk-o{display:grid;grid-template-columns:20px minmax(0,1fr) repeat(3,34px);align-items:center;gap:10px}
.mpk-hd{padding:var(--s-1) 10px 2px;font-size:11px;color:var(--muted)}
.mpk-hd span{text-align:center}
.mpk-o{width:100%;padding:var(--s-2) 10px;border-radius:var(--r-in);text-align:left;font-size:13px;transition:background .16s}
@media (hover:hover) and (pointer:fine){.mpk-o:hover{background:var(--row)}}
.mpk-o[aria-checked="true"]{background:var(--sel)}
.mpk-o[aria-checked="true"] .d{color:var(--fg2)}
.mpk-o:focus-visible{outline:2px solid var(--focus);outline-offset:-2px}
.mpk-o .t{display:flex;flex-direction:column;min-width:0}
.mpk-o .t b{font-weight:500;display:flex;align-items:center;gap:6px}
.mpk-o .ck{display:grid;color:var(--accent-fg,var(--accent));opacity:0;transform:scale(.6);transition:opacity .16s,transform .32s var(--ease)}
.mpk-o[aria-checked="true"] .ck,.mpk-o .ck:has(.spin){opacity:1;transform:none}
.mpk-o .ck .spin{width:10px;height:10px;color:var(--muted)}
.mpk-o .d{font-size:12px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.mpk-o.busy .d{color:var(--warn)}
.mpk-o.busy .mpk-mk{background:var(--warn);color:var(--on-ink)}
.mpk-m{display:flex;gap:2px;height:8px}
.mpk-m i{flex:1;border-radius:2px;background:var(--rail);transition:background .32s}
.mpk-m i.on{background:var(--fg2)}
.mpk-o[aria-checked="true"]:not(.busy) .mpk-m i.on{background:var(--accent-mark,var(--accent))}
/* the way out when a model can't take the request: wait for it, or a close match that starts now */
.mpk-sg{margin:var(--s-1) var(--s-1) 2px;padding:10px var(--s-3);border-radius:var(--r-in);background:var(--warn-bg);display:flex;flex-direction:column;gap:var(--s-2)}
.mpk-sgt{margin:0;font-size:12.5px;line-height:1.45}
.mpk-sga{display:flex;justify-content:flex-end;flex-wrap:wrap;gap:6px}
/* on the amber notice the secondary button is white, not grey */
.mpk-sg .pill.ghost{background:var(--surface);color:var(--fg);box-shadow:var(--shadow)}
@media (hover:hover) and (pointer:fine){.mpk-sg .pill.ghost:hover{box-shadow:var(--shadow-pop)}}
.mpk-th{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:var(--s-2) var(--s-3);padding:var(--s-3) 10px 6px;margin:6px var(--s-1) 0;border-top:1px solid color-mix(in srgb,var(--line) 50%,transparent)}
.mpk-thh{display:flex;flex-direction:column;min-width:0;font-size:13px}
.mpk-thh b{font-weight:500;display:flex;align-items:center;gap:6px}
.mpk-thh b svg{color:var(--muted)}
.mpk-thd{font-size:12px}
.mpk-eb{display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 var(--s-2) 0 10px;border-radius:var(--r-pill);background:var(--chip);font-size:13px;transition:background .16s}
.mpk-eb[aria-expanded="true"]{background:var(--hover)}
@media (hover:hover) and (pointer:fine){.mpk-eb:hover{background:var(--hover)}}
.mpk-eb>svg{color:var(--muted);transition:transform .32s var(--ease)}
.mpk-eb[aria-expanded="true"]>svg{transform:rotate(180deg)}
.mpk-pop{min-width:220px}
.mpk-note{margin:0;padding:0 2px;font-size:12.5px;min-height:20px;display:flex;align-items:center;gap:6px}
.mpk-note svg{color:var(--ok);flex:none}
/* phones: the line under each name wraps, and the meters keep room for their labels */
/* on a phone the meters close up so a model's line keeps two or three words to a row */
@media (max-width:480px){.mpk-hd,.mpk-o{grid-template-columns:20px minmax(0,1fr) repeat(3,34px);gap:6px}.mpk-hd{font-size:10.5px;padding-inline:6px}.mpk-o{padding-inline:6px}.mpk-o .d{white-space:normal}}
model-picker.jsHTML · 76 lines
DM.def('model-picker', (r, c) => {
  r.innerHTML = r.innerHTML.replace(/\{chev\}/g, I('chev-d','xs')).replace('{brain}', I('brain','xs')).replace(/\{chk\}/g, I('check','xs'));
  // speed, quality and cost out of five, and the numbers behind them: tokens a second, and a typical reply's seconds and
  // price with no thinking (the same three models and figures as Model compare)
  const M = [
    { n:'Opus 5.5', m:'O', c:'var(--pl)', t:'var(--on-ink)', d:'Deepest reasoning, slower', s:2, q:5, p:5, tps:38, sec:9, cost:.021 },
    { n:'Sonnet 5.5', m:'S', c:'var(--accent)', t:'var(--on-accent)', d:'Balanced, the default for design work', s:4, q:4, p:2, tps:74, sec:4, cost:.006 },
    { n:'Haiku 5.5', m:'H', c:'var(--pm)', t:'var(--on-ink)', d:'Fastest, for quick edits', s:5, q:3, p:1, tps:142, sec:1.5, cost:.001 }
  ];
  // thinking effort, and what it may spend
  const E = [['Off', 'Answers straight away'], ['Low', 'Up to 2k thinking tokens'], ['Medium', 'Up to 8k thinking tokens'], ['High', 'Up to 24k thinking tokens']];
  const list = $('.mpk-list', r), tr = $('.mpk-tr', r), fold = $('.mpk-fold', r), sgf = $('.mpk-sgf', r), sgt = $('.mpk-sgt', r), note = $('.mpk-note', r), thd = $('.mpk-thd', r), ev = $('.mpk-ev', r), items = $$('.mpk-pop .mi', r);
  const meter = (v, tip) => `<span class="mpk-m" data-tip="${tip}">${[1, 2, 3, 4, 5].map(k => `<i${k <= v ? ' class="on"' : ''}></i>`).join('')}</span>`;
  list.innerHTML = M.map((x, i) => `<button class="mpk-o" role="radio" aria-checked="false" tabindex="-1" data-i="${i}"><span class="mpk-mk" style="--mc:${x.c};--mt:${x.t}">${x.m}</span><span class="t"><b>${x.n}<span class="ck">${I('check','xs')}</span></b><span class="d">${x.d}</span></span>${meter(x.s, x.tps + ' tokens a second')}${meter(x.q, 'Quality ' + x.q + ' of 5')}${meter(x.p, '$' + x.cost.toFixed(3) + ' a reply')}</button>`).join('');
  const rows = $$('.mpk-o', list);
  // Opus is at capacity when the demo starts (busy is its wait in seconds), and free again once you've waited it out
  let cur = 1, eff = 2, busy = 20, waiting = false;
  function paint(){
    rows.forEach((b, i) => { b.setAttribute('aria-checked', i === cur); b.tabIndex = i === cur ? 0 : -1; });
    const mk = $('.mpk-mk', tr); mk.style.setProperty('--mc', M[cur].c); mk.style.setProperty('--mt', M[cur].t); mk.textContent = M[cur].m;
    swap($('.mpk-nm', tr), M[cur].n); swap($('.mpk-ef', tr), E[eff][0]); swap(thd, E[eff][1]); swap(ev, E[eff][0]);
    items.forEach((m, i) => m.setAttribute('aria-checked', i === eff));
  }
  const say = t => { note.innerHTML = t ? `${I('ok','xs')}<span>${t}</span>` : ''; if (t) enter(note); };
  const sg = on => { sgf.classList.toggle('shut', !on); sgf.inert = !on; };
  sg(false);
  // thinking effort: a small dropdown of the four levels, each with what it may spend
  items.forEach((m, i) => m.addEventListener('click', () => { eff = i; paint(); say(''); }));
  function choose(i){ cur = i; sg(false); paint(); say(`Set for this chat: ${M[cur].n}, ${E[eff][0].toLowerCase()} thinking`); }
  // picking a model checks it can take the request first; a busy one offers a wait, or a close match that starts now
  function pick(i){
    if (i !== 0 || !busy) return choose(i);
    if (waiting) return;
    const row = rows[0], d = $('.d', row), ck = $('.ck', row);
    ck.innerHTML = '<span class="spin"></span>'; swap(d, 'Checking it can take this chat'); say('');
    c.run(async () => {
      await c.wait(1000);
      ck.innerHTML = I('check','xs'); row.classList.add('busy'); swap(d, `At capacity, about ${busy}s wait`);
      sgt.textContent = `Opus 5.5 is at capacity for about ${busy}s. Sonnet 5.5 on high thinking comes close on long reviews and starts now.`; sg(true);
    });
  }
  rows.forEach((b, i) => b.onclick = () => pick(i));
  // arrow keys move through the models, as in any radio group
  c.on(list, 'keydown', e => {
    const k = { ArrowDown:1, ArrowRight:1, ArrowUp:-1, ArrowLeft:-1 }[e.key]; if (!k) return;
    e.preventDefault(); const i = (rows.indexOf(document.activeElement) + k + rows.length) % rows.length; rows[i].focus({ preventScroll:true }); pick(i);
  });
  $('.mpk-use', r).onclick = () => { eff = 3; choose(1); };
  $('.mpk-wait', r).onclick = () => c.run(async () => {
    sg(false); waiting = true; const d = $('.d', rows[0]);
    for (let w = busy; w > 0; w--){ d.textContent = `Starts in ${w}s`; await c.wait(1000); }
    busy = 0; waiting = false; rows[0].classList.remove('busy'); swap(d, M[0].d); choose(0);
  });
  // the trigger opens and closes the list; Escape closes it and gives the trigger the focus back
  // the trigger sits centred on its own, and glides up as the list folds open; it aims for the open list's own height,
  // not the one it is easing through, so the two move as one
  const mpk = $('.mpk', r), effp = $('.mpk-pop', r);
  const centred = centre(c, mpk, () => { const shut = fold.classList.contains('shut'), sgx = sgf.classList.contains('shut') ? 0 : sgf.firstElementChild.scrollHeight;
    let h = mpk.offsetHeight - fold.offsetHeight + (shut ? 0 : fold.firstElementChild.scrollHeight - sgf.offsetHeight + sgx);
    return effp.classList.contains('open') ? Math.max(h, effp.offsetTop + effp.offsetHeight) : h; }, [fold.firstElementChild, effp]);
  const open = v => { fold.classList.toggle('shut', !v); fold.inert = !v; tr.setAttribute('aria-expanded', v); centred(); };
  tr.onclick = () => open(tr.getAttribute('aria-expanded') !== 'true');
  c.on(r, 'keydown', e => { if (e.key === 'Escape' && tr.getAttribute('aria-expanded') === 'true'){ open(false); tr.focus({ preventScroll:true }); } });
  paint();
  // the preview's story: compare the meters, ask for Opus, find it busy, and take the close match on high thinking
  c.story(async s => {
    await s.beat(3);
    await s.point($$('.mpk-m', rows[2])[2]); await s.beat(3);
    await s.point($$('.mpk-m', rows[0])[1]); await s.beat(3);
    await s.tap(rows[0]); s.away();
    await s.until(() => !sgf.classList.contains('shut')); await s.beat(5);
    await s.tap($('.mpk-use', r)); s.away();
    await s.beat(4);
  });
});
06

Questions

What people ask about it.

Does it check whether a model is available?
No. It calls onValueChange with the id of any row that is picked, busy or not. Check the model in your own code, set its status to checking and then busy or ready, and pass a suggestion if it can't start.
Where do the meters come from?
From you. Give each model speed, quality and cost from 1 to 5, and the figures behind them in the detail props, like "74 tokens a second" or "$0.006 a reply".
Can I use my own effort levels?
Yes. Pass efforts as a list of { label, detail }. The picked one is effort or defaultEffort, which defaults to the third in the list, or the last if there are fewer.
Can it start closed?
Yes. It opens by default; set defaultOpen={false}, or control it with open and onOpenChange.
07

More in Conversation

Everything for asking, answering and replying, by text or by voice.

Live answerAnswers that stream in with numbered sources and next steps.
ComposerMessage input with attachments, what to work on, a mode and dictation.
Thread panelSwitch between agent threads and reply in place.
Artifact cardA file the agent made, with its versions, Open, Copy and Download.
Rich text editorA minimal editor with an inline AI menu that streams a suggestion to accept or discard.
Tool callEach tool the agent calls, with its input and output.
Voice captureTalk to the agent, then fix what it misheard before sending.
Voice orbA hands-free voice chat, with an orb that shows who is talking.
All 15 in Conversation →