Review and approveDecision prompt
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callDecision prompt
The agent asks a few quick questions before it starts. 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 decision-promptReact
npx designme add decision-prompt --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.
Decision prompt lets an agent ask a few multiple-choice questions before it starts a task, one question at a time. When the last one is answered it shows a short summary of the choices and a line that says the agent is getting going.
- When a request is vague and two or three answers would change what the agent does.
- When you want quick answers by keyboard instead of a long reply in chat.
- When some questions can be left to the agent and people should be able to skip them.
- When the person should see a recap of their choices before the work begins.
- Shows one question at a time with a "Question 2 of 3" counter and progress dots.
- Each option shows a number badge, its
labeland an optionaldetailsuch as "Recommended". - Continue saves the picked option and moves on. Skip records
null, which the summary shows asskippedLabel("Agent decides" by default). - Double-clicking an option picks it and continues in one go.
onCompletefires with every answer once the last question is done. Answers can also be controlled withanswersandonAnswersChange.- The summary ends with a
startLabelline that shimmers until you setstarted, then settles with a check andstartedLabel.
- Options sit in a
radiogrouplabelled with the question, and each option is aradiowitharia-checked. - Number keys 1 to 9 pick an option, Arrow Up and Arrow Down move the choice, and Enter continues.
- The question area is an
aria-live="polite"region, so each new question and the summary are announced. - Once all questions are answered the Skip and Continue footer is made
inert.
Usage
Import it and drive it from your own state.
Import
import { DecisionPrompt } from '@/components/designme/decision-prompt/DecisionPrompt';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 { DecisionPrompt, type DecisionQuestion } from './DecisionPrompt';
/* ---------- Decision prompt: three quick questions before a redesign, then a summary ---------- */
const QUESTIONS: DecisionQuestion[] = [
{ question: 'Which frames should the agent redesign?', label: 'Frames', options: [{ label: 'Pricing only', detail: '1 frame' }, { label: 'Pricing and checkout', detail: '3 frames' }, { label: 'The whole flow', detail: '9 frames' }] },
{ question: 'How bold can the changes be?', label: 'Scope', options: [{ label: 'Polish spacing and type', detail: 'Safe' }, { label: 'Rework the layout', detail: 'Medium' }, { label: 'Fresh direction', detail: 'Bold' }] },
{ question: 'Where should the result go?', label: 'Output', options: [{ label: 'New Figma page', detail: 'Keeps the original' }, { label: 'Branch in the repo', detail: 'Opens a PR' }, { label: 'Both', detail: 'Recommended' }] }
];
export default function DecisionPromptDemo() {
return (
<div style={{ width: '100%' }}>
<DecisionPrompt questions={QUESTIONS} startLabel="Starting the redesign" />
</div>
);
}
Props
Everything DecisionPrompt takes.
| Prop | Type | What it does |
|---|---|---|
questionsrequired | DecisionQuestion[] | |
answers | (number | null)[] | The answers so far, an option's index or null for a skipped question. Leave it out to let Continue and Skip fill it |
defaultAnswers | (number | null)[] | |
onAnswersChange | (answers: (number | null)[]) => void | |
onComplete | (answers: (number | null)[]) => void | Called once every question is answered, with the picked option's index for each, or null where the person skipped |
skippedLabel | string | What the summary says for a skipped question |
startLabel | string | The line under the summary while the agent gets going |
started | boolean | The agent has got going: the line under the summary settles with a check |
startedLabel | string | What that line says once started. Defaults to startLabel |
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.
DecisionPrompt.tsxReact · 106 lines
'use client';
import { useState, type KeyboardEvent } from 'react';
import { cx } from '../lib/cx';
import { useControllable } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { enter, Think } from '../lib/motion';
import '../designme.css';
import './decision-prompt.css';
export interface DecisionOption {
label: string;
/** Quiet text at the end of the row, like "3 frames" or "Recommended" */
detail?: string;
}
export interface DecisionQuestion {
question: string;
/** A short name for the question in the summary, like "Scope" */
label: string;
options: DecisionOption[];
}
export interface DecisionPromptProps {
questions: DecisionQuestion[];
/** The answers so far, an option's index or null for a skipped question. Leave it out to let Continue and Skip fill it */
answers?: (number | null)[];
defaultAnswers?: (number | null)[];
onAnswersChange?: (answers: (number | null)[]) => void;
/** Called once every question is answered, with the picked option's index for each, or null where the person skipped */
onComplete?: (answers: (number | null)[]) => void;
/** What the summary says for a skipped question */
skippedLabel?: string;
/** The line under the summary while the agent gets going */
startLabel?: string;
/** The agent has got going: the line under the summary settles with a check */
started?: boolean;
/** What that line says once started. Defaults to startLabel */
startedLabel?: 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;
}
/** The agent asks a few quick questions before it starts. Number keys pick, Enter continues. */
export function DecisionPrompt({
questions, answers: value, defaultAnswers = [], onAnswersChange, onComplete, skippedLabel = 'Agent decides', startLabel = 'Starting',
started = false, startedLabel, bare = true, className
}: DecisionPromptProps) {
const [answers, setAnswers] = useControllable(value, defaultAnswers, onAnswersChange);
const [sel, setSel] = useState(0);
const n = questions.length, at = answers.length, done = at >= n, q = questions[Math.min(at, n - 1)];
const next = (skipped = false) => {
if (done) return;
const all = [...answers, skipped ? null : sel];
setAnswers(all); setSel(0);
if (all.length >= n) onComplete?.(all);
};
const key = (e: KeyboardEvent) => {
if (done) return;
const count = q.options.length;
if (/^[1-9]$/.test(e.key) && +e.key <= count) { setSel(+e.key - 1); e.preventDefault(); }
else if (e.key === 'Enter') { next(); e.preventDefault(); }
else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { setSel((sel + (e.key === 'ArrowDown' ? 1 : count - 1)) % count); e.preventDefault(); }
};
return (
<div data-dm="" className={cx('dm-dpx', !bare && 'dm-panel', className)} tabIndex={0} onKeyDown={key}>
<div className="dm-dpx-h">
<span className="dm-dpx-k dm-muted dm-num">{done ? 'All set' : `Question ${at + 1} of ${n}`}</span>
<div className="dm-dpx-dots" aria-hidden="true">{questions.map((_, i) => <i key={i} className={cx(!done && i === at && 'dm-on', (done || i < at) && 'dm-done')} />)}</div>
</div>
{/* keyed by question, so each one plays its entrance */}
<div className="dm-dpx-body" key={done ? 'sum' : at} aria-live="polite">
{done ? (
<div className="dm-dpx-sum">
{questions.map((x, i) => (
<div key={i} className={cx('dm-dpx-sr', enter().className)} style={enter(i * 70).style}>
<span>{x.label}</span>{answers[i] === null ? skippedLabel : x.options[answers[i]!]?.label}
</div>
))}
<div className={cx('dm-dpx-run', enter().className)} style={enter(n * 70).style}>{started
? <><span style={{ color: 'var(--dm-ok)' }}><Icon name="check" size="xs" /></span><span>{startedLabel ?? startLabel}</span></>
: <><Think /><span className="dm-shim">{startLabel}</span></>}</div>
</div>
) : (
<>
<div className="dm-dpx-q dm-enter">{q.question}</div>
<div role="radiogroup" aria-label={q.question}>
{q.options.map((o, i) => (
<button key={i} type="button" role="radio" aria-checked={i === sel} className={cx('dm-dpx-o', i === sel && 'dm-on', enter().className)} style={enter(60 + i * 50).style}
onClick={() => setSel(i)} onDoubleClick={() => next()}>
<span className="dm-kbd">{i + 1}</span>{o.label}{o.detail && <span className="dm-d">{o.detail}</span>}
</button>
))}
</div>
</>
)}
</div>
<div className={cx('dm-dpx-f', done && 'dm-leave')} inert={done}>
<button type="button" className="dm-pill dm-ghost dm-sm dm-dpx-skip" onClick={() => next(true)}>Skip</button>
<button type="button" className="dm-pill dm-primary dm-sm dm-dpx-go" onClick={() => next()}>Continue <span className="dm-kbd">↵</span></button>
</div>
</div>
);
}
decision-prompt.cssReact · 27 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Decision prompt ---------- */
/* the agent asks in the chat: no container; with .panel it becomes a card */
.dm-dpx{padding:var(--dm-s-4) 0;display:flex;flex-direction:column;gap:var(--dm-s-3);outline:0;border-radius:var(--dm-r-in)}
.dm-dpx.dm-panel{padding:var(--dm-s-4);border-radius:var(--dm-r-card)}
.dm-dpx:focus-visible{outline:2px solid var(--dm-focus);outline-offset:6px}
.dm-dpx-h{display:flex;align-items:center;justify-content:space-between}
.dm-dpx-k{font-size:12px}
.dm-dpx-dots{display:flex;gap:var(--dm-s-1)}
.dm-dpx-dots i{width:6px;height:6px;border-radius:3px;background:var(--dm-line);transition:width .45s var(--dm-ease),background .35s}
.dm-dpx-dots i.dm-on{width:16px;background:var(--dm-accent-mark,var(--dm-accent))}
.dm-dpx-dots i.dm-done{background:var(--dm-accent-mark,var(--dm-accent));opacity:.4}
.dm-dpx-body{min-height:168px}
.dm-dpx-q{font-size:14px;font-weight:500;margin-bottom:10px;text-wrap:balance}
.dm-dpx-o{display:flex;align-items:center;gap:10px;width:calc(100% + 20px);margin-inline:-10px;padding:9px 10px;border-radius:var(--dm-r-in);font-size:13.5px;text-align:left;color:var(--dm-fg2);transition:background .25s,color .25s}
@media (hover:hover) and (pointer:fine){.dm-dpx-o:hover{background:var(--dm-row);color:var(--dm-fg)}}
.dm-dpx-o.dm-on{background:var(--dm-sel);color:var(--dm-fg)}
.dm-dpx-o .dm-kbd{width:20px;text-align:center;transition:background .25s,color .25s}
.dm-dpx-o.dm-on .dm-kbd{background:var(--dm-accent);color:var(--dm-on-accent)}
.dm-dpx-o .dm-d{margin-left:auto;font-size:12px;color:var(--dm-muted)}
.dm-dpx-f{display:flex;justify-content:flex-end;gap:6px}
.dm-dpx-sum{display:flex;flex-direction:column;gap:6px}
.dm-dpx-sr{display:flex;gap:10px;font-size:13px;padding:7px 0;border-bottom:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}
.dm-dpx-sr:last-child{border:0}
.dm-dpx-sr span{color:var(--dm-muted);width:92px;flex:none}
.dm-dpx-run{display:flex;align-items:center;gap:var(--dm-s-2);font-size:13px;margin-top:6px}
decision-prompt.htmlHTML · 10 lines
<div class="card bare" data-c="decision-prompt">
<div class="stage-in">
<div class="dpx" tabindex="0">
<div class="dpx-h"><span class="dpx-k muted num">Question 1 of 3</span><div class="dpx-dots"><i class="on"></i><i></i><i></i></div></div>
<div class="dpx-body"></div>
<div class="dpx-f"><button class="pill ghost sm dpx-skip">Skip</button><button class="pill primary sm dpx-go">Continue <span class="kbd">↵</span></button></div>
</div>
</div>
</div>
decision-prompt.cssHTML · 26 lines
/* ---------- Decision prompt ---------- */
/* the agent asks in the chat: no container; with .panel it becomes a card */
.dpx{padding:var(--s-4) 0;display:flex;flex-direction:column;gap:var(--s-3);outline:0;border-radius:var(--r-in)}
.dpx.panel{padding:var(--s-4);border-radius:var(--r-card)}
.dpx:focus-visible{outline:2px solid var(--focus);outline-offset:6px}
.dpx-h{display:flex;align-items:center;justify-content:space-between}
.dpx-k{font-size:12px}
.dpx-dots{display:flex;gap:var(--s-1)}
.dpx-dots i{width:6px;height:6px;border-radius:3px;background:var(--line);transition:width .45s var(--ease),background .35s}
.dpx-dots i.on{width:16px;background:var(--accent-mark,var(--accent))}
.dpx-dots i.done{background:var(--accent-mark,var(--accent));opacity:.4}
.dpx-body{min-height:168px}
.dpx-q{font-size:14px;font-weight:500;margin-bottom:10px;text-wrap:balance}
.dpx-o{display:flex;align-items:center;gap:10px;width:calc(100% + 20px);margin-inline:-10px;padding:9px 10px;border-radius:var(--r-in);font-size:13.5px;text-align:left;color:var(--fg2);transition:background .25s,color .25s}
@media (hover:hover) and (pointer:fine){.dpx-o:hover{background:var(--row);color:var(--fg)}}
.dpx-o.on{background:var(--sel);color:var(--fg)}
.dpx-o .kbd{width:20px;text-align:center;transition:background .25s,color .25s}
.dpx-o.on .kbd{background:var(--accent);color:var(--on-accent)}
.dpx-o .d{margin-left:auto;font-size:12px;color:var(--muted)}
.dpx-f{display:flex;justify-content:flex-end;gap:6px}
.dpx-sum{display:flex;flex-direction:column;gap:6px}
.dpx-sr{display:flex;gap:10px;font-size:13px;padding:7px 0;border-bottom:1px solid color-mix(in srgb,var(--line) 50%,transparent)}
.dpx-sr:last-child{border:0}
.dpx-sr span{color:var(--muted);width:92px;flex:none}
.dpx-run{display:flex;align-items:center;gap:var(--s-2);font-size:13px;margin-top:6px}
decision-prompt.jsHTML · 30 lines
DM.def('decision-prompt', (r, c) => {
const Q = [
['Which frames should the agent redesign?', 'Frames', [['Pricing only','1 frame'],['Pricing and checkout','3 frames'],['The whole flow','9 frames']]],
['How bold can the changes be?', 'Scope', [['Polish spacing and type','Safe'],['Rework the layout','Medium'],['Fresh direction','Bold']]],
['Where should the result go?', 'Output', [['New Figma page','Keeps the original'],['Branch in the repo','Opens a PR'],['Both','Recommended']]]
];
const ans = []; let qi = 0, sel = 0, done = false;
const body = $('.dpx-body', r), k = $('.dpx-k', r), dots = $$('.dpx-dots i', r), go = $('.dpx-go', r), skip = $('.dpx-skip', r), card = $('.dpx', r);
function paint(){
k.textContent = 'Question ' + (qi + 1) + ' of 3';
dots.forEach((d, i) => { d.classList.toggle('on', i === qi); d.classList.toggle('done', i < qi); });
const q = Q[qi];
body.innerHTML = `<div class="dpx-q">${q[0]}</div>` + q[2].map((o, i) => `<button class="dpx-o${i === sel ? ' on' : ''}" data-i="${i}"><span class="kbd">${i + 1}</span>${o[0]}<span class="d">${o[1]}</span></button>`).join('');
enter($('.dpx-q', body)); stagger($$('.dpx-o', body), 50, 60);
$$('.dpx-o', body).forEach(b => { b.onclick = () => choose(+b.dataset.i); b.ondblclick = next; });
}
function choose(i){ sel = i; $$('.dpx-o', body).forEach((b, j) => b.classList.toggle('on', j === i)); }
function next(skipped){
if (done) return; ans.push(skipped === true ? 'Agent decides' : Q[qi][2][sel][0]); qi++; sel = 0;
if (qi < Q.length) return paint();
done = true; dots.forEach(d => { d.classList.remove('on'); d.classList.add('done'); }); k.textContent = 'All set';
go.parentElement.classList.add('leave');
body.innerHTML = `<div class="dpx-sum">${Q.map((q, i) => `<div class="dpx-sr"><span>${q[1]}</span>${ans[i]}</div>`).join('')}<div class="dpx-run"><span class="think on"><i></i><i></i><i></i><i></i></span><span class="shim">Starting the redesign</span></div></div>`;
stagger($$('.dpx-sr, .dpx-run', body), 70);
}
go.onclick = () => next(); skip.onclick = () => next(true);
c.on(card, 'keydown', e => { if (done) return; if (/^[1-3]$/.test(e.key)){ choose(+e.key - 1); e.preventDefault(); } else if (e.key === 'Enter'){ next(); e.preventDefault(); } else if (e.key === 'ArrowDown' || e.key === 'ArrowUp'){ choose((sel + (e.key === 'ArrowDown' ? 1 : 2)) % 3); e.preventDefault(); } });
paint();
});
Questions
What people ask about it.
- How do I get the answers to my agent?
- Use
onComplete. It receives an array with the chosen option's index for each question, ornullwhere the person skipped. - Does the keyboard work right away?
- The whole prompt is focusable, so the keys work once it has focus, for example after clicking an option. Number keys pick, arrows move and Enter continues.
- How do I show that the agent has actually started?
- Set
startedto true. The shimmeringstartLabelline turns into a check withstartedLabel, or the same text if you leave that out.
More in Review and approve
Moments where a person checks, steers or signs off on the agent's work.