Skip to content
Star on GitHub
Review and approveProposal card
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

Proposal card

A suggested change with confidence, alternatives and undo. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add proposal-card
Raise contrast on the secondary buttonPricing, frame 2 of 3
#C9CFD82.1 : 1
#5B64725.9 : 1
Confidence
0%
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add proposal-card

React

$npx designme add proposal-card --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.

Proposal card shows a single change the agent wants to make, like a new colour for a low-contrast button, as a before and after with the agent's confidence. The person can accept the suggestion, pick one of the other options instead, or say not now, and either choice can be undone.

  • When the agent spots a fix during a review and should ask before applying it.
  • When there is more than one reasonable fix and the agent should rank them.
  • When people need to see how sure the agent is before they accept.
  • When an accepted or dismissed suggestion should be easy to take back.
03

Usage

Import it and drive it from your own state.

Import

import { ProposalCard } from '@/components/designme/proposal-card/ProposalCard';

Example

The demo above as a React component, 19 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 { ProposalCard, type ProposalOption } from './ProposalCard';

/* ---------- Proposal card: the agent's fix, its confidence, two other options, Accept or Not now ---------- */
const OPTIONS: ProposalOption[] = [
  { color: '#5b6472', label: '#5B6472', badge: '5.9 : 1', confidence: 84, name: 'Suggested', description: 'Balanced with the primary' },
  { color: '#475161', label: '#475161', badge: '7.4 : 1', confidence: 62, name: 'Darker', description: 'Stronger than the primary' },
  { color: '#116ff8', label: '#116FF8', badge: '4.6 : 1', confidence: 41, name: 'Accent outline', description: 'Competes with the CTA' }
];
export default function ProposalCardDemo() {
  return (
    <div style={{ width: '100%' }}>
      <ProposalCard title="Raise contrast on the secondary button" subtitle="Pricing, frame 2 of 3"
        from={{ color: '#c9cfd8', label: '#C9CFD8', badge: '2.1 : 1' }} options={OPTIONS} acceptedLabel="Applied to 3 buttons" />
    </div>
  );
}
04

Props

Everything ProposalCard takes.

PropTypeWhat it does
titlerequiredReactNodeWhat the agent wants to change
subtitleReactNodeWhere, like "Pricing, frame 2 of 3"
iconIconNameThe icon in the orange tile. Defaults to a wand
fromrequiredProposalValueHow it is now
optionsrequiredProposalOption[]The changes on offer, the agent's pick first
valuenumberIndex of the option shown as the change. Leave it out to let the list pick it
defaultValuenumberDefaults to 0, the agent's pick
onValueChange(index: number) => void
openbooleanWhether the other options are listed. Leave it out to let their toggle open them
defaultOpenboolean
onOpenChange(open: boolean) => void
statusProposalStatusopen, accepted or dismissed. Leave it out to let Accept, Not now and Undo move it
defaultStatusProposalStatus
onStatusChange(status: ProposalStatus) => void
onAccept(option: ProposalOption, index: number) => voidCalled with the chosen option when Accept is pressed
onDismiss() => void
onUndo() => void
acceptedLabelstringShown once accepted, like "Applied to 3 buttons"
dismissedLabelstringShown once dismissed
confidenceLabelstring
barebooleanSits flat in a chat thread (the default, as on designme.dev). Set false to give it a card of its own
classNamestring
05

Code

The full source, as the CLI installs it.

ProposalCard.tsxReact · 166 lines
'use client';
import { useEffect, useState, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useControllable, useTween } from '../lib/hooks';
import { Icon, type IconName } from '../lib/icons';
import { enter, Fold, Swap } from '../lib/motion';
import '../designme.css';
import './proposal-card.css';

export type ProposalTone = 'ok' | 'warn' | 'bad';

/** One side of the change: a swatch, its value and a tag, like #C9CFD8 with "2.1 : 1" */
export interface ProposalValue {
  /** Any CSS colour, shown in the swatch */
  color: string;
  /** Like "#C9CFD8" */
  label: string;
  /** A short tag after the value, like a contrast ratio */
  badge?: string;
  /** Colours the tag. The current value defaults to bad, the options to ok */
  tone?: ProposalTone;
}

/** A change the agent can make. The first one is its suggestion, the others show under "other options" */
export interface ProposalOption extends ProposalValue {
  /** Like "Suggested" or "Darker" */
  name: string;
  /** The quiet line beside it in the list, like "Balanced with the primary" */
  description?: string;
  /** How sure the agent is, 0 to 100 */
  confidence: number;
}

export type ProposalStatus = 'open' | 'accepted' | 'dismissed';

export interface ProposalCardProps {
  /** What the agent wants to change */
  title: ReactNode;
  /** Where, like "Pricing, frame 2 of 3" */
  subtitle?: ReactNode;
  /** The icon in the orange tile. Defaults to a wand */
  icon?: IconName;
  /** How it is now */
  from: ProposalValue;
  /** The changes on offer, the agent's pick first */
  options: ProposalOption[];
  /** Index of the option shown as the change. Leave it out to let the list pick it */
  value?: number;
  /** Defaults to 0, the agent's pick */
  defaultValue?: number;
  onValueChange?: (index: number) => void;
  /** Whether the other options are listed. Leave it out to let their toggle open them */
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  /** open, accepted or dismissed. Leave it out to let Accept, Not now and Undo move it */
  status?: ProposalStatus;
  defaultStatus?: ProposalStatus;
  onStatusChange?: (status: ProposalStatus) => void;
  /** Called with the chosen option when Accept is pressed */
  onAccept?: (option: ProposalOption, index: number) => void;
  onDismiss?: () => void;
  onUndo?: () => void;
  /** Shown once accepted, like "Applied to 3 buttons" */
  acceptedLabel?: string;
  /** Shown once dismissed */
  dismissedLabel?: string;
  confidenceLabel?: string;
  /** Sits flat in a chat thread (the default, as on designme.dev). Set false to give it a card of its own */
  bare?: boolean;
  className?: string;
}

const STEPS = 5;

/** A change the agent proposes, with its confidence, other options, and Accept or Not now. */
export function ProposalCard({
  title, subtitle, icon = 'wand', from, options, value, defaultValue = 0, onValueChange, open, defaultOpen = false, onOpenChange,
  status, defaultStatus = 'open', onStatusChange, onAccept, onDismiss, onUndo, acceptedLabel = 'Applied', dismissedLabel = 'Dismissed. The agent will not suggest this again.',
  confidenceLabel = 'Confidence', bare = true, className
}: ProposalCardProps) {
  const [sel, setSel] = useControllable(value, defaultValue, onValueChange);
  const [isOpen, setOpen] = useControllable(open, defaultOpen, onOpenChange);
  const [state, setState] = useControllable(status, defaultStatus, onStatusChange);
  // the confidence fills in a moment after the card appears, then follows the chosen option
  const [ready, setReady] = useState(false);
  useEffect(() => { const t = setTimeout(() => setReady(true), 400); return () => clearTimeout(t); }, []);
  // the list only plays its entrance once someone has picked another option
  const [picked, setPicked] = useState(false);
  const opt = options[Math.max(0, Math.min(options.length - 1, sel))];
  const conf = useTween(ready && opt ? opt.confidence : 0, 700, 0);
  const others = options.map((o, i) => ({ o, i })).filter(x => x.i !== sel);

  const choose = (i: number) => { setPicked(true); setSel(i); };
  const accept = () => { setOpen(false); setState('accepted'); onAccept?.(opt, sel); };
  const dismiss = () => { setState('dismissed'); onDismiss?.(); };
  const undo = () => { setState('open'); onUndo?.(); };

  return (
    <div data-dm="" className={cx('dm-prx', !bare && 'dm-panel', className)}>
      <div className="dm-prx-h">
        <span className="dm-prx-ic"><Icon name={icon} /></span>
        <div className="dm-prx-ht"><b>{title}</b>{subtitle && <span className="dm-muted">{subtitle}</span>}</div>
      </div>
      <div className="dm-prx-diff">
        <div className="dm-prx-sw">
          <span className="dm-prx-c" style={{ background: from.color }} />
          <span className="dm-num">{from.label}</span>
          {from.badge && <span className={cx('dm-tag', 'dm-' + (from.tone ?? 'bad'))}>{from.badge}</span>}
        </div>
        <span className="dm-prx-ar"><Icon name="right" size="xs" /></span>
        {opt && (
          <div className="dm-prx-sw dm-prx-new">
            <span className="dm-prx-c" style={{ background: opt.color }} />
            <Swap className="dm-num">{opt.label}</Swap>
            {opt.badge && <span className={cx('dm-tag', 'dm-' + (opt.tone ?? 'ok'))}><Swap>{opt.badge}</Swap></span>}
          </div>
        )}
      </div>
      <div className="dm-prx-cf">
        <span className="dm-muted">{confidenceLabel}</span>
        <div className="dm-prx-seg" role="meter" aria-label={confidenceLabel} aria-valuemin={0} aria-valuemax={100} aria-valuenow={opt?.confidence ?? 0}>
          {Array.from({ length: STEPS }, (_, i) => <i key={i} className={cx(conf >= (i + 1) * 20 - 10 && 'dm-on')} />)}
        </div>
        <span className="dm-num dm-prx-pc">{Math.round(conf)}%</span>
      </div>
      {others.length > 0 && (
        <>
          <button type="button" className={cx('dm-prx-alt-t', isOpen && 'dm-open')} aria-expanded={isOpen} onClick={() => setOpen(!isOpen)}>
            <Icon name="chev-r" size="xs" /><span>{others.length} other {others.length > 1 ? 'options' : 'option'}</span>
          </button>
          <Fold open={isOpen} className="dm-prx-alt">
            <div className="dm-prx-alts" key={sel}>
              {others.map(({ o, i }, k) => (
                <button type="button" key={i} className={cx('dm-prx-a', picked && 'dm-enter')} style={picked ? enter(k * 50).style : undefined} onClick={() => choose(i)}>
                  <span className="dm-prx-c" style={{ background: o.color }} />
                  <span>{o.name}{o.badge && `, ${o.badge}`}</span>
                  {o.description && <span className="dm-d">{o.description}</span>}
                </button>
              ))}
            </div>
          </Fold>
        </>
      )}
      <div className="dm-prx-f">
        {state === 'open' ? (
          <>
            <button type="button" className="dm-pill dm-ghost dm-sm dm-prx-no" onClick={dismiss}>Not now</button>
            <button type="button" className="dm-pill dm-primary dm-sm dm-prx-ok" onClick={accept}>Accept</button>
          </>
        ) : state === 'accepted' ? (
          <>
            <span className="dm-prx-done dm-enter"><Icon name="ok" /><span>{acceptedLabel}</span></span>
            <button type="button" className="dm-pill dm-ghost dm-sm dm-prx-un dm-enter" style={enter(60).style} onClick={undo}><Icon name="undo" size="xs" />Undo</button>
          </>
        ) : (
          <>
            <span className="dm-prx-done dm-muted dm-enter" style={{ color: 'var(--dm-muted)' }}>{dismissedLabel}</span>
            <button type="button" className="dm-pill dm-ghost dm-sm dm-prx-un dm-enter" style={enter(60).style} onClick={undo}>Undo</button>
          </>
        )}
      </div>
    </div>
  );
}
proposal-card.cssReact · 30 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Proposal card ---------- */
/* a suggestion posted in the chat: no container; with .panel it becomes a card */
.dm-prx{padding:14px 0;display:flex;flex-direction:column;gap:var(--dm-s-3)}
.dm-prx.dm-panel{padding:14px}
.dm-prx-h{display:flex;gap:10px;align-items:center}
.dm-prx-ic{width:32px;height:32px;border-radius:10px;background:var(--dm-pp-bg);color:var(--dm-pp);display:grid;place-items:center;flex:none}
.dm-prx-ht{display:flex;flex-direction:column;min-width:0}
.dm-prx-ht b{font-weight:500;font-size:13.5px}
.dm-prx-ht span{font-size:12.5px}
.dm-prx-diff{display:flex;align-items:center;gap:var(--dm-s-2);padding:10px;border-radius:var(--dm-r-in);background:var(--dm-chip);flex-wrap:wrap}
.dm-prx-sw{display:flex;align-items:center;gap:7px;font-size:12px;flex:1;min-width:0}
.dm-prx-ar{color:var(--dm-muted);display:grid}
.dm-prx-c{width:18px;height:18px;border-radius:6px;flex:none;box-shadow:inset 0 0 0 1px rgba(0,0,0,.08);transition:background .5s var(--dm-ease)}
.dm-prx-new .dm-num{transition:opacity .3s}
.dm-prx-cf{display:flex;align-items:center;gap:10px;font-size:12.5px}
.dm-prx-seg{display:flex;gap:3px;flex:1}
.dm-prx-seg i{flex:1;height:5px;border-radius:3px;background:var(--dm-line);transition:background .4s}
.dm-prx-seg i.dm-on{background:var(--dm-ok)}
.dm-prx-pc{width:34px;text-align:right;color:var(--dm-fg2)}
.dm-prx-alt-t{display:flex;align-items:center;gap:6px;font-size:12.5px;color:var(--dm-fg2);align-self:flex-start}
.dm-prx-alt-t svg{transition:transform .4s var(--dm-ease)}
.dm-prx-alt-t.dm-open svg{transform:rotate(90deg)}
.dm-prx-alts{display:flex;flex-direction:column;gap:2px;padding-top:2px;margin-inline:-8px}
.dm-prx-a{display:flex;align-items:center;gap:9px;padding:7px var(--dm-s-2);border-radius:10px;font-size:13px;text-align:left;transition:background .25s}
@media (hover:hover) and (pointer:fine){.dm-prx-a:hover{background:var(--dm-row)}}
.dm-prx-a .dm-d{margin-left:auto;font-size:12px;color:var(--dm-muted)}
.dm-prx-f{display:flex;justify-content:flex-end;gap:6px}
.dm-prx-done{display:flex;align-items:center;gap:var(--dm-s-2);font-size:13px;color:var(--dm-ok);flex:1}
proposal-card.htmlHTML · 13 lines
<div class="card bare" data-c="proposal-card">
  <div class="stage-in">
    <div class="prx">
      <div class="prx-h"><span class="prx-ic"><svg class="i s"><use href="#i-wand"/></svg></span><div class="prx-ht"><b>Raise contrast on the secondary button</b><span class="muted">Pricing, frame 2 of 3</span></div></div>
      <div class="prx-diff"><div class="prx-sw"><span class="prx-c" style="background:#c9cfd8"></span><span class="num">#C9CFD8</span><span class="tag bad">2.1 : 1</span></div><span class="prx-ar"><svg class="i xs"><use href="#i-right"/></svg></span><div class="prx-sw prx-new"><span class="prx-c" style="background:#5b6472"></span><span class="num">#5B6472</span><span class="tag ok"><span class="prx-rt">5.9 : 1</span></span></div></div>
      <div class="prx-cf"><span class="muted">Confidence</span><div class="prx-seg"><i></i><i></i><i></i><i></i><i></i></div><span class="num prx-pc">0%</span></div>
      <button class="prx-alt-t"><svg class="i xs"><use href="#i-chev-r"/></svg><span>2 other options</span></button>
      <div class="fold shut prx-alt"><div><div class="prx-alts"></div></div></div>
      <div class="prx-f"><button class="pill ghost sm prx-no">Not now</button><button class="pill primary sm prx-ok">Accept</button></div>
    </div>
  </div>
</div>
proposal-card.cssHTML · 29 lines
/* ---------- Proposal card ---------- */
/* a suggestion posted in the chat: no container; with .panel it becomes a card */
.prx{padding:14px 0;display:flex;flex-direction:column;gap:var(--s-3)}
.prx.panel{padding:14px}
.prx-h{display:flex;gap:10px;align-items:center}
.prx-ic{width:32px;height:32px;border-radius:10px;background:var(--pp-bg);color:var(--pp);display:grid;place-items:center;flex:none}
.prx-ht{display:flex;flex-direction:column;min-width:0}
.prx-ht b{font-weight:500;font-size:13.5px}
.prx-ht span{font-size:12.5px}
.prx-diff{display:flex;align-items:center;gap:var(--s-2);padding:10px;border-radius:var(--r-in);background:var(--chip);flex-wrap:wrap}
.prx-sw{display:flex;align-items:center;gap:7px;font-size:12px;flex:1;min-width:0}
.prx-ar{color:var(--muted);display:grid}
.prx-c{width:18px;height:18px;border-radius:6px;flex:none;box-shadow:inset 0 0 0 1px rgba(0,0,0,.08);transition:background .5s var(--ease)}
.prx-new .num{transition:opacity .3s}
.prx-cf{display:flex;align-items:center;gap:10px;font-size:12.5px}
.prx-seg{display:flex;gap:3px;flex:1}
.prx-seg i{flex:1;height:5px;border-radius:3px;background:var(--line);transition:background .4s}
.prx-seg i.on{background:var(--ok)}
.prx-pc{width:34px;text-align:right;color:var(--fg2)}
.prx-alt-t{display:flex;align-items:center;gap:6px;font-size:12.5px;color:var(--fg2);align-self:flex-start}
.prx-alt-t svg{transition:transform .4s var(--ease)}
.prx-alt-t.open svg{transform:rotate(90deg)}
.prx-alts{display:flex;flex-direction:column;gap:2px;padding-top:2px;margin-inline:-8px}
.prx-a{display:flex;align-items:center;gap:9px;padding:7px var(--s-2);border-radius:10px;font-size:13px;text-align:left;transition:background .25s}
@media (hover:hover) and (pointer:fine){.prx-a:hover{background:var(--row)}}
.prx-a .d{margin-left:auto;font-size:12px;color:var(--muted)}
.prx-f{display:flex;justify-content:flex-end;gap:6px}
.prx-done{display:flex;align-items:center;gap:var(--s-2);font-size:13px;color:var(--ok);flex:1}
proposal-card.jsHTML · 27 lines
DM.def('proposal-card', (r, c) => {
  r.innerHTML = r.innerHTML.replace('{wand}', I('wand')).replace('{right}', I('right','xs')).replace('{chev}', I('chev-r','xs'));
  const segs = $$('.prx-seg i', r), pc = $('.prx-pc', r), at = $('.prx-alt-t', r), alt = $('.prx-alt', r), f = $('.prx-f', r);
  const nw = $('.prx-new', r);
  const all = [
    { bg:'#5b6472', hex:'#5B6472', ratio:'5.9 : 1', conf:84, name:'Suggested', d:'Balanced with the primary' },
    { bg:'#475161', hex:'#475161', ratio:'7.4 : 1', conf:62, name:'Darker', d:'Stronger than the primary' },
    { bg:'#116ff8', hex:'#116FF8', ratio:'4.6 : 1', conf:41, name:'Accent outline', d:'Competes with the CTA' }
  ];
  let cur = 0;
  const alts = $('.prx-alts', r);
  function conf(v){ tween(+pc.textContent.replace('%','') || 0, v, 700, x => { pc.textContent = Math.round(x) + '%'; segs.forEach((s, i) => s.classList.toggle('on', x >= (i + 1) * 20 - 10)); }); }
  function list(){ alts.innerHTML = all.map((o, i) => i === cur ? '' : `<button class="prx-a" data-i="${i}"><span class="prx-c" style="background:${o.bg}"></span><span>${o.name}, ${o.ratio}</span><span class="d">${o.d}</span></button>`).join(''); $$('.prx-a', alts).forEach(a => a.onclick = () => apply(+a.dataset.i)); }
  function apply(i){ cur = i; const o = all[i]; $('.prx-c', nw).style.background = o.bg; swap($('.num', nw), o.hex); swap($('.prx-rt', nw), o.ratio); conf(o.conf); list(); stagger($$('.prx-a', alts), 50); }
  at.onclick = () => { const op = alt.classList.toggle('shut'); at.classList.toggle('open', !op); };
  list();
  let saved;
  const reset = () => { f.innerHTML = saved; bind(); };
  function bind(){
    $('.prx-ok', r).onclick = () => { saved = f.innerHTML; alt.classList.add('shut'); at.classList.remove('open');
      f.innerHTML = `<span class="prx-done">${I('ok')}<span>Applied to 3 buttons</span></span><button class="pill ghost sm prx-un">${I('undo','xs')}Undo</button>`; stagger([...f.children], 60);
      $('.prx-un', r).onclick = reset; };
    $('.prx-no', r).onclick = () => { saved = f.innerHTML; f.innerHTML = `<span class="prx-done muted" style="color:var(--muted)">Dismissed. The agent will not suggest this again.</span><button class="pill ghost sm prx-un">Undo</button>`; stagger([...f.children], 60); $('.prx-un', r).onclick = reset; };
  }
  bind(); c.later(400, () => conf(84));
});
06

Questions

What people ask about it.

Which option is the agent's suggestion?
The first one in options. defaultValue is 0, so it shows as the change until the person picks another.
How do I apply the change when someone accepts it?
Handle onAccept(option, index). The card only records the decision, so applying and reverting the change happens in your code, with onUndo for the revert.
Can I use it for something other than colours?
Each value has a color for its swatch and a free-text label and badge, so it fits any change that can be shown as a swatch and a short value.
07

More in Review and approve

Moments where a person checks, steers or signs off on the agent's work.

Change reviewAccept or reject each change the agent made, file by file.
Plan reviewCheck the agent's plan, edit steps, then let it run.
Permission gateThe agent asks before it touches something new.
Hand-off to a personThe agent hands a conversation to a person, with what they need to pick it up.
Decision promptThe agent asks a few quick questions before it starts.
Design tunerTweak a component by hand or let the agent tune it.
Rewrite toolbarSelect a sentence and let the agent rewrite it in place.
Launch checklistThe agent checks a design before it ships, and fixes what fails.
All 14 in Review and approve →