Prompt Suggestions New
Starter prompts in category tabs for an empty chat, plus follow-up chips that cascade in after a reply.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/ai-prompt-suggestions.json02
Or copy the source below (2 files). Dependencies: lucide-react.
Source
components/niceui/ai/PromptSuggestions.tsx
import { useEffect, useRef, useState } from 'react';
import { ArrowUp, ArrowUpRight, BarChart3, Bot, Code2, CornerDownRight, GraduationCap, PenLine, type LucideIcon } from 'lucide-react';
import { cn } from '@/lib/cn';
import { Markdown, useStream } from './stream';
export type Suggestion = { title: string; prompt: string };
export type SuggestionCategory = { id: string; label: string; icon: LucideIcon; items: Suggestion[] };
export type PromptSuggestionsProps = {
categories: SuggestionCategory[];
onSelect: (prompt: string) => void;
className?: string;
};
/** Category tabs with starter cards for an empty chat. */
export function PromptSuggestions({ categories, onSelect, className }: PromptSuggestionsProps) {
const [tab, setTab] = useState(0);
const cat = categories[tab];
return (
<div className={cn('w-full', className)}>
<div role="tablist" aria-label="Suggestion categories" className="relative mx-auto grid w-full max-w-sm grid-cols-4 rounded-xl bg-ink-950/60 p-1 shadow-hairline">
<span
className="absolute inset-y-1 left-1 rounded-lg bg-ink-800 shadow-hairline transition-transform duration-300 ease-smooth"
style={{ width: `calc((100% - 8px) / ${categories.length})`, transform: `translateX(${tab * 100}%)` }}
/>
{categories.map((c, i) => (
<button
key={c.id}
type="button"
role="tab"
aria-selected={tab === i}
onClick={() => setTab(i)}
className={cn('focus-ring relative z-10 flex items-center justify-center gap-1.5 rounded-lg py-1.5 text-xs font-medium transition-colors', tab === i ? 'text-ink-50' : 'text-ink-400 hover:text-ink-200')}
>
<c.icon className="hidden h-3.5 w-3.5 sm:block" />
{c.label}
</button>
))}
</div>
<div key={cat.id} role="tabpanel" className="mt-4 grid grid-cols-1 gap-2 sm:grid-cols-2">
{cat.items.map((s, i) => (
<button
key={s.title}
type="button"
onClick={() => onSelect(s.prompt)}
className="focus-ring group relative flex min-w-0 animate-fade-up gap-3 rounded-xl bg-ink-850/70 p-3 text-left shadow-hairline transition-all duration-300 ease-smooth hover:-translate-y-0.5 hover:bg-ink-800 hover:shadow-[0_0_0_1px_rgba(125,211,252,0.25),0_12px_28px_-14px_rgba(56,189,248,0.45)]"
style={{ animationDelay: `${i * 60}ms` }}
>
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-white/5 text-brand-300 transition-colors group-hover:bg-brand-500/15">
<cat.icon className="h-3.5 w-3.5" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-medium text-ink-100">{s.title}</span>
<span className="mt-0.5 line-clamp-2 block text-xs leading-snug text-ink-500">{s.prompt}</span>
</span>
<ArrowUpRight className="h-3.5 w-3.5 shrink-0 text-ink-600 transition-all duration-300 group-hover:-translate-y-0.5 group-hover:translate-x-0.5 group-hover:text-brand-300" />
</button>
))}
</div>
</div>
);
}
export type FollowUpsProps = { items: string[]; show: boolean; onSelect: (text: string) => void; className?: string };
/** Follow-up chips that cascade in after a reply finishes. */
export function FollowUps({ items, show, onSelect, className }: FollowUpsProps) {
return (
<div className={cn('flex flex-wrap gap-1.5', className)} aria-hidden={!show}>
{items.map((t, i) => (
<button
key={t}
type="button"
tabIndex={show ? 0 : -1}
onClick={() => onSelect(t)}
className={cn(
'focus-ring group flex items-center gap-1.5 rounded-full bg-ink-850 py-1.5 pl-2.5 pr-3 text-xs text-ink-200 shadow-hairline transition-all duration-500 ease-spring hover:bg-ink-800 hover:text-ink-50 active:scale-95',
show ? 'translate-y-0 opacity-100' : 'pointer-events-none translate-y-2 opacity-0',
)}
style={{ transitionDelay: show ? `${i * 70}ms` : '0ms' }}
>
<CornerDownRight className="h-3 w-3 text-brand-300 transition-transform group-hover:translate-x-0.5" />
{t}
</button>
))}
</div>
);
}
const CATEGORIES: SuggestionCategory[] = [
{
id: 'write',
label: 'Write',
icon: PenLine,
items: [
{ title: 'Launch announcement', prompt: 'Draft a launch post for our new offline mode. Upbeat, under 120 words, with a clear call to action.' },
{ title: 'Polite follow-up', prompt: 'Write a friendly follow-up to a client who hasn’t replied in two weeks.' },
{ title: 'Rewrite for clarity', prompt: 'Rewrite this paragraph so a new hire understands it on first read.' },
{ title: 'Release notes', prompt: 'Turn these merged pull request titles into customer-facing release notes.' },
],
},
{
id: 'code',
label: 'Code',
icon: Code2,
items: [
{ title: 'Explain this hook', prompt: 'Explain what this useEffect does and whether it can loop forever.' },
{ title: 'Write the tests', prompt: 'Write unit tests for a debounce function, including the edge cases.' },
{ title: 'Fix a type error', prompt: 'Why does TypeScript say “string is not assignable to never” here?' },
{ title: 'SQL from English', prompt: 'Write a query for weekly active users by plan over the last 12 weeks.' },
],
},
{
id: 'analyze',
label: 'Analyze',
icon: BarChart3,
items: [
{ title: 'Find the churn signal', prompt: 'Look at this export of cancellations and tell me what predicts churn.' },
{ title: 'Summarize feedback', prompt: 'Group these 200 survey answers into themes, with counts and quotes.' },
{ title: 'Compare pricing', prompt: 'Compare our pricing page with three competitors in a table.' },
{ title: 'Check a forecast', prompt: 'Review this revenue forecast and flag any unrealistic assumptions.' },
],
},
{
id: 'learn',
label: 'Learn',
icon: GraduationCap,
items: [
{ title: 'Explain like I’m new', prompt: 'Explain vector databases to a product designer, with one everyday analogy.' },
{ title: 'Make a study plan', prompt: 'Build a four-week plan to learn Rust in five hours a week.' },
{ title: 'Quiz me', prompt: 'Quiz me on the CAP theorem with five questions, one at a time.' },
{ title: 'Compare concepts', prompt: 'What’s the difference between SSR, SSG and ISR? Use one example for all three.' },
],
},
];
const prefersReduced = () => typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
export function PromptSuggestionsDemo() {
const [text, setText] = useState('');
const [typing, setTyping] = useState(false);
const timer = useRef<number>();
useEffect(() => () => window.clearInterval(timer.current), []);
const fill = (prompt: string) => {
window.clearInterval(timer.current);
if (prefersReduced()) {
setText(prompt);
return;
}
let i = 0;
setTyping(true);
timer.current = window.setInterval(() => {
i += 2;
setText(prompt.slice(0, i));
if (i >= prompt.length) {
window.clearInterval(timer.current);
setTyping(false);
}
}, 18);
};
const hour = new Date().getHours();
const greeting = hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening';
return (
<div className="mx-auto w-full max-w-xl">
<div className="flex flex-col items-center text-center">
<span className="relative flex h-11 w-11 items-center justify-center">
<span className="absolute inset-0 animate-spin-slow rounded-full bg-[conic-gradient(from_0deg,#38bdf8,#a78bfa,#2dd4bf,#38bdf8)] opacity-80 blur-[6px]" />
<span className="relative flex h-10 w-10 items-center justify-center rounded-full bg-ink-900 ring-1 ring-white/10">
<Bot className="h-5 w-5 text-brand-300" />
</span>
</span>
<h3 className="mt-3 text-xl font-medium tracking-[-0.02em] text-ink-50">{greeting}, Maya</h3>
<p className="mt-1 text-sm text-ink-400">What are we working on today?</p>
</div>
<PromptSuggestions categories={CATEGORIES} onSelect={fill} className="mt-5" />
<div className="mt-4 flex items-end gap-2 rounded-2xl bg-ink-900 p-2 pl-4 shadow-card transition-shadow duration-300 focus-within:shadow-[0_0_0_1px_rgba(56,189,248,0.35),0_8px_32px_-8px_rgba(56,189,248,0.25)]">
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
readOnly={typing}
rows={2}
aria-label="Message"
placeholder="Pick a suggestion or ask anything…"
className="min-w-0 flex-1 resize-none bg-transparent py-1.5 text-sm leading-relaxed text-ink-50 outline-none placeholder:text-ink-500"
/>
<button
type="button"
disabled={!text.trim() || typing}
onClick={() => setText('')}
aria-label="Send message"
className={cn('focus-ring flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-all duration-300 ease-spring active:scale-90', text.trim() && !typing ? 'bg-brand-500 text-white shadow-glow hover:bg-brand-400' : 'bg-ink-800 text-ink-500')}
>
<ArrowUp className="h-4 w-4" />
</button>
</div>
</div>
);
}
const EXCHANGES: { q: string; a: string; next: string[] }[] = [
{
q: 'How should I cache API responses in my app?',
a: 'Start with **stale-while-revalidate**: serve the cached response instantly, then refresh it in the background.\n- Cache **GET** requests only, keyed by URL and user\n- Set a short max age, around `60s`, for data that changes often\n- Invalidate on writes instead of waiting for expiry',
next: ['Show me the code', 'When should I not cache?', 'Compare with a CDN'],
},
{
q: 'Show me the code',
a: 'Here’s the core of it, wrapped in a tiny helper:\n- Read from the cache with `cache.get(key)`\n- If it exists, return it and call `refresh(key)` without awaiting\n- Otherwise await `fetch()`, store the result and return it',
next: ['Add a max age', 'Make it work offline', 'Start over'],
},
{
q: 'When should I not cache?',
a: 'Skip the cache when freshness **is** the feature:\n- Balances, inventory and anything you can buy against\n- Responses that differ per permission level\n- Endpoints with side effects, even if they use GET',
next: ['Show me the code', 'Compare with a CDN', 'Start over'],
},
{
q: 'Compare with a CDN',
a: 'A CDN caches **between** users at the edge, while an app cache lives **inside** one client.\n- Use a CDN for public, identical responses\n- Use an app cache for per-user data and instant back-navigation\n- Most apps want both, with different max ages',
next: ['When should I not cache?', 'Show me the code', 'Start over'],
},
];
export function FollowUpsDemo() {
const [i, setI] = useState(0);
const ex = EXCHANGES[i];
const [reduced] = useState(prefersReduced);
const stream = useStream(ex.a, { speed: 24, enabled: !reduced });
const done = reduced || stream.done;
const ask = (t: string) => {
const j = t === 'Start over' ? 0 : EXCHANGES.findIndex((e) => e.q === t);
setI(j >= 0 ? j : (i + 1) % EXCHANGES.length);
stream.restart();
};
return (
<div className="mx-auto w-full max-w-xl space-y-4">
<div className="flex justify-end">
<p key={ex.q} className="max-w-[85%] animate-fade-up rounded-2xl rounded-br-md bg-brand-600 px-3.5 py-2 text-sm text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.15)]">
{ex.q}
</p>
</div>
<div className="flex gap-3">
<div className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-brand-400 to-brand-700 text-white shadow-glow">
<Bot className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1 space-y-4">
<div className="min-h-[124px]">
<Markdown text={reduced ? ex.a : stream.output} caret={!done} />
</div>
<div>
<p className={cn('mb-2 text-[11px] font-medium uppercase tracking-[0.12em] text-ink-500 transition-opacity duration-500', done ? 'opacity-100' : 'opacity-0')}>Ask next</p>
<FollowUps key={i} items={ex.next} show={done} onSelect={ask} />
</div>
</div>
</div>
</div>
);
}components/niceui/ai/stream.tsx
import { Fragment, useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import { cn } from '@/lib/cn';
/** Streams `text` token-by-token (words + whitespace). */
export function useStream(text: string, { speed = 32, enabled = true }: { speed?: number; enabled?: boolean } = {}) {
const [count, setCount] = useState(0);
const [run, setRun] = useState(0);
const tokens = useRef<string[]>([]);
tokens.current = text.split(/(\s+)/).filter(Boolean);
useEffect(() => {
setCount(0);
}, [text, run]);
useEffect(() => {
if (!enabled) return;
if (count >= tokens.current.length) return;
const jitter = speed * (0.5 + Math.random());
const t = window.setTimeout(() => setCount((c) => c + 2), jitter);
return () => window.clearTimeout(t);
}, [count, enabled, speed, text, run]);
const restart = useCallback(() => setRun((r) => r + 1), []);
const total = tokens.current.length;
return { output: tokens.current.slice(0, count).join(''), done: count >= total, restart, progress: total ? Math.min(1, count / total) : 1 };
}
export function Caret({ className }: { className?: string }) {
return <span aria-hidden className={cn('ml-0.5 inline-block h-[1.05em] w-[7px] translate-y-[3px] animate-blink rounded-[2px] bg-brand-400', className)} />;
}
function inline(text: string) {
return text.split(/(\*\*[^*]+\*\*|`[^`]+`)/g).map((part, i) => {
if (part.startsWith('**') && part.endsWith('**') && part.length > 4)
return <strong key={i} className="font-medium text-ink-50">{part.slice(2, -2)}</strong>;
if (part.startsWith('`') && part.endsWith('`') && part.length > 2)
return <code key={i} className="rounded bg-white/[0.06] px-1 py-px font-mono text-[0.85em] text-brand-200">{part.slice(1, -1)}</code>;
return <Fragment key={i}>{part}</Fragment>;
});
}
/** Minimal markdown: `## heading`, `- bullet`, `1. step`, **bold**, `code`. */
export function Markdown({ text, caret, className }: { text: string; caret?: boolean; className?: string }) {
const blocks = text.split('\n').filter((l, i, arr) => l.trim() !== '' || i === arr.length - 1);
const last = blocks.length - 1;
const tail = (i: number): ReactNode => (caret && i === last ? <Caret /> : null);
return (
<div className={cn('space-y-2 text-sm leading-relaxed text-ink-300', className)}>
{blocks.map((line, i) => {
if (line.startsWith('## '))
return (
<h4 key={i} className="pt-1 text-[15px] font-medium text-ink-50">
{inline(line.slice(3))}
{tail(i)}
</h4>
);
const bullet = line.match(/^(- |\d+\. )(.*)$/);
if (bullet)
return (
<div key={i} className="flex gap-2.5 pl-1">
<span className={cn('shrink-0 select-none', bullet[1] === '- ' ? 'mt-[9px] h-1 w-1 rounded-full bg-brand-400' : 'font-mono text-xs leading-6 text-ink-500')}>
{bullet[1] === '- ' ? '' : bullet[1].trim()}
</span>
<p>
{inline(bullet[2])}
{tail(i)}
</p>
</div>
);
return (
<p key={i}>
{inline(line)}
{tail(i)}
</p>
);
})}
</div>
);
}