UI Elements

AI Prompt

An AI-style prompt input that fans out a stack of preview cards and swaps its action icons the moment the user starts typing.

ai-prompt.tsx
"use client";

import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
  ArrowUp,
  Camera,
  CodeXml,
  FileText,
  Folder,
  Image,
  Mic,
  Mountain,
  Plus,
} from "lucide-react";
import { useState } from "react";

const cards = [
  { id: 1, icon: FileText },
  { id: 2, icon: Mountain },
  { id: 3, icon: CodeXml }, // front / center card
];

const attachments = [
  { id: 1, icon: Camera, label: "Camera" },
  { id: 2, icon: Image, label: "Photos" },
  { id: 3, icon: Folder, label: "Files" },
];

// Easings — ease-out for enter/exit, ease-in-out for on-screen morphs.
const EASE_OUT = [0.22, 1, 0.36, 1] as const;
const EASE_IN_OUT = [0.65, 0, 0.35, 1] as const;

// Resting (stacked) vs. active (fanned) transforms for each card.
const stacked = [
  { x: 0, y: -34, scale: 0.86, rotate: 0, z: 1 },
  { x: 0, y: -17, scale: 0.93, rotate: 0, z: 2 },
  { x: 0, y: 0, scale: 1, rotate: 0, z: 3 },
];
const fanned = [
  { x: -210, y: 26, scale: 0.9, rotate: -11, z: 2 },
  { x: 210, y: 26, scale: 0.9, rotate: 11, z: 2 },
  { x: 0, y: 0, scale: 1, rotate: 0, z: 3 },
];

const AiPrompt = () => {
  const reduce = useReducedMotion();
  const [value, setValue] = useState("");
  const active = value.trim().length > 0;

  const cardTransition = reduce
    ? { duration: 0 }
    : { type: "spring" as const, bounce: 0.24, duration: 0.6 };

  return (
    <div className="size-full flex flex-col items-center justify-center gap-9 px-4">
      {/* Card stack — fans out while typing */}
      <div className="relative flex h-[240px] w-full items-center justify-center">
        {cards.map((card, index) => {
          const pose = active ? fanned[index] : stacked[index];
          return (
            <motion.div
              key={card.id}
              initial={false}
              animate={{
                x: pose.x,
                y: pose.y,
                scale: pose.scale,
                rotate: pose.rotate,
              }}
              transition={{ ...cardTransition, delay: reduce ? 0 : index * 0.03 }}
              style={{ zIndex: pose.z }}
              className="group absolute flex h-[196px] w-[300px] items-center justify-center overflow-hidden rounded-[28px] border border-border bg-background shadow-[0_18px_40px_-24px_rgba(0,0,0,0.7)]"
            >
              <div className="flex size-full flex-col items-center justify-center gap-4">
                <card.icon
                  className="size-9 text-muted-foreground/70 transition-colors duration-300 group-hover:text-foreground"
                  strokeWidth={1.5}
                />
                <div className="flex w-full flex-col items-center gap-1.5">
                  <div className="h-3.5 w-1/3 rounded-full bg-muted-foreground/70 transition-colors duration-300 group-hover:bg-foreground/80" />
                  <div className="h-3.5 w-1/2 rounded-full bg-muted" />
                </div>
              </div>
            </motion.div>
          );
        })}
      </div>

      {/* Composer */}
      <div className="flex w-full max-w-[540px] items-center gap-2">
        {/* Leading control: attachment icons ⇄ add button */}
        <div className="flex items-center">
          <AnimatePresence mode="popLayout" initial={false}>
            {active ? (
              <motion.button
                key="add"
                type="button"
                aria-label="Add attachment"
                layout
                initial={reduce ? false : { opacity: 0, scale: 0.8 }}
                animate={{ opacity: 1, scale: 1 }}
                exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.8 }}
                transition={{ duration: 0.18, ease: EASE_OUT }}
                whileTap={{ scale: 0.92 }}
                className="flex size-9 shrink-0 items-center justify-center rounded-full border border-border bg-muted/40 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
              >
                <Plus className="size-[18px]" />
              </motion.button>
            ) : (
              <motion.div
                key="attachments"
                layout
                className="flex items-center gap-0.5"
              >
                {attachments.map((item, index) => (
                  <motion.button
                    key={item.id}
                    type="button"
                    aria-label={item.label}
                    initial={reduce ? false : { opacity: 0, x: 8, filter: "blur(4px)" }}
                    animate={{ opacity: 1, x: 0, filter: "blur(0px)" }}
                    exit={
                      reduce
                        ? { opacity: 0 }
                        : { opacity: 0, x: 6, filter: "blur(4px)" }
                    }
                    transition={{
                      duration: 0.2,
                      ease: EASE_OUT,
                      delay: reduce ? 0 : index * 0.04,
                    }}
                    whileTap={{ scale: 0.9 }}
                    className="flex size-9 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
                  >
                    <item.icon className="size-5" strokeWidth={1.75} />
                  </motion.button>
                ))}
              </motion.div>
            )}
          </AnimatePresence>
        </div>

        {/* Input pill — flex-grows smoothly as the leading control collapses */}
        <motion.div
          layout
          transition={{ layout: { duration: 0.32, ease: EASE_IN_OUT } }}
          className="flex h-11 flex-1 items-center rounded-full border border-border bg-background px-4 transition-colors duration-200 focus-within:border-foreground/25 focus-within:ring-4 focus-within:ring-foreground/5"
        >
          <input
            aria-label="Message"
            placeholder="Message"
            value={value}
            onChange={(e) => setValue(e.target.value)}
            className="h-full w-full border-none bg-transparent text-sm text-foreground caret-foreground outline-none placeholder:text-muted-foreground"
          />
        </motion.div>

        {/* Trailing action — one button: voice input ⇄ send */}
        <motion.button
          type="button"
          aria-label={active ? "Send message" : "Voice input"}
          whileTap={{ scale: 0.92 }}
          className="flex size-10 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
        >
          <AnimatePresence mode="wait" initial={false}>
            {active ? (
              <motion.span
                key="send"
                initial={reduce ? false : { opacity: 0, scale: 0.5, rotate: -25 }}
                animate={{ opacity: 1, scale: 1, rotate: 0 }}
                exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, rotate: 25 }}
                transition={{ duration: 0.18, ease: EASE_OUT }}
              >
                <ArrowUp className="size-5" />
              </motion.span>
            ) : (
              <motion.span
                key="voice"
                initial={reduce ? false : { opacity: 0, scale: 0.5, rotate: 25 }}
                animate={{ opacity: 1, scale: 1, rotate: 0 }}
                exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, rotate: -25 }}
                transition={{ duration: 0.18, ease: EASE_OUT }}
              >
                <Mic className="size-5" strokeWidth={2} />
              </motion.span>
            )}
          </AnimatePresence>
        </motion.button>
      </div>
    </div>
  );
};

export default AiPrompt;