AI

Suggested Prompts

Click-to-send conversation starters and follow-ups — a wrapping strip of one-shot cards that latches after the first pick, goes inert while the assistant streams, and re-rolls without collapsing the row.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  AlertCircle,
  BarChart3,
  Check,
  Code2,
  FileText,
  Lightbulb,
  RefreshCw,
  Search,
  Sparkles,
  type LucideIcon,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/suggested-prompts.json

Prompt

Build a React + TypeScript + Tailwind "SuggestedPrompts" component with zod and
lucide-react, reusing a roving-tabindex hook.

Contract
- A zod schema (`suggestedPromptSchema` in a sibling contract file) is the single
  source of truth for one card: { id, text, icon? } where icon is a closed enum
  of glyph keys ("sparkles" | "search" | "code" | "file" | "chart" | "idea") —
  a contract carries data, never a ReactNode, and a closed set fails parsing
  instead of rendering a hole. `suggestedPromptsStatusSchema` is
  "loading" | "empty" | "error" | "ready". One refine on the array rejects
  duplicate ids (the send lock keys on id); guard `Array.isArray` inside it,
  because a refine still runs on a payload whose inner shape failed and a bare
  dereference would throw out of safeParse instead of returning an error.
- There is deliberately NO separate short "label" field. The card renders the
  exact string that will be sent, because the click is irreversible — a chip
  that says "Summarize" while sending three sentences is a trap. The cost of
  that honesty is a batch that mixes 8-character and 200-character cards, which
  the layout has to absorb.
- Props: items; status; onSelect(item) [required]; streaming?; onRefresh?;
  refreshing?; resetKey?; label? (default "Suggested", "" hides the eyebrow);
  refreshLabel?; skeletonCount? (clamped 1–8); className. Affordances are
  handler-gated: no onRefresh means no re-roll button anywhere, including the
  error branch.

Behavior
- MISFIRE IS THE WHOLE PROBLEM. A click sends for real and cannot be taken back,
  so two guards run before onSelect: (1) `busy` — streaming, refreshing, or a
  "loading" status still showing the previous batch — and (2) a one-shot latch.
  The latch is a ref written synchronously inside the handler, not state: React
  does not re-render between two clicks dispatched in the same task, so a
  state-only guard lets a double tap through. Measured: three synchronous
  `.click()` calls on one card produce 3 sends with a state guard and 1 with the
  ref guard.
- Once a card is spent the WHOLE group renders aria-disabled — never the native
  `disabled` attribute, which blurs the element the instant it flips and drops
  the reader's focus to `body`, and which would also make "a click still reaches
  the handler and is refused" untestable. Do not add pointer-events:none for the
  same reason: the click must arrive and be turned down.
- The latch clears when the batch changes. "Changed" is decided by the ids' own
  content (plus an optional `resetKey`), not array identity — a consumer that
  builds `items` inline would otherwise re-arm the group on every keystroke
  elsewhere in the page. Adjust the state during render, not in an effect, so
  there is never a frame showing fresh suggestions in a locked group.
- Re-rolling must not make the row jump. `refreshing` keeps the current cards
  mounted and inert while the next batch loads, and a `loading` status arriving
  with items still present also keeps them (items outrank a transient status).
  The only branch that renders zero height is the empty one — which returns null
  outright: no wrapper, no heading, no leftover gap under the message.
- Keyboard: the strip is ONE tab stop. Left/Right (plus Home/End) move a roving
  tabindex through the cards in DOM order, exactly one card carries tabIndex 0,
  and Enter/Space on a focused card sends it. Up/Down are deliberately left
  alone so the page can still scroll; make the group vertical only if you stack
  the cards.

Rendering & styling
- These are optional commands, not form options: a `div` with role="group",
  aria-labelledby pointing at the visible eyebrow (aria-label when it is
  hidden), aria-describedby pointing at an sr-only line that says choosing one
  sends it immediately, and plain `button` children. No radio/checkbox roles and
  no aria-checked/aria-pressed — those would tell a screen-reader user they must
  pick one, and that a pick is reversible.
- Layout wraps (flex-wrap + items-start), it is not a horizontal rail: an 8- and
  a 200-character suggestion cannot share a fixed-width track without either
  clipping the long one or padding the short one, and a rail also hides its last
  card from keyboard users. Cards take their natural width, cap at max-w-full,
  and long ones simply own a row.
- The text span carries `wrap-anywhere`, not `break-words`: only
  `overflow-wrap: anywhere` lowers min-content width. Measured at 375px with a
  111-character unbreakable URL — with it the page scrollWidth is 365 against a
  375 client width; with `break-words` instead, 500.
- Semantic tokens only: bg-card + border for a card, hover:bg-accent and
  hover:border-primary/50, border-primary + bg-primary/5 + a Check glyph for the
  card that was just sent, bg-muted for skeletons, text-muted-foreground for the
  eyebrow and the error line, text-destructive for the error glyph, and
  focus-visible:ring-2 ring-ring everywhere. cn() merges the consumer className
  into the root.
- Only the colour transitions, the skeleton pulse and the re-roll spinner
  animate, each with a motion-reduce:*-none twin; with motion off every one of
  them still works.

Customization levers
- Send semantics: keep onSelect one-shot. If your product wants "fill the
  composer instead of sending", drop the latch and the streaming block — that is
  a different component, and it should look different too (no latch, no dimming).
- Density: px-3 py-2 text-sm reads as a card; h-8 px-3 rounded-full with the
  icon removed turns the same strip into pill-shaped quick replies.
- Batch policy: `resetKey` re-arms without changing `items` (same suggestions
  offered on a new turn); returning a genuinely new batch re-arms by itself.
- Chrome: `label=""` drops the eyebrow, omitting onRefresh drops the re-roll
  everywhere, `skeletonCount` should match the number of suggestions you
  usually return, and the icon enum is the natural place to add your own glyphs.
- Focus hand-off after a send is the consumer's call: keep the group mounted
  through the reply (focus stays on the spent card) or move focus to the
  transcript yourself — unmounting the strip on click drops focus to `body`.

Concepts

  • Click-to-send — the card is a command, not a choice. It carries no selected state, nothing toggles back off, and the accessible name is the exact text that will leave, because the reader gets no confirmation step.
  • One-shot latch — the first accepted click spends the entire batch, not just the card that was pressed. The guard lives in a ref updated inside the handler, so two clicks landing in the same task (double tap, synthetic pair) still produce exactly one send; a state-only guard measured 3.
  • Inert, not disabled — while the assistant streams, cards keep their native disabled-free markup and are marked aria-disabled. They stay focusable, they still receive the click, and the handler turns it down — a card that removes itself from the tab order mid-reply would strand keyboard focus on body.
  • Stale-batch hold — a re-roll keeps the current cards on screen until the next batch lands, so the strip's height moves from one real value to another instead of collapsing to zero and shoving the transcript. The only zero-height branch is the empty one, and that one renders no node at all.
  • Wrap over rail — suggestions are 8 to 200 characters. A wrapping flow lets a long one own a row and a short one stay small; a horizontal rail would need a fixed track (clipping the long) and would push its last card out of keyboard reach.
  • Content-keyed re-arm — "is this a new batch" is answered from the ids' content plus an optional resetKey, never from array identity, so a consumer that rebuilds items on every render cannot silently unlock a spent group.

On This Page