ConversationPrompt starters
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callPrompt starters
Suggested tasks that fill the composer in one click. 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 prompt-startersReact
npx designme add prompt-starters --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.
Prompt starters shows a grid of suggested tasks as cards, each with an icon, a short title and a one-line description. Picking a card writes its full prompt into the field below, word by word, and the Send button passes that prompt to your agent.
- On an empty chat or a new workspace, when people don't know what to ask the agent first.
- When you want to show what the agent is good at with real, ready-to-send tasks.
- When you have more suggestions than fit on screen and want a Shuffle button to swap in another set.
- Starters are laid out two to a row and stagger in. Each one has an
icon, an optionaltonethat colours the icon, atitle, adescriptionand thepromptit writes. - Picking a card highlights it and types its prompt into the field at a word every 40 milliseconds.
- The picked starter is tracked by its title and can be controlled with
valueandonValueChange. Settingvaluefrom outside fills the field the same way a click does. - Send is enabled once a starter is picked. It calls
onSendwith the prompt and then showssentLabel, which defaults to "Sent to the agent". onShuffleadds a Shuffle button. Pass a newstartersset when it is called, and the cards stagger in again while the field clears.
- Each starter card is a button with
aria-pressedset on the picked one. - The Send icon button is labelled "Send" and stays disabled until a starter is picked.
- With
prefers-reduced-motion: reduce, the prompt appears at once instead of word by word.
Usage
Import it and drive it from your own state.
Import
import { PromptStarters } from '@/components/designme/prompt-starters/PromptStarters';Example
The demo above as a React component, 31 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 { useState } from 'react';
import { PromptStarters, type Starter } from './PromptStarters';
/* ---------- Prompt starters: two sets of four; the box starts empty, a pick fills it, Shuffle swaps between them ---------- */
const SETS: Starter[][] = [
[
{ icon: 'grid', tone: 'blue', title: 'Audit a page', description: 'Check the pricing page for spacing and contrast', prompt: 'Audit pricing for spacing and contrast' },
{ icon: 'edit', tone: 'purple', title: 'Rewrite copy', description: 'Make the onboarding intro shorter', prompt: 'Make the onboarding intro shorter' },
{ icon: 'code', tone: 'green', title: 'Export tokens', description: 'Turn colour and spacing into CSS variables', prompt: 'Export the tokens as CSS variables' },
{ icon: 'layers', tone: 'orange', title: 'Compare frames', description: 'Diff the mobile and desktop frames', prompt: 'Diff the mobile and desktop frames' }
],
[
{ icon: 'chart', tone: 'teal', title: 'Explain a metric', description: 'Why did activation drop last week', prompt: 'Why did activation drop last week?' },
{ icon: 'mail', tone: 'pink', title: 'Draft an email', description: 'Announce the new pricing to customers', prompt: 'Announce the new pricing by email' },
{ icon: 'alert', tone: 'red', title: 'Find a bug', description: 'Trace why checkout logs people out', prompt: 'Fix checkout logging people out' },
{ icon: 'figma', tone: 'purple', title: 'Build from Figma', description: 'Turn the hero frame into code', prompt: 'Build the hero frame in React' }
]
];
export default function PromptStartersDemo() {
const [set, setSet] = useState(0);
const [picked, setPicked] = useState<string | null>(null);
return (
<div style={{ width: '100%' }}>
<PromptStarters starters={SETS[set]} value={picked} onValueChange={setPicked} onShuffle={() => { setPicked(null); setSet(s => (s + 1) % SETS.length); }} />
</div>
);
}
Props
Everything PromptStarters takes.
| Prop | Type | What it does |
|---|---|---|
startersrequired | Starter[] | The starters to show, two to a row. Pass a new set (from onShuffle) and they stagger in again |
value | string | null | The title of the picked starter. Leave it out to let the cards pick it |
defaultValue | string | null | |
onValueChange | (title: string | null) => void | |
onSend | (prompt: string) => void | Called with the prompt when the person presses Send |
onShuffle | () => void | Adds a Shuffle button under the field. Swap in another set of starters when it is called |
placeholder | string | |
sentLabel | string | Shown in the field once the prompt is sent |
className | string |
Code
The full source, as the CLI installs it.
PromptStarters.tsxReact · 103 lines
'use client';
import { useEffect, useState } from 'react';
import { cx } from '../lib/cx';
import { useControllable, useReducedMotion } from '../lib/hooks';
import { Icon, type IconName } from '../lib/icons';
import { enter, Tip } from '../lib/motion';
import '../designme.css';
import './prompt-starters.css';
export type StarterTone = 'blue' | 'purple' | 'green' | 'orange' | 'red' | 'yellow' | 'pink' | 'teal';
export interface Starter {
icon: IconName;
/** Colours the icon */
tone?: StarterTone;
/** A few words, like "Audit a page" */
title: string;
/** One quiet line under the title */
description: string;
/** The full prompt it writes into the field when picked */
prompt: string;
}
export interface PromptStartersProps {
/** The starters to show, two to a row. Pass a new set (from onShuffle) and they stagger in again */
starters: Starter[];
/** The title of the picked starter. Leave it out to let the cards pick it */
value?: string | null;
defaultValue?: string | null;
onValueChange?: (title: string | null) => void;
/** Called with the prompt when the person presses Send */
onSend?: (prompt: string) => void;
/** Adds a Shuffle button under the field. Swap in another set of starters when it is called */
onShuffle?: () => void;
placeholder?: string;
/** Shown in the field once the prompt is sent */
sentLabel?: string;
className?: string;
}
const TONE: Record<StarterTone, string> = { blue: 'pb', purple: 'pl', green: 'pm', orange: 'pp', red: 'pr', yellow: 'py', pink: 'pk', teal: 'pt' };
/** Suggested first prompts as cards. Picking one writes its prompt into the field, word by word. */
export function PromptStarters({ starters, value, defaultValue = null, onValueChange, onSend, onShuffle, placeholder = 'Pick a starter or type your own', sentLabel = 'Sent to the agent', className }: PromptStartersProps) {
const [picked, setPicked] = useControllable(value, defaultValue, onValueChange);
const reduce = useReducedMotion();
const set = starters.map(s => s.title).join('\u0000');
// what the field shows: the picked prompt as it is typed, or the line after sending
const [field, setField] = useState<{ key: number; words: string[]; sent: boolean } | null>(null);
const [typed, setTyped] = useState(0);
const fill = (s: Starter) => { setField(f => ({ key: (f?.key ?? 0) + 1, words: s.prompt.split(' '), sent: false })); setTyped(0); };
const pick = (s: Starter) => { setPicked(s.title); setFilledFor(s.title); fill(s); };
// a starter picked from outside, through value, fills the field the same way a click does
const [filledFor, setFilledFor] = useState(picked);
if (picked !== filledFor) { setFilledFor(picked); const s = starters.find(x => x.title === picked); if (s) fill(s); }
// a word every 40ms
useEffect(() => {
if (!field || field.sent || typed >= field.words.length) return;
const t = setTimeout(() => setTyped(n => n + 1), typed === 0 ? 0 : reduce ? 0 : 40);
return () => clearTimeout(t);
}, [field, typed, reduce]);
// a new set of starters clears the field
const [shown, setShown] = useState(set);
if (shown !== set) { setShown(set); setField(null); setTyped(0); }
useEffect(() => { if (picked !== null && !starters.some(s => s.title === picked)) setPicked(null); }, [set]); // eslint-disable-line react-hooks/exhaustive-deps
const send = () => {
const s = starters.find(x => x.title === picked); if (!s || !field || field.sent) return;
onSend?.(s.prompt);
setField(f => ({ key: (f?.key ?? 0) + 1, words: [sentLabel], sent: true }));
setPicked(null);
};
const ready = !!field && !field.sent && picked !== null;
return (
<div data-dm="" className={cx('dm-psx', className)}>
<div className="dm-psx-grid" key={set}>
{starters.map((s, i) => {
const e = enter(i * 60), tone = s.tone && TONE[s.tone];
return (
<button type="button" key={s.title} className={cx('dm-psx-c', e.className, picked === s.title && 'dm-on')} style={e.style} aria-pressed={picked === s.title} onClick={() => pick(s)}>
<span className="dm-psx-ic" style={tone ? { color: `var(--dm-${tone})` } : undefined}><Icon name={s.icon} /></span>
<b>{s.title}</b>
<span className="dm-d">{s.description}</span>
</button>
);
})}
</div>
<div className={cx('dm-psx-in', field && 'dm-has')}>
<span className="dm-psx-ph">{placeholder}</span>
<span className="dm-psx-tx" key={field?.key}>
{field?.sent ? <span className="dm-w">{field.words[0]}</span> : field?.words.slice(0, typed).map((w, i) => <span key={i} className="dm-w">{w} </span>)}
</span>
<Tip label="Send">
<button type="button" className="dm-psx-go" aria-label="Send" disabled={!ready} onClick={send}><Icon name="up" /></button>
</Tip>
</div>
{onShuffle && <button type="button" className="dm-pill dm-ghost dm-sm dm-psx-sh" onClick={onShuffle}><Icon name="refresh" size="xs" />Shuffle</button>}
</div>
);
}
prompt-starters.cssReact · 24 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Prompt starters ---------- */
.dm-psx{display:flex;flex-direction:column;gap:10px;align-items:center}
.dm-psx-grid{display:grid;grid-template-columns:1fr 1fr;gap:var(--dm-s-2);width:100%}
/* white cards with a dimmed grey edge; the picked one's edge is a step stronger */
.dm-psx-c{display:flex;flex-direction:column;gap:6px;align-items:flex-start;text-align:left;padding:var(--dm-s-3);border-radius:14px;background:var(--dm-chip);transition:background .25s,box-shadow .25s}
@media (hover:hover) and (pointer:fine){.dm-psx-c:hover{background:var(--dm-hover)}}
.dm-psx-c.dm-on{background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-rail) 50%,transparent),0 1px 2px rgba(0,0,0,.04)}
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]) .dm-psx-c.dm-on{background:var(--dm-hover)}}
:root[data-theme="dark"] .dm-psx-c.dm-on{background:var(--dm-hover)}
.dm-psx-ic{width:28px;height:28px;border-radius:9px;display:grid;place-items:center;background:var(--dm-surface)!important}
.dm-psx-c b{font-weight:500;font-size:13.5px}
.dm-psx-c span.dm-d{font-size:12px;color:var(--dm-muted);line-height:1.4}
.dm-psx-in{display:flex;align-items:center;gap:var(--dm-s-2);width:100%;min-height:46px;padding:6px 6px 6px var(--dm-s-4);border-radius:var(--dm-r-pill);background:var(--dm-surface);box-shadow:var(--dm-shadow);font-size:13.5px;position:relative}
.dm-psx-ph{color:var(--dm-muted);transition:opacity .3s}
.dm-psx-in.dm-has .dm-psx-ph{display:none}
.dm-psx-tx{flex:1;min-width:0}
.dm-psx-tx .dm-w{animation:dm-lvw .4s var(--dm-ease) both}
@keyframes dm-lvw{from{opacity:0;filter:blur(3px)}to{opacity:1;filter:none}}
.dm-psx-go{margin-left:auto;width:32px;height:32px;border-radius:50%;display:grid;place-items:center;background:var(--dm-accent);color:var(--dm-on-accent);flex:none;transition:opacity .3s}
.dm-psx-go[disabled]{opacity:1;cursor:not-allowed;background:var(--dm-dis-hatch),var(--dm-dis-fill);color:var(--dm-dis);box-shadow:var(--dm-soft-ring)}
.dm-psx-sh svg{transition:transform .6s var(--dm-ease)}
@media (hover:hover) and (pointer:fine){.dm-psx-sh:hover svg{transform:rotate(180deg)}}
prompt-starters.htmlHTML · 10 lines
<div class="card bare" data-c="prompt-starters">
<div class="stage-in">
<div class="psx">
<div class="psx-grid"></div>
<div class="psx-in"><span class="psx-ph">Pick a starter or type your own</span><span class="psx-tx"></span><button class="psx-go" aria-label="Send" data-tip="Send" disabled><svg class="i s"><use href="#i-up"/></svg></button></div>
<button class="pill ghost sm psx-sh"><svg class="i xs"><use href="#i-refresh"/></svg>Shuffle</button>
</div>
</div>
</div>
prompt-starters.cssHTML · 23 lines
/* ---------- Prompt starters ---------- */
.psx{display:flex;flex-direction:column;gap:10px;align-items:center}
.psx-grid{display:grid;grid-template-columns:1fr 1fr;gap:var(--s-2);width:100%}
/* white cards with a dimmed grey edge; the picked one's edge is a step stronger */
.psx-c{display:flex;flex-direction:column;gap:6px;align-items:flex-start;text-align:left;padding:var(--s-3);border-radius:14px;background:var(--chip);transition:background .25s,box-shadow .25s}
@media (hover:hover) and (pointer:fine){.psx-c:hover{background:var(--hover)}}
.psx-c.on{background:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--rail) 50%,transparent),0 1px 2px rgba(0,0,0,.04)}
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]) .psx-c.on{background:var(--hover)}}
:root[data-theme="dark"] .psx-c.on{background:var(--hover)}
.psx-ic{width:28px;height:28px;border-radius:9px;display:grid;place-items:center;background:var(--surface)!important}
.psx-c b{font-weight:500;font-size:13.5px}
.psx-c span.d{font-size:12px;color:var(--muted);line-height:1.4}
.psx-in{display:flex;align-items:center;gap:var(--s-2);width:100%;min-height:46px;padding:6px 6px 6px var(--s-4);border-radius:var(--r-pill);background:var(--surface);box-shadow:var(--shadow);font-size:13.5px;position:relative}
.psx-ph{color:var(--muted);transition:opacity .3s}
.psx-in.has .psx-ph{display:none}
.psx-tx{flex:1;min-width:0}
.psx-tx .w{animation:lvw .4s var(--ease) both}
@keyframes lvw{from{opacity:0;filter:blur(3px)}to{opacity:1;filter:none}}
.psx-go{margin-left:auto;width:32px;height:32px;border-radius:50%;display:grid;place-items:center;background:var(--accent);color:var(--on-accent);flex:none;transition:opacity .3s}
.psx-go[disabled]{opacity:1;cursor:not-allowed;background:var(--dis-hatch),var(--dis-fill);color:var(--dis);box-shadow:var(--soft-ring)}
.psx-sh svg{transition:transform .6s var(--ease)}
@media (hover:hover) and (pointer:fine){.psx-sh:hover svg{transform:rotate(180deg)}}
prompt-starters.jsHTML · 26 lines
DM.def('prompt-starters', (r, c) => {
r.innerHTML = r.innerHTML.replace('{up}', I('up')).replace('{shuffle}', I('refresh','xs'));
const sets = [
[['grid','var(--pb)','var(--pb-bg)','Audit a page','Check the pricing page for spacing and contrast','Audit pricing for spacing and contrast'],['edit','var(--pl)','var(--pl-bg)','Rewrite copy','Make the onboarding intro shorter','Make the onboarding intro shorter'],['code','var(--pm)','var(--pm-bg)','Export tokens','Turn colour and spacing into CSS variables','Export the tokens as CSS variables'],['layers','var(--pp)','var(--pp-bg)','Compare frames','Diff the mobile and desktop frames','Diff the mobile and desktop frames']],
[['chart','var(--pt)','var(--pt-bg)','Explain a metric','Why did activation drop last week','Why did activation drop last week?'],['mail','var(--pk)','var(--pk-bg)','Draft an email','Announce the new pricing to customers','Announce the new pricing by email'],['bug','var(--pr)','var(--pr-bg)','Find a bug','Trace why checkout logs people out','Fix checkout logging people out'],['figma','var(--pl)','var(--pl-bg)','Build from Figma','Turn the hero frame into code','Build the hero frame in React']]
];
let si = 0;
const grid = $('.psx-grid', r), tx = $('.psx-tx', r), inp = $('.psx-in', r), go = $('.psx-go', r);
function paint(){
grid.innerHTML = sets[si].map((s, i) => `<button class="psx-c" data-i="${i}"><span class="psx-ic" style="color:${s[1]};background:${s[2]}">${I(s[0] === 'bug' ? 'alert' : s[0])}</span><b>${s[3]}</b><span class="d">${s[4]}</span></button>`).join('');
stagger($$('.psx-c', grid), 60);
$$('.psx-c', grid).forEach(b => b.onclick = () => pick(b));
}
let k = null;
function pick(b){
$$('.psx-c', grid).forEach(x => x.classList.toggle('on', x === b));
if (k) k.stop(); k = ctl(c); const kk = k; c.offs.push(() => kk.stop());
tx.innerHTML = ''; inp.classList.add('has'); go.disabled = false;
kk.run(async () => { for (const w of sets[si][b.dataset.i][5].split(' ')){ tx.insertAdjacentHTML('beforeend', `<span class="w">${w} </span>`); await kk.wait(40); } });
}
$('.psx-sh', r).onclick = () => { si = (si + 1) % sets.length; tx.innerHTML = ''; inp.classList.remove('has'); go.disabled = true; paint(); };
go.onclick = () => { tx.innerHTML = '<span class="w">Sent to the agent</span>'; go.disabled = true; $$('.psx-c', grid).forEach(x => x.classList.remove('on')); };
// the box starts empty: a starter fills it when someone picks one, in one short line
paint();
});
Questions
What people ask about it.
- Can people edit the prompt before sending it?
- Not inside this component. Its field displays the picked prompt and is not a text input. To let people edit, look up the starter from the title
onValueChangegives you and put its prompt into your own composer. - How do I offer a different set of suggestions?
- Pass
onShuffleand changestarterswhen it is called. A new set clears the field and the picked starter, then the cards come in again. - Can I pick a starter for the person, for example on first load?
- Yes. Set
valueto the starter's title from your own state and its prompt is typed into the field as if it had been clicked.
More in Conversation
Everything for asking, answering and replying, by text or by voice.