Skip to content
Pebble Pro is here at $49 one timeSee what’s included ↗
Pebble
ComponentsDocsMCPPricing
⌕ Find a component↗Log in
All components 673

Categories

Core inputs & forms46Buttons & actions33Navigation36Overlays & feedback49Data display33Charts & data viz40Cards & layout43Marketing blocks38App shells & pages37AI interfaces40Text & typography effects33Backgrounds & visual effects38Motion & transitions32Numbers & counters32Media34E-commerce333D & interactive36Themed sets40
Start with the essentials.Installation guide ↗Connect your agent ↗
← All componentsAI interfaces

Prompt Suggestions

An empty-chat starter with a greeting, category chips that filter with layout animation, a surprise-me shuffle and roving-keyboard suggestion cards that hand their prompt to onSelect.

Preview

Good afternoon, Ines

What should we start with?

Install

Add the editable source with the shadcn CLI. Your project also needs the shared theme tokens this component uses. Setup and requirements. The Pebble installer is not published yet.

npx shadcn@latest add https://pebbleui.com/r/prompt-suggestions.json

Props

PropTypeDefaultDescription
suggestionsPromptSuggestion[]—{ id, title, prompt, category, icon? }.
onSelect(prompt: string, suggestion) => void—Called with the prompt when a card is picked.
eyebrow / greetingReactNode— / "What should we start with?"Line above the heading, and the heading.
filtersbooleantrueCategory chips built from the suggestions.
shufflebooleantrueA Surprise me button that reshuffles the cards.
limitnumber6Most cards shown.
columns1 | 2 | 32Grid columns from sm up; arrow keys follow it.
Build notes
The order is kept in state as ids. A shuffle is a Fisher–Yates pass in the
click handler, never during render. New suggestions are appended after the
kept order. `visible = ordered.filter(category).slice(0, limit)`. Cards are
`motion.li layout` inside `AnimatePresence mode="popLayout"`. The roving focus
id falls back to the first visible card. Arrow steps are ±1 and ±`columns`,
clamped. The chip thumb is a `layoutId` span with the house spring.

<!-- PEBBLE-LICENSE -->
View source

registry/ai/prompt-suggestions/prompt-suggestions.tsx

"use client";

/* PEBBLE-LICENSE
Built by Pebble. Component: prompt-suggestions.

First-party license: MIT

The following first-party terms apply only to original Pebble material.

MIT License

Copyright (c) 2026 Praxys Solutions LLC (Pebble)

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
END PEBBLE-LICENSE */

import * as React from "react";
import { AnimatePresence, LayoutGroup, MotionConfig, motion } from "motion/react";

import { cn } from "@/lib/utils";

export type PromptSuggestion = {
  id: string;
  /** Short label, 2–4 words. */
  title: string;
  /** The prompt passed to `onSelect`, shown as a two-line preview. */
  prompt: string;
  /** Filter group, e.g. "Write". */
  category: string;
  /** 16 px icon. */
  icon?: React.ReactNode;
};

export type PromptSuggestionsProps = Omit<React.ComponentProps<"div">, "onSelect"> & {
  suggestions: PromptSuggestion[];
  onSelect?: (prompt: string, suggestion: PromptSuggestion) => void;
  /** Small line above the heading, e.g. "Good afternoon, Ines". */
  eyebrow?: React.ReactNode;
  greeting?: React.ReactNode;
  /** Show category chips. Categories come from the suggestions, in order. */
  filters?: boolean;
  /** Show a "Surprise me" button that reshuffles the cards. */
  shuffle?: boolean;
  /** Most cards shown at once. */
  limit?: number;
  /** Grid columns at the `sm` breakpoint and up; arrow keys follow it. */
  columns?: 1 | 2 | 3;
};

const ALL = "All";
const spring = { type: "spring", stiffness: 420, damping: 32, mass: 0.8 } as const;

function Svg({ className, children }: { className?: string; children: React.ReactNode }) {
  return (
    <svg
      viewBox="0 0 16 16"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.5}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden
      className={cn("size-4 shrink-0", className)}
    >
      {children}
    </svg>
  );
}

const colsClass = { 1: "sm:grid-cols-1", 2: "sm:grid-cols-2", 3: "sm:grid-cols-3" } as const;

export function PromptSuggestions({
  suggestions,
  onSelect,
  eyebrow,
  greeting = "What should we start with?",
  filters = true,
  shuffle = true,
  limit = 6,
  columns = 2,
  className,
  ...props
}: PromptSuggestionsProps) {
  const [category, setCategory] = React.useState(ALL);
  const [order, setOrder] = React.useState<string[]>(() => suggestions.map((s) => s.id));
  const [focusId, setFocusId] = React.useState<string | null>(null);
  const cards = React.useRef(new Map<string, HTMLButtonElement>());
  const headingId = React.useId();

  const categories = React.useMemo(
    () => [ALL, ...Array.from(new Set(suggestions.map((s) => s.category)))],
    [suggestions],
  );
  const byId = React.useMemo(() => new Map(suggestions.map((s) => [s.id, s])), [suggestions]);
  // Keep the shuffled order, adding any new suggestions at the end.
  const ordered = [
    ...order.filter((id) => byId.has(id)),
    ...suggestions.filter((s) => !order.includes(s.id)).map((s) => s.id),
  ].map((id) => byId.get(id)!);
  const visible = ordered
    .filter((s) => category === ALL || s.category === category)
    .slice(0, limit);
  const active = visible.find((s) => s.id === focusId) ?? visible[0];

  const surprise = () => {
    const ids = ordered.map((s) => s.id);
    for (let i = ids.length - 1; i > 0; i--) {
      const j = Math.floor(Math.random() * (i + 1));
      [ids[i], ids[j]] = [ids[j], ids[i]];
    }
    setOrder(ids);
  };

  const move = (from: number, key: string) => {
    const n = visible.length;
    const step: Record<string, number> = {
      ArrowRight: 1,
      ArrowLeft: -1,
      ArrowDown: columns,
      ArrowUp: -columns,
    };
    let to = from;
    if (key === "Home") to = 0;
    else if (key === "End") to = n - 1;
    else if (key in step) to = Math.min(n - 1, Math.max(0, from + step[key]));
    else return false;
    const target = visible[to];
    setFocusId(target.id);
    cards.current.get(target.id)?.focus();
    return true;
  };

  return (
    <MotionConfig reducedMotion="user">
      <div
        data-slot="prompt-suggestions"
        className={cn("flex w-full flex-col gap-5", className)}
        {...props}
      >
        <div className="flex flex-col gap-1.5 px-1">
          {eyebrow && <p className="text-sm text-muted-foreground">{eyebrow}</p>}
          <h2
            id={headingId}
            className="text-[26px] leading-tight font-semibold tracking-[-0.02em] text-foreground"
          >
            {greeting}
          </h2>
        </div>

        {(filters || shuffle) && (
          <div className="flex flex-wrap items-center justify-between gap-2">
            {filters ? (
              <div role="group" aria-label="Filter suggestions" className="flex flex-wrap gap-1.5">
                {categories.map((c) => {
                  const on = c === category;
                  return (
                    <button
                      key={c}
                      type="button"
                      aria-pressed={on}
                      onClick={() => setCategory(c)}
                      className={cn(
                        "relative inline-flex h-7 cursor-pointer items-center rounded-full px-3 text-[13px] font-medium text-muted-foreground",
                        "transition-colors duration-(--motion-fast) hover:text-foreground",
                        "focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
                        on && "text-foreground",
                      )}
                    >
                      {on && (
                        <motion.span
                          layoutId={`${headingId}-chip`}
                          transition={spring}
                          className="absolute inset-0 rounded-full bg-card shadow-raised-xs ring-1 inset-shadow-highlight ring-hairline"
                        />
                      )}
                      <span className="relative">{c}</span>
                    </button>
                  );
                })}
              </div>
            ) : (
              <span />
            )}
            {shuffle && (
              <button
                type="button"
                onClick={surprise}
                className={cn(
                  "group inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-full px-2.5 text-[13px] font-medium text-accent-strong",
                  "transition-colors duration-(--motion-fast) hover:bg-accent/10",
                  "focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none active:scale-[0.97] motion-reduce:active:scale-100",
                )}
              >
                <Svg className="size-3.5 transition-transform duration-(--motion-base) ease-enter group-hover:rotate-12 motion-reduce:transition-none">
                  <rect x="2.5" y="2.5" width="11" height="11" rx="2.5" />
                  <path
                    d="M5.5 5.5h.01M10.5 5.5h.01M8 8h.01M5.5 10.5h.01M10.5 10.5h.01"
                    strokeWidth={2.2}
                  />
                </Svg>
                Surprise me
              </button>
            )}
          </div>
        )}

        <LayoutGroup>
          <ul
            aria-labelledby={headingId}
            className={cn("grid grid-cols-1 gap-2.5", colsClass[columns])}
          >
            <AnimatePresence mode="popLayout" initial={false}>
              {visible.map((s, i) => (
                <motion.li
                  key={s.id}
                  layout
                  initial={{ opacity: 0, scale: 0.96 }}
                  animate={{ opacity: 1, scale: 1 }}
                  exit={{ opacity: 0, scale: 0.96, transition: { duration: 0.12 } }}
                  transition={spring}
                >
                  <button
                    ref={(el) => {
                      if (el) cards.current.set(s.id, el);
                      else cards.current.delete(s.id);
                    }}
                    type="button"
                    data-slot="prompt-suggestion"
                    tabIndex={s.id === active?.id ? 0 : -1}
                    onFocus={() => setFocusId(s.id)}
                    onClick={() => onSelect?.(s.prompt, s)}
                    onKeyDown={(e) => {
                      if (move(i, e.key)) e.preventDefault();
                    }}
                    className={cn(
                      "group relative flex h-full w-full cursor-pointer flex-col gap-2 rounded-xl bg-card p-3.5 text-left shadow-raised-xs ring-1 inset-shadow-highlight ring-hairline",
                      "transition-[box-shadow,transform] duration-(--motion-base) ease-enter",
                      "active:translate-y-0 active:scale-[0.99] [@media(hover:hover)]:hover:-translate-y-0.5 [@media(hover:hover)]:hover:shadow-raised-md",
                      "motion-reduce:transition-none motion-reduce:hover:translate-y-0",
                      "focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
                    )}
                  >
                    <span className="flex items-center gap-2.5">
                      <span
                        aria-hidden
                        className="grid size-7 shrink-0 place-items-center rounded-lg bg-muted text-foreground ring-1 inset-shadow-xs ring-hairline transition-colors duration-(--motion-base) group-hover:bg-accent/12 group-hover:text-accent-strong"
                      >
                        {s.icon ?? (
                          <Svg>
                            <path d="M3 4.5h10M3 8h10M3 11.5h6" />
                          </Svg>
                        )}
                      </span>
                      <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
                        {s.title}
                      </span>
                      <Svg className="size-3.5 -translate-x-1 text-muted-foreground opacity-0 transition-[opacity,transform] duration-(--motion-base) ease-enter group-hover:translate-x-0 group-hover:opacity-100 group-focus-visible:translate-x-0 group-focus-visible:opacity-100">
                        <path d="M3.5 8h9M9 4.5 12.5 8 9 11.5" />
                      </Svg>
                    </span>
                    <span className="line-clamp-2 text-[13px] leading-5 text-muted-foreground">
                      {s.prompt}
                    </span>
                    <span className="sr-only">, {s.category}</span>
                  </button>
                </motion.li>
              ))}
            </AnimatePresence>
          </ul>
        </LayoutGroup>
      </div>
    </MotionConfig>
  );
}
Pebble

Small details.
Better interfaces.

React components to explore, make your own, and build on.

Library

  • Components
  • Installation
  • Pricing

Resources

  • For agents
  • About
  • Support

Legal

  • License
  • Terms
  • Privacy
  • Final sale

Built by Pebble

A product of Praxys Solutions LLC ↗

Pebble Pro is live

$49 one time, lifetime updates ↗