ConversationModel picker
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callModel 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.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add model-pickerReact
npx designme add model-picker --reactThe 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.
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.
- Each model row has a coloured tile, a name, a one-line
note, and three five-step meters forspeed,qualityandcost.speedDetail,qualityDetailandcostDetailshow on hover, like "74 tokens a second". - The picked row is tinted and checked, and its meters turn the accent colour. The trigger above the list shows the picked model and effort, and both crossfade when they change.
- A model's
statuscan bechecking, which spins beside its name, orbusy, which turns its tile and line amber.statusTextreplaces the note, like "At capacity, about 20s wait". suggestionopens a short message under the list with its buttons, the last one filled, like Wait for Opus and Use Sonnet on high. It opens and closes by its height.- The thinking effort is a small dropdown under the list.
effortsdefaults to Off, Low, Medium and High, each with a line shown in the menu and under "Thinking", like "Up to 8k thinking tokens". noteadds a confirmation with a green check under the picker. The trigger opens and closes the list.
- The models form a radio group labelled "Model". Each row is a button with
role="radio"andaria-checked, and only the picked one is in the tab order. - Arrow keys move to the next or previous model and pick it, wrapping at the ends.
- The trigger has
aria-expanded. Escape closes the list and moves the focus back to the trigger. - The thinking effort is a menu button with
aria-haspopupandaria-expanded; its levels aremenuitemradioitems witharia-checked. The suggestion and the confirmation are announced as they change. - From the keyboard, Enter, Space or the down arrow opens a menu with the focus on its picked item, or its first (the up arrow, on its last); the arrow keys move through it and wrap, Home and End jump to the ends, Tab closes it and moves on, and closing it gives the focus back to its button.
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>
);
}
Props
Everything ModelPicker takes.
| Prop | Type | What it does |
|---|---|---|
modelsrequired | ModelOption[] | |
value | string | The picked model's id. Leave it out to let the list hold its own |
defaultValue | string | |
onValueChange | (id: string) => void | Called when a row is picked, busy or not, so you can check the model first |
efforts | ThinkingEffort[] | Defaults to Off, Low, Medium and High |
effort | string | |
defaultEffort | string | |
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 |
note | string | The confirmation under the picker, like "Set for this chat: Sonnet 5.5, high thinking". Shown with a green check |
label | ReactNode | The label beside the trigger. Defaults to "Model for this chat" |
open | boolean | Whether the list is open. Leave it out to let the trigger and Escape open and close it |
defaultOpen | boolean | |
onOpenChange | (open: boolean) => void | |
className | string |
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);
});
});
Questions
What people ask about it.
- Does it check whether a model is available?
- No. It calls
onValueChangewith the id of any row that is picked, busy or not. Check the model in your own code, set itsstatustocheckingand thenbusyorready, and pass asuggestionif it can't start. - Where do the meters come from?
- From you. Give each model
speed,qualityandcostfrom 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
effortsas a list of{ label, detail }. The picked one iseffortordefaultEffort, 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 withopenandonOpenChange.
More in Conversation
Everything for asking, answering and replying, by text or by voice.