Skip to content
Star on GitHub
WorkspaceModel compare
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 compare

Run one prompt on three models side by side and keep the best. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add model-compare
3 models

01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add model-compare

React

$npx designme add model-compare --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.

One prompt run on two or three models, with their answers side by side in columns on one surface. Each column has a light header with the model's mark and name, which is also the menu that swaps the model, and a quiet line under it with the time, tokens and cost once the answer is in. The answers stream in, and Use this answer keeps the best one. Where the component is narrow, the columns stack.

  • When people choose which model an agent or feature should use.
  • When you want to compare quality, speed and cost on the same prompt.
  • In a prompt playground or evaluation tool where someone picks the better answer.
03

Usage

Import it and drive it from your own state.

Import

import { ModelCompare } from '@/components/designme/model-compare/ModelCompare';

Example

The demo above as a React component, 75 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 { useCallback, useRef, useState } from 'react';
import { ModelCompare, type ModelCompareAnswer, type ModelCompareColumn, type ModelCompareModel } from './ModelCompare';
import { useStory, useWait } from '../lib/useScript';

/* ---------- Model compare: one prompt runs on three models, each streams its answer, then one answer is chosen ---------- */
const MODELS: ModelCompareModel[] = [
  { name: 'Opus', mark: 'O', color: 'var(--dm-pl)', description: 'Most capable, slower' },
  { name: 'Sonnet', mark: 'S', color: 'var(--dm-accent)', markText: 'var(--dm-on-accent)', description: 'Balanced speed and quality' },
  { name: 'Haiku', mark: 'H', color: 'var(--dm-pm)', description: 'Fastest, lowest cost' }
];
// how each model behaves in the demo: time to finish, speed, cost and its answer to the first prompt
const RUNS: Record<string, { ms: number; tps: number; cost: number; answer: string; other: string }> = {
  Opus: { ms: 2400, tps: 38, cost: .021, answer: 'Pro gives every designer an agent that knows your tokens, so reviews take minutes, not days.', other: 'A considered take: the trade-offs first, then one clear recommendation you can act on.' },
  Sonnet: { ms: 1300, tps: 74, cost: .006, answer: 'Ship polished pages faster with an agent that checks every frame for you.', other: 'A clear, direct answer with the two points that matter most.' },
  Haiku: { ms: 600, tps: 142, cost: .001, answer: 'Your design QA, on autopilot.', other: 'Short answer: yes, and the one line why.' }
};
const FIRST = 'Write a one line value prop for the Pro plan';
// a rough count of the tokens in some words, as the preview on designme.dev shows them
const tok = (words: number) => Math.round(words * 1.35);

export default function ModelCompareDemo() {
  const [columns, setColumns] = useState<ModelCompareColumn[]>(MODELS.map(m => ({ id: m.name, model: m.name })));
  const [asked, setAsked] = useState<string>();
  const [answers, setAnswers] = useState<Record<string, ModelCompareAnswer>>({});
  const [chosen, setChosen] = useState<string | null>(null);
  const wait = useWait();
  // each column's latest run; an older run that is still writing stops when a newer one starts
  const runs = useRef<Record<string, number>>({});

  const run = useCallback(async (column: ModelCompareColumn, q: string) => {
    const id = column.id, mine = (runs.current[id] = (runs.current[id] ?? 0) + 1);
    const live = () => runs.current[id] === mine;
    const set = (a: ModelCompareAnswer) => setAnswers(all => ({ ...all, [id]: a }));
    const r = RUNS[column.model];
    set({ state: 'thinking' });
    await wait(r.ms * .35); if (!live()) return;
    const words = (q === FIRST ? r.answer : r.other).split(' ');
    for (let i = 1; i <= words.length; i++) {
      set({ state: 'answering', text: words.slice(0, i).join(' '), tokens: tok(i) });
      await wait(r.ms * .65 / words.length); if (!live()) return;
    }
    set({ state: 'done', text: words.join(' '), tokens: tok(words.length), seconds: r.ms / 1000, tokensPerSecond: r.tps, cost: r.cost });
  }, [wait]);

  const send = (q: string, cols: ModelCompareColumn[]) => { setAsked(q); cols.forEach(c => run(c, q)); };
  // a removed column stops writing and forgets its answer
  const change = (next: ModelCompareColumn[]) => {
    for (const c of columns) if (!next.some(n => n.id === c.id)) { runs.current[c.id] = (runs.current[c.id] ?? 0) + 1; setAnswers(({ [c.id]: _gone, ...rest }) => rest); }
    setColumns(next);
  };

  // the preview's story: run the prompt on all three, read the answers, then use Sonnet's. Clicking, focusing or typing in it
  // takes over
  const ref = useRef<HTMLDivElement>(null);
  const settled = useRef(false);
  settled.current = columns.every(c => answers[c.id]?.state === 'done');
  useStory(ref, async ({ wait, press }) => {
    await wait(1000);
    press('.dm-mcx-go');
    await wait(400);
    while (!settled.current) await wait(200);
    await wait(1500);
    press('.dm-mcx-c[aria-label="Sonnet"] .dm-mcx-pick');
  });

  return (
    <div ref={ref} style={{ width: '100%' }}>
      <ModelCompare models={MODELS} columns={columns} onColumnsChange={change} defaultPrompt={FIRST} asked={asked} answers={answers}
        onSend={send} onRun={c => asked && run(c, asked)} chosen={chosen} onChosenChange={setChosen} />
    </div>
  );
}
04

Props

Everything ModelCompare takes.

PropTypeWhat it does
modelsrequiredModelCompareModel[]The models on offer in every menu
columnsModelCompareColumn[]The columns, left to right. Leave it out to let the menus, Remove and Add model change them
defaultColumnsModelCompareColumn[]Defaults to the first `max` models
onColumnsChange(columns: ModelCompareColumn[]) => void
minnumberThe fewest columns: Remove shows only while there are more. Defaults to 2
maxnumberThe most columns side by side: Add model shows only while there are fewer. Defaults to 3
promptstringThe text in the prompt field. Leave it out to let the field keep its own
defaultPromptstring
onPromptChange(prompt: string) => void
askedstringThe prompt the answers are for. Once it is set, a column whose model changes, or a column added, asks for its own run
answersRecord<string, ModelCompareAnswer>Each column's answer, by column id. A column without one waits for a prompt
onSend(prompt: string, columns: ModelCompareColumn[]) => voidCalled when Run sends the prompt to every column
onRun(column: ModelCompareColumn) => voidCalled when one column should answer again: its model changed, or it was added after a prompt was sent
chosenstring | nullThe id of the column whose answer is used. Its column takes the accent's tint
defaultChosenstring | null
onChosenChange(id: string | null) => void
placeholderstringShown in a column before it has an answer
classNamestring
05

Code

The full source, as the CLI installs it.

ModelCompare.tsxReact · 251 lines
'use client';
import { useEffect, useRef, useState, type FormEvent } from 'react';
import { cx } from '../lib/cx';
import { useControllable } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { Think, Tip } from '../lib/motion';
import { usePopover } from '../lib/popover';
import '../designme.css';
import './model-compare.css';

/** A model that can fill a column */
export interface ModelCompareModel {
  /** Like "Opus". Columns refer to models by this name */
  name: string;
  /** The letter in its coloured mark, like "O" */
  mark: string;
  /** The mark's colour, like 'var(--dm-pl)' */
  color: string;
  /** The text colour on the mark. Defaults to 'var(--dm-on-ink)' */
  markText?: string;
  /** What it is good at, like "Most capable, slower": under its name until it answers, and in the menus */
  description?: string;
}

/** One column, side by side with the others */
export interface ModelCompareColumn {
  /** Stays the same when the column's model changes, so answers can be keyed by it */
  id: string;
  /** The name of the model in it */
  model: string;
}

/** thinking before the first word, answering while the words arrive, done once it has finished */
export type ModelCompareState = 'thinking' | 'answering' | 'done';

/** A column's answer to the prompt that was sent */
export interface ModelCompareAnswer {
  state: ModelCompareState;
  /** The answer so far. New words fade in as it grows */
  text?: string;
  /** Tokens in the answer so far, shown under the model's name while it writes and once it is done */
  tokens?: number;
  /** Time to finish, in seconds, shown once done */
  seconds?: number;
  /** Shown in the time's tooltip, like "38 tokens a second" */
  tokensPerSecond?: number;
  /** What the answer cost, in dollars, shown once done */
  cost?: number;
}

export interface ModelCompareProps {
  /** The models on offer in every menu */
  models: ModelCompareModel[];
  /** The columns, left to right. Leave it out to let the menus, Remove and Add model change them */
  columns?: ModelCompareColumn[];
  /** Defaults to the first `max` models */
  defaultColumns?: ModelCompareColumn[];
  onColumnsChange?: (columns: ModelCompareColumn[]) => void;
  /** The fewest columns: Remove shows only while there are more. Defaults to 2 */
  min?: number;
  /** The most columns side by side: Add model shows only while there are fewer. Defaults to 3 */
  max?: number;
  /** The text in the prompt field. Leave it out to let the field keep its own */
  prompt?: string;
  defaultPrompt?: string;
  onPromptChange?: (prompt: string) => void;
  /** The prompt the answers are for. Once it is set, a column whose model changes, or a column added, asks for its own run */
  asked?: string;
  /** Each column's answer, by column id. A column without one waits for a prompt */
  answers?: Record<string, ModelCompareAnswer>;
  /** Called when Run sends the prompt to every column */
  onSend?: (prompt: string, columns: ModelCompareColumn[]) => void;
  /** Called when one column should answer again: its model changed, or it was added after a prompt was sent */
  onRun?: (column: ModelCompareColumn) => void;
  /** The id of the column whose answer is used. Its column takes the accent's tint */
  chosen?: string | null;
  defaultChosen?: string | null;
  onChosenChange?: (id: string | null) => void;
  /** Shown in a column before it has an answer */
  placeholder?: string;
  className?: string;
}

const running = (a?: ModelCompareAnswer) => a?.state === 'thinking' || a?.state === 'answering';
const NONE: Record<string, ModelCompareAnswer> = {};
const tokens = (n: number) => n + (n === 1 ? ' token' : ' tokens');

/** The coloured letter that stands for a model */
const Mark = ({ model }: { model?: ModelCompareModel }) => (
  <span className="dm-mcx-mk" style={{ background: model?.color, color: model?.markText ?? 'var(--dm-on-ink)' }} aria-hidden="true">{model?.mark}</span>
);

/** A menu item: the model's mark, its name and what it is good at */
const Item = ({ model }: { model: ModelCompareModel }) => (
  <><Mark model={model} /><span>{model.name}{model.description && <span className="dm-d">{model.description}</span>}</span></>
);

/** The quiet line under a model's name: what it is good at, then its tokens as it writes, then time, tokens and cost */
function Meta({ model, answer, fast, cheap }: { model?: ModelCompareModel; answer?: ModelCompareAnswer; fast: boolean; cheap: boolean }) {
  if (!answer || answer.state === 'thinking') return <>{model?.description}</>;
  if (answer.state === 'answering') return <>{answer.tokens !== undefined ? tokens(answer.tokens) : 'Writing'}</>;
  const tps = answer.tokensPerSecond !== undefined ? ', ' + answer.tokensPerSecond + ' tokens a second' : '';
  const parts = [
    answer.seconds !== undefined && <Tip key="s" label={(fast ? 'Fastest. ' : '') + 'Time to finish' + tps}><span className={cx('dm-mcx-tm', fast && 'dm-best')}>{answer.seconds.toFixed(1)}s</span></Tip>,
    answer.tokens !== undefined && <Tip key="t" label="Tokens in the answer"><span className="dm-mcx-tk">{tokens(answer.tokens)}</span></Tip>,
    answer.cost !== undefined && <Tip key="c" label={cheap ? 'Cheapest answer' : 'Cost of this answer'}><span className={cx('dm-mcx-co', cheap && 'dm-best')}>${answer.cost.toFixed(3)}</span></Tip>
  ].filter(Boolean);
  return <>{parts.flatMap((p, i) => (i ? [' · ', p] : [p]))}</>;
}

function Column({ column, models, answer, placeholder, removable, leaving, fresh, best, chosen, onModel, onRemove, onChoose }: {
  column: ModelCompareColumn; models: ModelCompareModel[]; answer?: ModelCompareAnswer; placeholder: string;
  removable: boolean; leaving: boolean; fresh: boolean; best: { fast?: string; cheap?: string }; chosen: string | null;
  onModel: (name: string) => void; onRemove: (focused: boolean) => void; onChoose: () => void;
}) {
  const menu = usePopover();
  const model = models.find(m => m.name === column.model);
  const done = answer?.state === 'done';
  const won = chosen === column.id;
  return (
    <section aria-label={column.model} className={cx('dm-mcx-c', fresh && 'dm-enter', leaving && 'dm-leave', done && 'dm-done', won && 'dm-win')}>
      <div className="dm-mcx-h">
        <button type="button" className="dm-mcx-sel" aria-label={column.model + ', swap model'} {...menu.trigger}><Mark model={model} /><span className="dm-nm">{column.model}</span><Icon name="chev-d" size="xs" /></button>
        <Tip label="Remove">
          <button type="button" className="dm-ib dm-mcx-x" aria-label={'Remove ' + column.model} hidden={!removable} onClick={e => onRemove(document.activeElement === e.currentTarget)}><Icon name="x" size="xs" /></button>
        </Tip>
      </div>
      <div {...menu.pop} className={cx(menu.pop.className, 'dm-mcx-pop')} role="menu" aria-label="Swap model">
        {models.map(m => (
          <button type="button" key={m.name} className="dm-mi" role="menuitemradio" aria-checked={m.name === column.model}
            onClick={() => { menu.closeSoon(); if (m.name !== column.model) onModel(m.name); }}>
            <Item model={m} /><span className="dm-ck"><Icon name="check" size="xs" /></span>
          </button>
        ))}
      </div>
      <div className="dm-mcx-m dm-num"><Meta model={model} answer={answer} fast={best.fast === column.id} cheap={best.cheap === column.id} /></div>
      <div className="dm-mcx-a">
        {!answer ? <span className="dm-ph">{placeholder}</span>
          : answer.state === 'thinking' ? <span className="dm-wk"><Think /><span className="dm-shim">Thinking</span></span>
          : <span>{(answer.text ?? '').split(' ').filter(Boolean).map((w, i) => <span key={i} className="dm-w">{w} </span>)}</span>}
      </div>
      <div className="dm-mcx-ft">
        <button type="button" className="dm-pill dm-line dm-sm dm-mcx-pick" aria-pressed={won} disabled={!done} onClick={onChoose}>
          {won ? <><Icon name="check" size="xs" />Chosen</> : 'Use this answer'}
        </button>
      </div>
    </section>
  );
}

/** One prompt run on two or three models, their answers side by side, so you can use the best one. */
export function ModelCompare({
  models, columns, defaultColumns, onColumnsChange, min = 2, max = 3, prompt, defaultPrompt = '', onPromptChange, asked, answers = NONE,
  onSend, onRun, chosen, defaultChosen = null, onChosenChange, placeholder = 'Waiting for a prompt', className
}: ModelCompareProps) {
  const [cols, setCols] = useControllable(columns, defaultColumns ?? models.slice(0, max).map(m => ({ id: m.name, model: m.name })), onColumnsChange);
  const [text, setText] = useControllable(prompt, defaultPrompt, onPromptChange);
  const [pick, setPick] = useControllable(chosen, defaultChosen, onChosenChange);
  const add = usePopover({ align: 'end' });
  const addBtn = add.trigger.ref, field = useRef<HTMLInputElement>(null);
  const [live, setLive] = useState('');

  // columns that were there from the start come in with the component; later ones play their own entrance
  const [first] = useState(() => new Set(cols.map(c => c.id)));
  // a removed column fades out before it leaves the list
  const [leaving, setLeaving] = useState<string[]>([]);
  const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
  useEffect(() => () => timers.current.forEach(clearTimeout), []);
  const latest = useRef(cols);
  useEffect(() => { latest.current = cols; });

  const busy = cols.some(c => running(answers[c.id]));
  // Run waits for a full run to finish; a single column answering again leaves it free, as on designme.dev
  const [sent, setSent] = useState(false);
  useEffect(() => { if (!busy && sent) { setSent(false); setLive('All ' + cols.length + ' answers are in'); } }, [busy, sent, cols.length]);
  // when the answers settle, the fastest time and the lowest cost read a step darker; they stay put until the next answers
  // settle, so a column answering again or leaving changes nothing until then, as on designme.dev
  const [best, setBest] = useState<{ fast?: string; cheap?: string }>({});
  const settled = useRef<boolean | null>(null);
  useEffect(() => {
    const ended = settled.current !== true && !busy;
    settled.current = !busy;
    if (!ended) return;
    const done = cols.filter(c => answers[c.id]?.state === 'done');
    const low = (k: 'seconds' | 'cost') => done.length < 2 ? undefined : done.reduce((a, b) => (answers[b.id][k] ?? Infinity) < (answers[a.id][k] ?? Infinity) ? b : a).id;
    const fast = low('seconds'), cheap = low('cost');
    setBest(b => b.fast === fast && b.cheap === cheap ? b : { fast, cheap });
  }, [busy, answers, cols]);

  const rerun = (c: ModelCompareColumn) => { setPick(null); if (asked) onRun?.(c); };
  const setModel = (id: string, model: string) => {
    const next = cols.map(c => c.id === id ? { ...c, model } : c);
    setCols(next); rerun(next.find(c => c.id === id)!);
  };
  const addModel = (model: string) => {
    let n = cols.length, id = model.toLowerCase() + '-' + n;
    while (cols.some(c => c.id === id)) id = model.toLowerCase() + '-' + ++n;
    const c = { id, model };
    setCols([...cols, c]); add.closeSoon(); rerun(c);
  };
  const remove = (id: string, had: boolean) => {
    if (leaving.includes(id)) return;
    if (pick === id) setPick(null);
    setLeaving(l => [...l, id]);
    timers.current.push(setTimeout(() => {
      setLeaving(l => l.filter(x => x !== id)); setCols(latest.current.filter(c => c.id !== id));
      // the focus was on Remove: it goes to Add model, which shows again now, or to the prompt
      if (had) setTimeout(() => (addBtn.current && !addBtn.current.hidden ? addBtn.current : field.current)?.focus({ preventScroll: true }));
    }, 260));
  };
  const choose = (c: ModelCompareColumn) => {
    const on = pick !== c.id; setPick(on ? c.id : null);
    if (on) setLive('Using the answer from ' + c.model);
  };
  const send = (e: FormEvent) => {
    e.preventDefault();
    const q = text.trim(); if (!q || (sent && busy)) return;
    setPick(null); setSent(true); setLive('Running on ' + cols.length + ' models'); onSend?.(q, cols);
  };
  const shown = cols.filter(c => !leaving.includes(c.id)).length;

  return (
    <div data-dm="" className={cx('dm-mcx dm-panel', className)}>
      <form className="dm-mcx-in" onSubmit={send}>
        <div className="dm-mcx-fd">
          <input ref={field} className="dm-mcx-q" value={text} onChange={e => setText(e.target.value)} aria-label="Prompt for every model" spellCheck={false} autoComplete="off" />
          <span className="dm-mcx-n dm-num">{shown} {shown === 1 ? 'model' : 'models'}</span>
        </div>
        <button type="button" className="dm-pill dm-ghost dm-sm dm-mcx-add" hidden={shown >= max} {...add.trigger}>
          <Icon name="plus" size="xs" />Add model
        </button>
        <Tip label="Run on every model" kbd="↵">
          <button type="submit" className="dm-pill dm-primary dm-sm dm-mcx-go" disabled={(sent && busy) || !text.trim()}>Run</button>
        </Tip>
      </form>
      <div {...add.pop} className={cx(add.pop.className, 'dm-mcx-pop dm-mcx-pa')} role="menu" aria-label="Add a model">
        {models.map(m => <button type="button" key={m.name} className="dm-mi" role="menuitem" onClick={() => addModel(m.name)}><Item model={m} /></button>)}
      </div>
      <div className="dm-mcx-cols">
        {cols.map(c => (
          <Column key={c.id} column={c} models={models} answer={answers[c.id]} placeholder={placeholder}
            removable={shown > min} leaving={leaving.includes(c.id)} fresh={!first.has(c.id)} best={best} chosen={pick}
            onModel={m => setModel(c.id, m)} onRemove={had => remove(c.id, had)} onChoose={() => choose(c)} />
        ))}
      </div>
      <p className="dm-mcx-live" aria-live="polite">{live}</p>
    </div>
  );
}

model-compare.cssReact · 55 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Model compare ---------- */
/* one prompt on top, then each model's answer in its own column on one white surface, split by hairlines. Colour is only
   for the models' marks and the answer that was chosen. Where the component is narrow, the columns stack */
.dm-mcx{display:flex;flex-direction:column;container-type:inline-size}
/* on a card it keeps the room its answers will take, so it stays centred before they arrive; the columns take the rest */
.dm-stage-in>.dm-mcx{min-height:inherit}
.dm-mcx-in{display:flex;align-items:center;gap:var(--dm-s-2);padding:var(--dm-s-3)}
/* the prompt field: a dimmed edge at rest, one step stronger while it has the focus, and no ring */
.dm-mcx-fd{flex:1;min-width:0;display:flex;align-items:center;gap:var(--dm-s-2);height:var(--dm-field-h);padding:0 var(--dm-s-3);border-radius:var(--dm-r-field);background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line) 50%,transparent);transition:box-shadow var(--dm-t-fast)}
@supports (corner-shape:squircle){.dm-mcx-fd{corner-shape:squircle;border-radius:var(--dm-r-field-sq)}}
.dm-mcx-fd:has(.dm-mcx-q:focus){box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line-strong) 50%,transparent)}
.dm-mcx-q{flex:1;min-width:0;height:100%;border:0;outline:0;background:none;font:inherit;font-size:13.5px;color:var(--dm-fg)}
.dm-mcx-q:focus-visible{outline:0}
.dm-mcx-n{font-size:12px;color:var(--dm-muted);white-space:nowrap}
.dm-mcx-go{flex:none}
.dm-mcx-pop{min-width:240px}
.dm-mcx-cols{flex:1;display:grid;grid-auto-flow:column;grid-auto-columns:minmax(0,1fr);border-top:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}
.dm-mcx-c{display:flex;flex-direction:column;min-width:0;padding:var(--dm-s-3) var(--dm-s-4) var(--dm-s-4);transition:background var(--dm-t) var(--dm-ease)}
.dm-mcx-c+.dm-mcx-c{border-left:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}
.dm-mcx-c.dm-enter{animation-duration:.5s}
.dm-mcx-c.dm-win{background:var(--dm-sel)}
/* the header: the model, which is also the menu that swaps it, and Remove */
.dm-mcx-h{display:flex;align-items:center;gap:var(--dm-s-1);margin:0 calc(-1 * var(--dm-s-2)) 0 calc(-1 * var(--dm-s-2))}
.dm-mcx-sel{display:inline-flex;align-items:center;gap:var(--dm-s-2);height:28px;padding:0 var(--dm-s-2);border-radius:var(--dm-r-sm);font-size:13.5px;font-weight:500;color:var(--dm-fg);min-width:0;transition:background var(--dm-t-fast)}
.dm-mcx-sel[aria-expanded="true"]{background:var(--dm-row)}
@media (hover:hover) and (pointer:fine){.dm-mcx-sel:hover{background:var(--dm-row)}}
.dm-mcx-sel .dm-nm{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.dm-mcx-sel>.dm-i{color:var(--dm-muted)}
.dm-mcx-mk{width:18px;height:18px;border-radius:var(--dm-r-xs);display:grid;place-items:center;font-size:10px;font-weight:500;flex:none}
.dm-mcx-x{width:28px;height:28px;margin-left:auto;color:var(--dm-muted)}
/* the quiet line under it: what the model is good at until it writes, then its tokens, then time, tokens and cost */
.dm-mcx-m{min-height:18px;margin-top:var(--dm-s-1);font-size:12px;line-height:18px;color:var(--dm-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-mcx-m [data-tip]{cursor:default;transition:color var(--dm-t)}
.dm-mcx-m .dm-best{color:var(--dm-fg)}
.dm-mcx-a{flex:1;min-height:112px;margin-top:var(--dm-s-3);font-size:13.5px;line-height:1.55;color:var(--dm-fg)}
.dm-mcx-a .dm-w{animation:dm-mcw .4s var(--dm-ease) both}
@keyframes dm-mcw{from{opacity:0;filter:blur(3px)}to{opacity:1;filter:none}}
.dm-mcx-a .dm-ph{color:var(--dm-muted);font-size:12.5px}
.dm-mcx-a .dm-wk{display:inline-flex;align-items:center;gap:var(--dm-s-2);font-size:12.5px;color:var(--dm-muted)}
.dm-mcx-a .dm-wk .dm-think{color:var(--dm-accent-fg,var(--dm-accent))}
/* Use this answer shows once the answer is in; the chosen one takes the accent's tint */
.dm-mcx-ft{margin-top:var(--dm-s-3);visibility:hidden;opacity:0;transition:opacity var(--dm-t) var(--dm-ease),visibility 0s var(--dm-t)}
.dm-mcx-c.dm-done .dm-mcx-ft{visibility:visible;opacity:1;transition:opacity var(--dm-t) var(--dm-ease)}
.dm-pill.dm-mcx-pick[aria-pressed="true"]{background:var(--dm-accent-bg);color:var(--dm-accent-fg,var(--dm-accent));box-shadow:none}
@media (hover:hover) and (pointer:fine){.dm-pill.dm-mcx-pick[aria-pressed="true"]:hover{background:var(--dm-accent-bg);color:var(--dm-accent-fg,var(--dm-accent));box-shadow:none}}
.dm-mcx-live{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
@container (max-width:539px){
  .dm-mcx-cols{grid-auto-flow:row}
  .dm-mcx-c+.dm-mcx-c{border-left:0;border-top:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}
  .dm-mcx-a{min-height:42px}
  .dm-mcx-n{display:none}
}
:root:not([data-style="soft"]) .dm-pill.dm-mcx-pick[aria-pressed="true"]:not(:where([data-style="soft"],[data-style="soft"] *)){box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-accent-mark) 50%,transparent)}
model-compare.htmlHTML · 15 lines
<div class="card wide" data-c="model-compare">
  <div class="stage-in">
    <div class="mcx panel">
      <form class="mcx-in">
        <div class="mcx-fd"><input class="mcx-q" value="Write a one line value prop for the Pro plan" aria-label="Prompt for every model" spellcheck="false" autocomplete="off"><span class="mcx-n num">3 models</span></div>
        <button type="button" class="pill ghost sm mcx-add" data-pop=".mcx-pa" data-pop-align="end" aria-haspopup="menu" aria-expanded="false" hidden><svg class="i xs"><use href="#i-plus"/></svg>Add model</button>
        <button type="submit" class="pill primary sm mcx-go" data-tip="Run on every model" data-tip-k="↵">Run</button>
      </form>
      <div class="pop mcx-pop mcx-pa" role="menu" aria-label="Add a model"></div>
      <div class="mcx-cols"></div>
      <p class="mcx-live" aria-live="polite"></p>
    </div>
  </div>
</div>
model-compare.cssHTML · 54 lines
/* ---------- Model compare ---------- */
/* one prompt on top, then each model's answer in its own column on one white surface, split by hairlines. Colour is only
   for the models' marks and the answer that was chosen. Where the component is narrow, the columns stack */
.mcx{display:flex;flex-direction:column;container-type:inline-size}
/* on a card it keeps the room its answers will take, so it stays centred before they arrive; the columns take the rest */
.stage-in>.mcx{min-height:inherit}
.mcx-in{display:flex;align-items:center;gap:var(--s-2);padding:var(--s-3)}
/* the prompt field: a dimmed edge at rest, one step stronger while it has the focus, and no ring */
.mcx-fd{flex:1;min-width:0;display:flex;align-items:center;gap:var(--s-2);height:var(--field-h);padding:0 var(--s-3);border-radius:var(--r-field);background:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line) 50%,transparent);transition:box-shadow var(--t-fast)}
@supports (corner-shape:squircle){.mcx-fd{corner-shape:squircle;border-radius:var(--r-field-sq)}}
.mcx-fd:has(.mcx-q:focus){box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line-strong) 50%,transparent)}
.mcx-q{flex:1;min-width:0;height:100%;border:0;outline:0;background:none;font:inherit;font-size:13.5px;color:var(--fg)}
.mcx-q:focus-visible{outline:0}
.mcx-n{font-size:12px;color:var(--muted);white-space:nowrap}
.mcx-go{flex:none}
.mcx-pop{min-width:240px}
.mcx-cols{flex:1;display:grid;grid-auto-flow:column;grid-auto-columns:minmax(0,1fr);border-top:1px solid color-mix(in srgb,var(--line) 50%,transparent)}
.mcx-c{display:flex;flex-direction:column;min-width:0;padding:var(--s-3) var(--s-4) var(--s-4);transition:background var(--t) var(--ease)}
.mcx-c+.mcx-c{border-left:1px solid color-mix(in srgb,var(--line) 50%,transparent)}
.mcx-c.enter{animation-duration:.5s}
.mcx-c.win{background:var(--sel)}
/* the header: the model, which is also the menu that swaps it, and Remove */
.mcx-h{display:flex;align-items:center;gap:var(--s-1);margin:0 calc(-1 * var(--s-2)) 0 calc(-1 * var(--s-2))}
.mcx-sel{display:inline-flex;align-items:center;gap:var(--s-2);height:28px;padding:0 var(--s-2);border-radius:var(--r-sm);font-size:13.5px;font-weight:500;color:var(--fg);min-width:0;transition:background var(--t-fast)}
.mcx-sel[aria-expanded="true"]{background:var(--row)}
@media (hover:hover) and (pointer:fine){.mcx-sel:hover{background:var(--row)}}
.mcx-sel .nm{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.mcx-sel>.i{color:var(--muted)}
.mcx-mk{width:18px;height:18px;border-radius:var(--r-xs);display:grid;place-items:center;font-size:10px;font-weight:500;flex:none}
.mcx-x{width:28px;height:28px;margin-left:auto;color:var(--muted)}
/* the quiet line under it: what the model is good at until it writes, then its tokens, then time, tokens and cost */
.mcx-m{min-height:18px;margin-top:var(--s-1);font-size:12px;line-height:18px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.mcx-m [data-tip]{cursor:default;transition:color var(--t)}
.mcx-m .best{color:var(--fg)}
.mcx-a{flex:1;min-height:112px;margin-top:var(--s-3);font-size:13.5px;line-height:1.55;color:var(--fg)}
.mcx-a .w{animation:mcw .4s var(--ease) both}
@keyframes mcw{from{opacity:0;filter:blur(3px)}to{opacity:1;filter:none}}
.mcx-a .ph{color:var(--muted);font-size:12.5px}
.mcx-a .wk{display:inline-flex;align-items:center;gap:var(--s-2);font-size:12.5px;color:var(--muted)}
.mcx-a .wk .think{color:var(--accent-fg,var(--accent))}
/* Use this answer shows once the answer is in; the chosen one takes the accent's tint */
.mcx-ft{margin-top:var(--s-3);visibility:hidden;opacity:0;transition:opacity var(--t) var(--ease),visibility 0s var(--t)}
.mcx-c.done .mcx-ft{visibility:visible;opacity:1;transition:opacity var(--t) var(--ease)}
.pill.mcx-pick[aria-pressed="true"]{background:var(--accent-bg);color:var(--accent-fg,var(--accent));box-shadow:none}
@media (hover:hover) and (pointer:fine){.pill.mcx-pick[aria-pressed="true"]:hover{background:var(--accent-bg);color:var(--accent-fg,var(--accent));box-shadow:none}}
.mcx-live{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
@container (max-width:539px){
  .mcx-cols{grid-auto-flow:row}
  .mcx-c+.mcx-c{border-left:0;border-top:1px solid color-mix(in srgb,var(--line) 50%,transparent)}
  .mcx-a{min-height:42px}
  .mcx-n{display:none}
}
:root:not([data-style="soft"]) .pill.mcx-pick[aria-pressed="true"]:not(:where([data-style="soft"],[data-style="soft"] *)){box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--accent-mark) 50%,transparent)}
model-compare.jsHTML · 109 lines
DM.def('model-compare', (r, c) => {
  r.innerHTML = r.innerHTML.replace('{plus}', I('plus','xs'));
  // the models on offer: the letter and colour of each one's mark (with the text colour on it), what it is good at, and how
  // it behaves in the preview: time to finish, tokens a second, cost, and its answer to the first prompt
  const MOD = {
    Opus:{ k:'O', c:'var(--pl)', t:'var(--on-ink)', d:'Most capable, slower', ms:2400, tps:38, cost:.021, o:'Pro gives every designer an agent that knows your tokens, so reviews take minutes, not days.' },
    Sonnet:{ k:'S', c:'var(--accent)', t:'var(--on-accent)', d:'Balanced speed and quality', ms:1300, tps:74, cost:.006, o:'Ship polished pages faster with an agent that checks every frame for you.' },
    Haiku:{ k:'H', c:'var(--pm)', t:'var(--on-ink)', d:'Fastest, lowest cost', ms:600, tps:142, cost:.001, o:'Your design QA, on autopilot.' }
  };
  const generic = { Opus:'A considered take: the trade-offs first, then one clear recommendation you can act on.', Sonnet:'A clear, direct answer with the two points that matter most.', Haiku:'Short answer: yes, and the one line why.' };
  const form = $('.mcx-in', r), cols = $('.mcx-cols', r), q = $('.mcx-q', r), go = $('.mcx-go', r), cnt = $('.mcx-n', r), add = $('.mcx-add', r), pa = $('.mcx-pa', r), live = $('.mcx-live', r);
  const DEF = q.value, MIN = 2, MAX = 3, C = [];
  let uid = 0, asked = '', sending = false, busy = 0;
  const mark = n => `<span class="mcx-mk" style="background:${MOD[n].c};color:${MOD[n].t}" aria-hidden="true">${MOD[n].k}</span>`;
  const item = n => `${mark(n)}<span>${n}<span class="d">${MOD[n].d}</span></span>`;
  // a rough count of the tokens in some words, for the live count and the totals
  const tok = words => Math.round(words * 1.35);
  const tokens = n => n + (n === 1 ? ' token' : ' tokens');
  function sync(){
    cnt.textContent = C.length + (C.length > 1 ? ' models' : ' model');
    C.forEach(col => { $('.mcx-x', col.el).hidden = C.length <= MIN; });
    add.hidden = C.length >= MAX;
    pa.innerHTML = Object.keys(MOD).map(n => `<button class="mi" role="menuitem" data-close data-m="${n}">${item(n)}</button>`).join('');
    $$('.mi', pa).forEach(b => b.onclick = () => addCol(b.dataset.m, true));
    go.disabled = sending || !q.value.trim();
  }
  function head(col){
    const el = col.el;
    $('.mcx-sel', el).innerHTML = `${mark(col.m)}<span class="nm">${col.m}</span>${I('chev-d','xs')}`;
    $('.mcx-sel', el).setAttribute('aria-label', col.m + ', swap model');
    $('.mcx-x', el).setAttribute('aria-label', 'Remove ' + col.m);
    el.setAttribute('aria-label', col.m);
    $$('.pop .mi', el).forEach(x => x.setAttribute('aria-checked', x.dataset.m === col.m));
  }
  // a column before its first answer: what the model is good at, and a quiet line where the answer will go
  function idle(col){ col.res = null; col.el.classList.remove('done'); $('.mcx-m', col.el).textContent = MOD[col.m].d; $('.mcx-a', col.el).innerHTML = '<span class="ph">Waiting for a prompt</span>'; }
  function addCol(m, fresh){
    const id = 'mcx-p' + (uid++);
    const el = html(`<section class="mcx-c"><div class="mcx-h"><button class="mcx-sel" data-pop=".${id}" aria-haspopup="menu" aria-expanded="false"></button><button class="ib mcx-x" data-tip="Remove">${I('x','xs')}</button></div><div class="pop mcx-pop ${id}" role="menu" aria-label="Swap model">${Object.keys(MOD).map(n => `<button class="mi" role="menuitemradio" data-close data-m="${n}">${item(n)}<span class="ck">${I('check','xs')}</span></button>`).join('')}</div><div class="mcx-m num"></div><div class="mcx-a"></div><div class="mcx-ft"><button class="pill line sm mcx-pick" aria-pressed="false">Use this answer</button></div></section>`);
    const col = { m, el, run:0, res:null };
    C.push(col); cols.appendChild(el); head(col); idle(col);
    // swapping the model clears the pick and, once a prompt has been sent, runs it on the new model in this column
    $$('.pop .mi', el).forEach(x => x.addEventListener('click', () => { if (col.m === x.dataset.m) return; col.m = x.dataset.m; col.run++; head(col); pick(null); if (asked) c.run(() => one(col)); else idle(col); }));
    $('.mcx-x', el).onclick = () => {
      hideTip(); const had = el.contains(document.activeElement);
      C.splice(C.indexOf(col), 1); col.run++; if (el.classList.contains('win')) pick(null);
      sync(); el.classList.add('leave');
      setTimeout(() => { el.remove(); if (had) (add.hidden ? q : add).focus({ preventScroll:true }); }, 260);
    };
    $('.mcx-pick', el).onclick = () => pick(el.classList.contains('win') ? null : col);
    if (fresh){ enter(el); sync(); if (asked) c.run(() => one(col)); }
    return col;
  }
  // the answer that was kept: its column takes the accent's tint, and pressing it again lets it go
  function pick(col){
    C.forEach(x => { const on = x === col, b = $('.mcx-pick', x.el); x.el.classList.toggle('win', on); b.setAttribute('aria-pressed', on); b.innerHTML = on ? I('check','xs') + 'Chosen' : 'Use this answer'; });
    if (col) live.textContent = 'Using the answer from ' + col.m;
  }
  async function one(col){
    const id = ++col.run, el = col.el, m = MOD[col.m], a = $('.mcx-a', el), meta = $('.mcx-m', el);
    el.classList.remove('done'); col.res = null; meta.textContent = m.d; busy++;
    a.innerHTML = '<span class="wk"><span class="think on"><i></i><i></i><i></i><i></i></span><span class="shim">Thinking</span></span>';
    try {
      await c.wait(m.ms * .35); if (id !== col.run) return;
      const text = asked === DEF ? m.o : generic[col.m], ws = text.split(' ');
      a.innerHTML = '<span></span>'; const p = a.firstChild;
      for (let i = 0; i < ws.length; i++){
        p.insertAdjacentHTML('beforeend', `<span class="w">${ws[i]} </span>`); meta.textContent = tokens(tok(i + 1));
        await c.wait(m.ms * .65 / ws.length); if (id !== col.run) return;
      }
      col.res = { s:m.ms / 1000, n:tok(ws.length), cost:m.cost };
      meta.innerHTML = `<span class="mcx-tm"></span> · <span class="mcx-tk"></span> · <span class="mcx-co"></span>`;
      $('.mcx-tm', meta).textContent = col.res.s.toFixed(1) + 's'; $('.mcx-tk', meta).textContent = tokens(col.res.n); $('.mcx-co', meta).textContent = '$' + m.cost.toFixed(3);
      $('.mcx-tk', meta).dataset.tip = 'Tokens in the answer';
      el.classList.add('done'); tips(col, false, false);
    } finally { busy--; if (!busy) best(); }
  }
  function tips(col, fast, cheap){
    const meta = $('.mcx-m', col.el), tm = $('.mcx-tm', meta), co = $('.mcx-co', meta); if (!tm) return;
    tm.classList.toggle('best', fast); tm.dataset.tip = (fast ? 'Fastest. ' : '') + 'Time to finish, ' + MOD[col.m].tps + ' tokens a second';
    co.classList.toggle('best', cheap); co.dataset.tip = cheap ? 'Cheapest answer' : 'Cost of this answer';
  }
  // once every column has answered, the fastest time and the lowest cost read a step darker
  function best(){
    const done = C.filter(x => x.res); if (done.length < 2) return;
    const fast = done.reduce((a, b) => b.res.s < a.res.s ? b : a), cheap = done.reduce((a, b) => b.res.cost < a.res.cost ? b : a);
    done.forEach(x => tips(x, x === fast, x === cheap));
  }
  ['Opus','Sonnet','Haiku'].forEach(m => addCol(m)); sync();
  // Run stays off while a run is answering, even if the prompt changes
  const run = () => c.run(async () => {
    const v = q.value.trim(); if (!v || sending) return;
    asked = v; sending = true; pick(null); go.disabled = true; live.textContent = 'Running on ' + C.length + ' models';
    try { await Promise.all(C.map(one)); } finally { sending = false; go.disabled = !q.value.trim(); }
    live.textContent = 'All ' + C.length + ' answers are in';
  });
  form.onsubmit = e => { e.preventDefault(); run(); };
  q.oninput = () => { go.disabled = sending || !q.value.trim(); };
  // the preview's story: run the prompt on all three, read the answers, then keep Sonnet's
  c.story(async s => {
    await s.beat(2);
    await s.tap(go); s.away();
    await s.until(() => !sending && C.every(x => x.res)); await s.beat(3);
    const son = C.find(x => x.m === 'Sonnet');
    if (son){ await s.tap($('.mcx-pick', son.el)); s.away(); }
    await s.beat(4);
  });
});
06

Questions

What people ask about it.

How do I stream each model's answer into its column?
Handle onSend, call your models, and update answers keyed by column id. Set state to 'thinking', then 'answering' with growing text and tokens, then 'done' with seconds, tokens, cost and optionally tokensPerSecond.
What happens when someone swaps the model in one column?
The column keeps its id, the pick is cleared, and onRun is called with that column if a prompt has already been sent (asked is set). Run just that column again and update its answer.
How do I know which answer the person kept?
Listen to onChosenChange. It gets the chosen column's id, or null when they let it go, swap a model or run a new prompt.
Can I compare more than three models?
Yes. Set max to the number of columns you want; Add model shows until that many are open. min sets how few there can be.
07

More in Workspace

Navigation, search, settings, models and automations around the agent.

Automation canvasA trigger, a few agent steps, and a run you can watch flow through.
Canvas toolbarA floating toolbar for a workflow canvas: run, pick a tool, comment, fit and zoom.
Command paletteJump anywhere or hand a task to the agent from one box.
Memory panelWhat the agent remembers about your team, editable and removable.
Agent settingsHow much the agent may do alone, with a save bar for changes.
Tools and connectionsThe tools the agent can reach, and how much it may do with each.
Knowledge sourcesThe files, sites and folders the agent knows, and how far each is indexed.
Scheduled runsTasks the agent runs on a schedule, with their last result and a switch.
All 11 in Workspace →