Review and approveProposal card
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callProposal 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.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add proposal-cardReact
npx designme add proposal-card --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.
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.
- The current value (
from) and the chosen option sit side by side as swatches with a label and an optional badge, such as a contrast ratio. - A five-segment meter and a percentage show the chosen option's
confidence. It fills in shortly after the card appears and follows each new pick. - The first entry in
optionsis the agent's pick. The others sit behind an "N other options" toggle, and clicking one makes it the change. - Accept calls
onAcceptwith the chosen option and its index, closes the options list and showsacceptedLabel. - Not now shows
dismissedLabel. Both end states offer Undo, which reopens the card and callsonUndo. - Badge tones default to bad for the current value and ok for the options, and can be set per value with
tone.
- The confidence bar has
role="meter"witharia-valuemin,aria-valuemax,aria-valuenowand anaria-label. - The other-options toggle is a button with
aria-expanded. - When the options list is closed it is
inertandaria-hidden. - With reduced motion turned on, the confidence count jumps straight to its value.
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>
);
}
Props
Everything ProposalCard takes.
| Prop | Type | What it does |
|---|---|---|
titlerequired | ReactNode | What the agent wants to change |
subtitle | ReactNode | Where, like "Pricing, frame 2 of 3" |
icon | IconName | The icon in the orange tile. Defaults to a wand |
fromrequired | ProposalValue | How it is now |
optionsrequired | ProposalOption[] | The changes on offer, the agent's pick first |
value | number | Index of the option shown as the change. Leave it out to let the list pick it |
defaultValue | number | Defaults to 0, the agent's pick |
onValueChange | (index: number) => void | |
open | boolean | Whether the other options are listed. Leave it out to let their toggle open them |
defaultOpen | boolean | |
onOpenChange | (open: boolean) => void | |
status | ProposalStatus | open, accepted or dismissed. Leave it out to let Accept, Not now and Undo move it |
defaultStatus | ProposalStatus | |
onStatusChange | (status: ProposalStatus) => void | |
onAccept | (option: ProposalOption, index: number) => void | Called with the chosen option when Accept is pressed |
onDismiss | () => void | |
onUndo | () => void | |
acceptedLabel | string | Shown once accepted, like "Applied to 3 buttons" |
dismissedLabel | string | Shown once dismissed |
confidenceLabel | string | |
bare | boolean | Sits flat in a chat thread (the default, as on designme.dev). Set false to give it a card of its own |
className | string |
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));
});
Questions
What people ask about it.
- Which option is the agent's suggestion?
- The first one in
options.defaultValueis 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, withonUndofor the revert. - Can I use it for something other than colours?
- Each value has a
colorfor its swatch and a free-textlabelandbadge, so it fits any change that can be shown as a swatch and a short value.
More in Review and approve
Moments where a person checks, steers or signs off on the agent's work.