Suggested Prompt Cards That Fill the Prompt Box

A dark grid of six suggested prompts above a prompt box. Each card lifts on a spring and lights up with a glow and a bright border edge that follow the pointer. Choosing a card sends a small light from the card to the box, which flashes and types the prompt out ready to edit. An idle demo walks through the cards until a visitor touches the grid. On phones the grid is two columns and the long prompt text is hidden.

Code

import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { motion, useInView, useReducedMotion } from "framer-motion";
import { ArrowUp, ArrowUpRight, CalendarDays, FileText, Lightbulb, ListChecks, MessageSquare, Receipt } from "lucide-react";

const prompts = [
  { title: "Draft a quote", tag: "Sales", icon: FileText, text: "Write a quote for a kitchen repaint, two coats, with a 10 percent deposit." },
  { title: "Reply to a review", tag: "Support", icon: MessageSquare, text: "Reply warmly to a three star review that mentions slow delivery." },
  { title: "Plan the week", tag: "Rota", icon: CalendarDays, text: "Plan next week's rota for four staff, with nobody working more than five days." },
  { title: "Chase an invoice", tag: "Finance", icon: Receipt, text: "Write a polite second reminder for invoice 1042, now 14 days overdue." },
  { title: "Summarise a call", tag: "Notes", icon: ListChecks, text: "Turn my call notes into three decisions and a short list of next steps." },
  { title: "Name a product", tag: "Ideas", icon: Lightbulb, text: "Suggest ten names for a small batch chilli oil that sound handmade." },
];

type Ghost = { key: number; x0: number; y0: number; x1: number; y1: number };

export default function SuggestedPromptCardsThatFillThePromptBox() {
  const reduce = useReducedMotion();
  const rootRef = useRef<HTMLElement>(null);
  const stageRef = useRef<HTMLDivElement>(null);
  const boxRef = useRef<HTMLFormElement>(null);
  const cardRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const inView = useInView(rootRef, { amount: 0.3 });
  const [selected, setSelected] = useState(-1);
  const [value, setValue] = useState("");
  const [feeding, setFeeding] = useState(false);
  const [auto, setAuto] = useState(true);
  const [ghost, setGhost] = useState<Ghost | null>(null);
  const [pulse, setPulse] = useState(0);

  const choose = (index: number) => {
    setSelected(index);
    setPulse((n) => n + 1);
    if (reduce) {
      setValue(prompts[index].text);
      return;
    }
    setValue("");
    setFeeding(true);
    const stage = stageRef.current?.getBoundingClientRect();
    const card = cardRefs.current[index]?.getBoundingClientRect();
    const box = boxRef.current?.getBoundingClientRect();
    if (stage && card && box) setGhost({ key: Date.now(), x0: card.left - stage.left + 36, y0: card.top - stage.top + 36, x1: box.left - stage.left + 34, y1: box.top - stage.top + 34 });
  };

  // Type the chosen prompt into the box, starting once the light has landed.
  useEffect(() => {
    if (!feeding || selected < 0) return;
    const target = prompts[selected].text;
    if (value.length >= target.length) {
      setFeeding(false);
      return;
    }
    const timer = setTimeout(() => setValue(target.slice(0, value.length + 2)), value.length === 0 ? 420 : 16);
    return () => clearTimeout(timer);
  }, [feeding, selected, value]);

  // Idle demo: walk through the cards until a visitor takes over.
  useEffect(() => {
    if (!auto || !inView) return;
    if (reduce) {
      if (selected < 0) choose(0);
      return;
    }
    const timer = setTimeout(() => choose((selected + 1) % prompts.length), selected < 0 ? 700 : 2400);
    return () => clearTimeout(timer);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [auto, inView, selected, reduce]);

  const track = (e: ReactPointerEvent<HTMLButtonElement>) => {
    const rect = e.currentTarget.getBoundingClientRect();
    e.currentTarget.style.setProperty("--mx", `${e.clientX - rect.left}px`);
    e.currentTarget.style.setProperty("--my", `${e.clientY - rect.top}px`);
  };

  const Active = selected >= 0 ? prompts[selected].icon : null;

  return (
    <section ref={rootRef} className="relative overflow-hidden bg-slate-950 px-4 py-14 text-slate-100 sm:px-6 sm:py-16">
      <style>{`
        .suggested-prompt-cards-card::before, .suggested-prompt-cards-card::after {
          content: ""; position: absolute; inset: 0; border-radius: inherit; opacity: 0; transition: opacity .35s ease; pointer-events: none;
        }
        .suggested-prompt-cards-card::before { background: radial-gradient(280px circle at var(--mx, 30%) var(--my, 0%), rgba(56,189,248,.2), transparent 65%); }
        .suggested-prompt-cards-card::after {
          padding: 1px; background: radial-gradient(220px circle at var(--mx, 30%) var(--my, 0%), rgba(165,243,252,.95), rgba(56,189,248,.25) 55%, transparent 75%);
          -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); -webkit-mask-composite: xor; mask-composite: exclude;
        }
        .suggested-prompt-cards-card:hover::before, .suggested-prompt-cards-card:hover::after,
        .suggested-prompt-cards-card:focus-visible::before, .suggested-prompt-cards-card:focus-visible::after,
        .suggested-prompt-cards-card[data-active="true"]::before, .suggested-prompt-cards-card[data-active="true"]::after { opacity: 1; }
        @keyframes suggested-prompt-cards-caret { 50% { opacity: 0; } }
        .suggested-prompt-cards-caret { animation: suggested-prompt-cards-caret .9s steps(1) infinite; }
        @media (prefers-reduced-motion: reduce) { .suggested-prompt-cards-caret { animation: none; } }
      `}</style>
      <div aria-hidden="true" className="pointer-events-none absolute -top-40 left-1/2 h-[420px] w-[900px] max-w-none -translate-x-1/2 rounded-full bg-sky-500/15 blur-3xl" />
      <div aria-hidden="true" className="pointer-events-none absolute inset-0 opacity-40" style={{ backgroundImage: "radial-gradient(rgba(148,163,184,.25) 1px, transparent 1px)", backgroundSize: "28px 28px", maskImage: "radial-gradient(ellipse at center, #000 20%, transparent 70%)", WebkitMaskImage: "radial-gradient(ellipse at center, #000 20%, transparent 70%)" }} />

      <div ref={stageRef} className="relative mx-auto max-w-3xl" onPointerDown={() => setAuto(false)} onKeyDown={() => setAuto(false)}>
        <div className="text-center">
          <h2 className="text-3xl font-semibold tracking-tight text-white sm:text-4xl">Where do you want to start?</h2>
          <p className="mx-auto mt-3 max-w-md text-sm text-slate-400 sm:text-base">Pick a job and Tally writes the first prompt for you. Edit it, then send.</p>
        </div>

        <ul className="mt-9 grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-3">
          {prompts.map((prompt, index) => {
            const on = selected === index;
            const Icon = prompt.icon;
            return (
              <motion.li key={prompt.title} initial={reduce ? false : { opacity: 0, y: 22 }} animate={{ opacity: 1, y: 0 }} transition={{ type: "spring", stiffness: 300, damping: 26, delay: 0.05 * index }}>
                <motion.button
                  ref={(el) => {
                    cardRefs.current[index] = el;
                  }}
                  type="button"
                  aria-pressed={on}
                  data-active={on}
                  onPointerMove={track}
                  onClick={() => {
                    setAuto(false);
                    choose(index);
                  }}
                  animate={{ y: on ? -6 : 0 }}
                  whileHover={reduce ? undefined : { y: -6 }}
                  whileTap={{ scale: 0.97 }}
                  transition={{ type: "spring", stiffness: 380, damping: 24 }}
                  className={`suggested-prompt-cards-card group relative flex h-full w-full flex-col items-start rounded-2xl border bg-white/[0.03] p-4 text-left transition-[box-shadow,border-color,background-color] duration-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-300 sm:p-5 ${on ? "border-transparent bg-sky-400/[0.06] shadow-[0_24px_60px_-18px_rgba(56,189,248,0.55)]" : "border-white/10 hover:shadow-[0_24px_60px_-18px_rgba(56,189,248,0.4)]"}`}
                >
                  <span className="flex w-full items-center justify-between">
                    <span className={`grid h-10 w-10 place-items-center rounded-xl transition-colors duration-300 ${on ? "bg-sky-300 text-slate-950" : "bg-white/10 text-sky-200"}`}>
                      <Icon aria-hidden="true" className="h-5 w-5" />
                    </span>
                    <ArrowUpRight aria-hidden="true" className={`h-4 w-4 transition duration-300 ${on ? "translate-x-0 text-sky-200 opacity-100" : "-translate-x-1 translate-y-1 text-slate-500 opacity-0 group-hover:translate-x-0 group-hover:translate-y-0 group-hover:opacity-100"}`} />
                  </span>
                  <span className="mt-4 block text-sm font-semibold text-white sm:text-base">{prompt.title}</span>
                  <span className="mt-1.5 hidden text-sm leading-relaxed text-slate-400 sm:block">{prompt.text}</span>
                  <span className="mt-2 text-xs font-medium text-sky-300/90 sm:mt-4">{prompt.tag}</span>
                </motion.button>
              </motion.li>
            );
          })}
        </ul>

        <motion.form
          ref={boxRef}
          onSubmit={(e) => e.preventDefault()}
          initial={reduce ? false : { opacity: 0, y: 22 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ type: "spring", stiffness: 300, damping: 26, delay: 0.32 }}
          className="relative mt-6 rounded-3xl border border-white/10 bg-slate-900/90 p-3 shadow-2xl shadow-black/40"
        >
          {!reduce && pulse > 0 && <motion.span key={pulse} aria-hidden="true" initial={{ opacity: 0 }} animate={{ opacity: [0, 1, 0] }} transition={{ duration: 1.1, delay: 0.38, times: [0, 0.25, 1] }} className="pointer-events-none absolute -inset-px rounded-3xl border border-sky-300 shadow-[0_0_40px_0_rgba(56,189,248,0.5)]" />}
          <div className="flex items-start gap-3">
            <span className="grid h-11 w-11 flex-none place-items-center rounded-2xl bg-white/5 text-sky-200">
              <motion.span key={selected} initial={reduce || selected < 0 ? false : { opacity: 0, scale: 0.4, rotate: -25 }} animate={{ opacity: 1, scale: 1, rotate: 0 }} transition={{ type: "spring", stiffness: 500, damping: 22, delay: 0.42 }}>
                {Active ? <Active aria-hidden="true" className="h-5 w-5" /> : <MessageSquare aria-hidden="true" className="h-5 w-5" />}
              </motion.span>
            </span>
            <div className="relative min-w-0 flex-1">
              <label htmlFor="suggested-prompt-cards-field" className="sr-only">
                Your prompt
              </label>
              <textarea
                id="suggested-prompt-cards-field"
                rows={2}
                value={value}
                onChange={(e) => {
                  setAuto(false);
                  setFeeding(false);
                  setValue(e.target.value);
                }}
                placeholder="Or describe the job in your own words..."
                className="block w-full resize-none bg-transparent py-1.5 text-[15px] leading-6 text-slate-100 placeholder:text-slate-500 focus:outline-none"
              />
              {feeding && value.length > 0 && (
                <span aria-hidden="true" className="pointer-events-none absolute inset-0 whitespace-pre-wrap break-words py-1.5 text-[15px] leading-6 text-transparent">
                  {value}
                  <span className="suggested-prompt-cards-caret ml-0.5 inline-block h-4 w-0.5 translate-y-0.5 bg-sky-300" />
                </span>
              )}
            </div>
            <button type="submit" aria-label="Send prompt" className={`grid h-11 w-11 flex-none place-items-center rounded-2xl transition-colors duration-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-300 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-900 ${value.trim() ? "bg-sky-300 text-slate-950 hover:bg-sky-200" : "bg-white/10 text-slate-500"}`}>
              <ArrowUp aria-hidden="true" className="h-5 w-5" strokeWidth={2.5} />
            </button>
          </div>
        </motion.form>

        {/* The light that carries a prompt from its card to the box. */}
        {ghost && (
          <motion.span
            key={ghost.key}
            aria-hidden="true"
            initial={{ x: ghost.x0, y: ghost.y0, opacity: 0, scale: 0.4 }}
            animate={{ x: ghost.x1, y: ghost.y1, opacity: [0, 1, 1, 0], scale: [0.4, 1.3, 1, 0.3] }}
            transition={{ duration: 0.46, ease: [0.5, 0, 0.2, 1] }}
            onAnimationComplete={() => setGhost(null)}
            className="pointer-events-none absolute left-0 top-0 -ml-2 -mt-2 h-4 w-4 rounded-full bg-cyan-200 shadow-[0_0_28px_10px_rgba(34,211,238,0.65)]"
          />
        )}
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/suggested-prompt-cards-that-fill-the-prompt-box.json

When to use it

How to customize it

Accessibility

Questions

How does the glow follow the pointer?

A pointer move handler writes the pointer position into two CSS variables on the card, `--mx` and `--my`. The card's `::before` layer is a radial gradient centred on those variables, so the light moves without any React render. It fades in on hover, focus or when the card is selected.

How is the glowing border edge made?

The `::after` layer has one pixel of padding and a radial gradient background, then a mask that cuts out everything except the padding. What remains is a one pixel ring that is brightest near the pointer.

How does the light travel to the prompt box?

On click the component measures the card and the box against their shared container and renders one absolutely positioned dot. Framer-motion animates its x and y from the card to the box while it scales and fades. The dot removes itself when the animation ends.

Can a click send the prompt immediately?

Yes. In the `choose` function call your send handler with `prompts[index].text` instead of setting the field value. Most products keep the edit step so people can adjust names and numbers first.

Does it work on touch screens?

Yes. A tap selects the card, which gives it the same lifted, glowing state that hover gives on a desktop, so nothing depends on hover alone.

Related: Glowing AI Prompt Box with Morphing Send Button, Streaming AI Chat Thread with Typing Indicator, Command Palette with Spring Highlight and Live Filtering.

More ai prompt and chat ui or all free UI components. Start for free to have a full site built around it.

More in AI Chat

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap