Skip to content
AI & Chat primitive

Prompt Suggestions

The starters that fill an empty thread, as a real list of real buttons rather than the usual grid of clickable divs — reachable by keyboard, skippable in one keystroke, and named for the prompt they actually send.

121 linesNo dependenciesAdded 14 Sept 2026
  • suggestions
  • starters
  • empty state
  • ai
  • onboarding
  • prompts

What's included

  • components/ui/prompt-suggestions.tsx
  • No runtime dependencies

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add prompt-suggestions

Or over MCP, from your editor's agent — no account needed.

License

Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.

Was this useful?

Live

Interactive, in your current theme. Every state below is the real component — type in it, tab through it, switch the theme.

Try one of these

Source

One file. Create components/ui/prompt-suggestions.tsx and paste.

components/ui/prompt-suggestions.tsx
'use client'

/**
 * <PromptSuggestions> — the starters that fill an empty thread.
 *
 * The blank conversation is the highest-leverage screen in an assistant and
 * usually ships as a centred logo. A visitor who does not know what the
 * thing can be asked asks nothing, so this is the control that answers
 * "what do I type" — and it is the reusable half of the catalog's
 * `<ChatEmptyState>` block, which is the whole screen with headings and a
 * capability strip around it.
 *
 * **A real `<ul>` of real `<button>`s.** The overwhelmingly common shape is
 * a grid of `<div>`s with click handlers, which cannot be reached by
 * keyboard at all and gives a screen reader no indication that there are
 * four of anything. A list announces "list, 4 items" and can be skipped in
 * one keystroke by someone who does not want them.
 *
 * **The heading owns the list.** `aria-labelledby` ties the two together,
 * because "list, 4 items" with no subject is barely better than silence.
 *
 * **The accessible name is the prompt, not the label.** A tile reading
 * "Summarise this…" is a truncation; what gets sent is a full sentence.
 * Where they differ, the button is named for what it will actually do — the
 * visible text stays short, and nobody clicks a thing whose name was cut
 * off mid-word.
 *
 * **`type="button"`, explicitly.** These sit next to a composer, and a
 * bare `<button>` inside a `<form>` defaults to `submit` — which would send
 * the empty prompt instead of filling it.
 */

import * as React from 'react'

export interface PromptSuggestion {
  /** Falls back to `label` when absent. */
  id?: string
  /** The short text on the tile. */
  label: string
  /** A second line of context. Optional. */
  description?: string
  /** Any node — an emoji, an icon, an avatar. Decorative, so it is hidden. */
  icon?: React.ReactNode
  /** What actually gets sent, when it differs from the label. */
  prompt?: string
}

export interface PromptSuggestionsProps {
  suggestions: PromptSuggestion[]
  /** Rendered above the list and used as its accessible name. */
  heading?: string
  /** Receives the full prompt — `prompt ?? label`. */
  onSelect?: (prompt: string, suggestion: PromptSuggestion) => void
  columns?: 1 | 2
  className?: string
}

export function PromptSuggestions({
  suggestions,
  heading = 'Try one of these',
  onSelect,
  columns = 2,
  className = '',
}: PromptSuggestionsProps) {
  const uid = React.useId()

  return (
    <div className={['w-full', className].filter(Boolean).join(' ')}>
      {heading ? (
        <h3
          id={`${uid}-heading`}
          data-stress-ignore
          className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground"
        >
          {heading}
        </h3>
      ) : null}

      <ul
        aria-labelledby={heading ? `${uid}-heading` : undefined}
        className={[
          'grid gap-2',
          columns === 2 ? 'sm:grid-cols-2' : 'grid-cols-1',
        ].join(' ')}
      >
        {suggestions.map((suggestion) => {
          const prompt = suggestion.prompt ?? suggestion.label
          const named = prompt !== suggestion.label

          return (
            <li key={suggestion.id ?? suggestion.label}>
              <button
                type="button"
                onClick={() => onSelect?.(prompt, suggestion)}
                aria-label={named ? prompt : undefined}
                className="flex h-full w-full items-start gap-2.5 rounded-xl border border-border bg-card p-3 text-start transition-colors hover:border-primary/40 hover:bg-muted/60 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
              >
                {suggestion.icon ? (
                  <span aria-hidden="true" className="mt-0.5 shrink-0 text-muted-foreground">
                    {suggestion.icon}
                  </span>
                ) : null}
                <span className="min-w-0">
                  <span className="block text-sm font-medium text-foreground">
                    {suggestion.label}
                  </span>
                  {suggestion.description ? (
                    <span className="mt-0.5 block text-xs leading-relaxed text-muted-foreground">
                      {suggestion.description}
                    </span>
                  ) : null}
                </span>
              </button>
            </li>
          )
        })}
      </ul>
    </div>
  )
}

Props

Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.

PropTypeDefault
suggestionsrequiredPromptSuggestion[]—
headingRendered above the list and used as its accessible name.string'Try one of these'
onSelectReceives the full prompt — `prompt ?? label`.(prompt: string, suggestion: PromptSuggestion) => void—
columns1 | 22
classNamestring''

For AI

The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.

See the prompt

More ai & chat

View all
Open the full page for this primitive

Message Bubble

One turn in a thread, with the part every chat UI leaves out: the speaker is in the text rather than implied by which side the bubble sits on, so a screen reader hears a conversation instead of an unattributed monologue.

AI & Chat178 linesNo deps
Open the full page for this primitive

Prompt Input

The composer, reduced to the four things that are actually hard: Enter that does not fire mid-IME-composition, one submit path for button and keyboard alike, stop as a separate button from send, and a row cap measured from the real line height.

AI & Chat177 lines1 dep
Open the full page for this primitive

Tool Call

A single tool invocation that opens into its arguments and result — status carried by a glyph and a word rather than a colour, arguments as a definition list, and a duration formatted arithmetically so it cannot drift at hydration.

AI & Chat202 lines1 dep