shadimage
Kokonut UI
componentai-promptsource: ok · refreshed

AI Input Selector

Animated AI chat input with model selection dropdown, file attachment and auto-resizing textarea. Built with React, Tailwind CSS and Motion.

npx shadcn add "https://kokonutui.com/r/ai-prompt.json"
free

Where it goes

chat

Interaction

dropdown

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
iconslucide-react
animationmotion

Registry dependencies

textareabuttondropdown-menu

Files4 · source cached 10/8/2026, 4:43:09 AM

/components/kokonutui/ai-prompt.tsx
"use client";

/**
 * @author: @kokonutui
 * @description: AI Prompt Input
 * @version: 1.0.0
 * @date: 2025-06-26
 * @license: MIT
 * @website: https://kokonutui.com
 * @github: https://github.com/kokonut-labs/kokonutui
 */

import {
  AlignLeft,
  ArrowRight,
  Bot,
  Check,
  ChevronDown,
  Code2,
  FileText,
  Languages,
  Lightbulb,
  Paperclip,
  Slash,
  X,
} from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { type ReactNode, useEffect, useId, useRef, useState } from "react";
import Anthropic from "@/components/icons/anthropic";
import AnthropicDark from "@/components/icons/anthropic-dark";
import { Button } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Textarea } from "@/components/ui/textarea";
import { useAutoResizeTextarea } from "@/hooks/use-auto-resize-textarea";
import { cn } from "@/lib/utils";

// Decorative: each icon sits next to the model name, which carries the label.
const OPENAI_ICON = (
  <svg
    aria-hidden="true"
    fill="currentColor"
    height="1em"
    preserveAspectRatio="xMidYMid"
    viewBox="0 0 256 260"
    width="1em"
    xmlns="http://www.w3.org/2000/svg"
  >
    <path d="M239.184 106.203a64.716 64.716 0 0 0-5.576-53.103C219.452 28.459 191 15.784 163.213 21.74A65.586 65.586 0 0 0 52.096 45.22a64.716 64.716 0 0 0-43.23 31.36c-14.31 24.602-11.061 55.634 8.033 76.74a64.665 64.665 0 0 0 5.525 53.102c14.174 24.65 42.644 37.324 70.446 31.36a64.72 64.72 0 0 0 48.754 21.744c28.481.025 53.714-18.361 62.414-45.481a64.767 64.767 0 0 0 43.229-31.36c14.137-24.558 10.875-55.423-8.083-76.483Zm-97.56 136.338a48.397 48.397 0 0 1-31.105-11.255l1.535-.87 51.67-29.825a8.595 8.595 0 0 0 4.247-7.367v-72.85l21.845 12.636c.218.111.37.32.409.563v60.367c-.056 26.818-21.783 48.545-48.601 48.601Zm-104.466-44.61a48.345 48.345 0 0 1-5.781-32.589l1.534.921 51.722 29.826a8.339 8.339 0 0 0 8.441 0l63.181-36.425v25.221a.87.87 0 0 1-.358.665l-52.335 30.184c-23.257 13.398-52.97 5.431-66.404-17.803ZM23.549 85.38a48.499 48.499 0 0 1 25.58-21.333v61.39a8.288 8.288 0 0 0 4.195 7.316l62.874 36.272-21.845 12.636a.819.819 0 0 1-.767 0L41.353 151.53c-23.211-13.454-31.171-43.144-17.804-66.405v.256Zm179.466 41.695-63.08-36.63L161.73 77.86a.819.819 0 0 1 .768 0l52.233 30.184a48.6 48.6 0 0 1-7.316 87.635v-61.391a8.544 8.544 0 0 0-4.4-7.213Zm21.742-32.69-1.535-.922-51.619-30.081a8.39 8.39 0 0 0-8.492 0L99.98 99.808V74.587a.716.716 0 0 1 .307-.665l52.233-30.133a48.652 48.652 0 0 1 72.236 50.391v.205ZM88.061 139.097l-21.845-12.585a.87.87 0 0 1-.41-.614V65.685a48.652 48.652 0 0 1 79.757-37.346l-1.535.87-51.67 29.825a8.595 8.595 0 0 0-4.246 7.367l-.051 72.697Zm11.868-25.58 28.138-16.217 28.188 16.218v32.434l-28.086 16.218-28.188-16.218-.052-32.434Z" />
  </svg>
);

// Rendered once in the trigger and once in the menu, so every instance needs
// its own gradient id.
function GeminiIcon() {
  const gradientId = useId();

  return (
    <svg
      aria-hidden="true"
      height="1em"
      style={{ flex: "none", lineHeight: "1" }}
      viewBox="0 0 24 24"
      xmlns="http://www.w3.org/2000/svg"
    >
      <defs>
        <linearGradient
          id={gradientId}
          x1="0%"
          x2="68.73%"
          y1="100%"
          y2="30.395%"
        >
          <stop offset="0%" stopColor="#1C7DFF" />
          <stop offset="52.021%" stopColor="#1C69FF" />
          <stop offset="100%" stopColor="#F0DCD6" />
        </linearGradient>
      </defs>
      <path
        d="M12 24A14.304 14.304 0 000 12 14.304 14.304 0 0012 0a14.305 14.305 0 0012 12 14.305 14.305 0 00-12 12"
        fill={`url(#${gradientId})`}
        fillRule="nonzero"
      />
    </svg>
  );
}

export interface PromptCommand {
  id: string;
  label: string;
  description?: string;
  icon?: ReactNode;
}

export interface PromptAttachment {
  id: string;
  file: File;
}

interface AIPromptProps {
  models?: string[];
  defaultModel?: string;
  placeholder?: string;
  headerText?: string;
  headerAction?: string;
  /**
   * Sends the message. Return a promise to show the Stop button until it
   * settles; `signal` aborts when the user presses Stop. Without it, the
   * component plays a short pretend reply so the demo feels alive.
   */
  onSubmit?: (
    value: string,
    model: string,
    options: {
      files: File[];
      signal: AbortSignal;
      command: PromptCommand | null;
    }
  ) => unknown;
  /** Shown when the message starts with "/". Pass [] to turn the menu off. */
  commands?: PromptCommand[];
  /** Called when the user presses Stop while a reply is in progress. */
  onStop?: () => void;
  /** Called with the files picked, dropped or pasted into the prompt. */
  onAttach?: (files: FileList | File[]) => void;
  className?: string;
}

const DEFAULT_COMMANDS: PromptCommand[] = [
  {
    id: "summarize",
    label: "Summarize",
    description: "Shorten text or files",
    icon: <AlignLeft className="size-4" />,
  },
  {
    id: "translate",
    label: "Translate",
    description: "Into another language",
    icon: <Languages className="size-4" />,
  },
  {
    id: "explain",
    label: "Explain",
    description: "Step by step, in plain words",
    icon: <Lightbulb className="size-4" />,
  },
  {
    id: "code",
    label: "Write code",
    description: "Functions, tests and fixes",
    icon: <Code2 className="size-4" />,
  },
];

const DEFAULT_MODELS = [
  "Gemini 3 Pro",
  "GPT-5.6 Mini",
  "Claude Fable 5",
  "GPT-5.6 Codex",
  "GPT-5.6",
];

const ANTHROPIC_ICON = (
  <svg
    aria-hidden="true"
    fill="currentColor"
    fillRule="evenodd"
    height="1em"
    style={{ flex: "none", lineHeight: "1" }}
    viewBox="0 0 24 24"
    width="1em"
    xmlns="http://www.w3.org/2000/svg"
  >
    <path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z" />
  </svg>
);

const MODEL_ICONS: Record<string, ReactNode> = {
  "GPT-5.6 Mini": OPENAI_ICON,
  "Gemini 3 Pro": <GeminiIcon />,
  "Claude Fable 5": ANTHROPIC_ICON,
  "GPT-5.6 Codex": OPENAI_ICON,
  "GPT-5.6": OPENAI_ICON,
};

const getModelIcon = (model: string) =>
  MODEL_ICONS[model] ?? (
    <Bot aria-hidden="true" className="h-4 w-4 opacity-50" />
  );

const EASE_OUT = [0.23, 1, 0.32, 1] as const;
const DEMO_REPLY_MS = 2400;
const WHITESPACE = /\s/;

const formatSize = (bytes: number) => {
  if (bytes < 1024) {
    return `${bytes} B`;
  }
  if (bytes < 1024 * 1024) {
    return `${Math.round(bytes / 1024)} KB`;
  }
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};

const waitFor = (ms: number, signal: AbortSignal) =>
  new Promise<void>((resolve) => {
    const id = setTimeout(resolve, ms);
    signal.addEventListener("abort", () => {
      clearTimeout(id);
      resolve();
    });
  });

function AttachmentChip({
  attachment,
  onRemove,
}: {
  attachment: PromptAttachment;
  onRemove: () => void;
}) {
  const { file } = attachment;
  const isImage = file.type.startsWith("image/");
  const [previewUrl, setPreviewUrl] = useState<string | null>(null);

  // Object URLs are created after mount and revoked on unmount, so a
  // removed chip doesn't keep its image in memory.
  useEffect(() => {
    if (!isImage) {
      return;
    }
    const url = URL.createObjectURL(file);
    setPreviewUrl(url);
    return () => URL.revokeObjectURL(url);
  }, [file, isImage]);

  return (
    <motion.li
      animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
      className="flex h-11 max-w-52 items-center gap-2 rounded-lg bg-white py-1 pr-1.5 pl-1 ring-1 ring-black/5 dark:bg-white/10 dark:ring-white/10"
      exit={{
        opacity: 0,
        filter: "blur(2px)",
        transition: { duration: 0.12, ease: EASE_OUT },
      }}
      initial={{ opacity: 0, y: 4, filter: "blur(2px)" }}
      layout="position"
      transition={{ duration: 0.2, ease: EASE_OUT }}
    >
      <span className="flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-md bg-black/5 text-black/50 dark:bg-white/10 dark:text-white/60">
        {previewUrl ? (
          // biome-ignore lint/performance/noImgElement: object URL preview, nothing to optimise
          <img
            alt=""
            className="size-full object-cover"
            height={36}
            src={previewUrl}
            width={36}
          />
        ) : (
          <FileText aria-hidden="true" className="size-4" />
        )}
      </span>
      <span className="min-w-0 flex-1 leading-tight">
        <span className="block truncate text-black text-xs dark:text-white">
          {file.name}
        </span>
        <span className="block text-[10px] text-black/50 tabular-nums dark:text-white/50">
          {formatSize(file.size)}
        </span>
      </span>
      <button
        aria-label={`Remove ${file.name}`}
        className="flex size-6 shrink-0 items-center justify-center rounded-md text-black/40 transition-colors duration-150 hover:bg-black/5 hover:text-black focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-blue-500 dark:text-white/40 dark:hover:bg-white/10 dark:hover:text-white"
        onClick={onRemove}
        type="button"
      >
        <X aria-hidden="true" className="size-3.5" />
      </button>
    </motion.li>
  );
}

function CommandMenu({
  id,
  commands,
  activeIndex,
  onHover,
  onSelect,
}: {
  id: string;
  commands: PromptCommand[];
  activeIndex: number;
  onHover: (index: number) => void;
  onSelect: (command: PromptCommand) => void;
}) {
  return (
    <motion.div
      animate={{ opacity: 1, y: 0, scale: 1 }}
      className="absolute inset-x-0 bottom-full z-10 mb-2 origin-bottom rounded-xl bg-white p-1 shadow-[0_8px_24px_-8px_rgb(0_0_0/0.18)] ring-1 ring-black/10 dark:bg-zinc-900 dark:ring-white/10"
      exit={{ opacity: 0, y: 2, scale: 0.98, transition: { duration: 0.1 } }}
      initial={{ opacity: 0, y: 4, scale: 0.98 }}
      transition={{ duration: 0.15, ease: EASE_OUT }}
    >
      {/* The highlight follows the keyboard instantly, without a transition. */}
      <div aria-label="Commands" id={id} role="listbox">
        {commands.map((command, i) => (
          // biome-ignore lint/a11y/useKeyWithClickEvents: options are driven from the textarea with the arrow keys
          <div
            aria-selected={i === activeIndex}
            className={cn(
              "flex h-9 cursor-pointer items-center gap-2 rounded-lg px-2",
              i === activeIndex && "bg-black/5 dark:bg-white/10"
            )}
            id={`${id}-${command.id}`}
            key={command.id}
            onClick={() => onSelect(command)}
            onMouseDown={(e) => e.preventDefault()}
            onPointerMove={() => onHover(i)}
            role="option"
            tabIndex={-1}
          >
            <span className="flex size-6 shrink-0 items-center justify-center text-black/50 dark:text-white/60 [&_svg]:size-4">
              {command.icon ?? <Slash />}
            </span>
            <span className="text-black text-sm dark:text-white">
              {command.label}
            </span>
            {command.description && (
              <span className="min-w-0 truncate text-black/40 text-xs dark:text-white/40">
                {command.description}
              </span>
            )}
          </div>
        ))}
      </div>
    </motion.div>
  );
}

function CommandPill({
  command,
  onRemove,
}: {
  command: PromptCommand;
  onRemove: () => void;
}) {
  return (
    <motion.li
      animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
      className="flex h-11 items-center gap-1.5 rounded-lg bg-blue-500/10 pr-1.5 pl-2.5 text-blue-600 text-sm dark:text-blue-400"
      exit={{
        opacity: 0,
        filter: "blur(2px)",
        transition: { duration: 0.12, ease: EASE_OUT },
      }}
      initial={{ opacity: 0, y: 4, filter: "blur(2px)" }}
      layout="position"
      transition={{ duration: 0.2, ease: EASE_OUT }}
    >
      <span aria-hidden="true" className="opacity-60">
        /
      </span>
      {command.label}
      <button
        aria-label={`Remove ${command.label} command`}
        className="flex size-6 items-center justify-center rounded-md transition-colors duration-150 hover:bg-blue-500/10 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-blue-500"
        onClick={onRemove}
        type="button"
      >
        <X aria-hidden="true" className="size-3.5" />
      </button>
    </motion.li>
  );
}

function SendButton({
  responding,
  canSend,
  onSend,
  onStop,
}: {
  responding: boolean;
  canSend: boolean;
  onSend: () => void;
  onStop: () => void;
}) {
  // One button with two faces. The faces crossfade with a little blur, so
  // Send turning into Stop reads as the same control changing state.
  const face =
    "absolute inset-0 flex items-center justify-center transition-[opacity,filter] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]";
  return (
    <button
      aria-label={responding ? "Stop response" : "Send message"}
      className={cn(
        "relative size-8 rounded-lg",
        "focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-blue-500",
        "transition-[background-color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]",
        responding
          ? "bg-black text-white hover:bg-black/80 dark:bg-white dark:text-black dark:hover:bg-white/80"
          : "bg-black/5 hover:bg-black/10 disabled:pointer-events-none dark:bg-white/5 dark:hover:bg-white/10"
      )}
      disabled={!(responding || canSend)}
      onClick={responding ? onStop : onSend}
      type="button"
    >
      <span
        aria-hidden="true"
        className={cn(
          face,
          responding ? "opacity-0 blur-[2px]" : "opacity-100 blur-0"
        )}
      >
        <ArrowRight
          className={cn(
            "h-4 w-4 transition-opacity duration-200 dark:text-white",
            canSend ? "opacity-100" : "opacity-30"
          )}
        />
      </span>
      <span
        aria-hidden="true"
        className={cn(
          face,
          responding ? "opacity-100 blur-0" : "opacity-0 blur-[2px]"
        )}
      >
        <span className="size-2.5 rounded-[2px] bg-current" />
      </span>
    </button>
  );
}

export default function AI_Prompt({
  models = DEFAULT_MODELS,
  defaultModel = "Claude Fable 5",
  placeholder = "What can I do for you? Type / for commands",
  headerText = "is free this weekend!",
  headerAction = "Ship Now!",
  onSubmit,
  onStop,
  onAttach,
  commands = DEFAULT_COMMANDS,
  className,
}: AIPromptProps) {
  const [value, setValue] = useState("");
  const [command, setCommand] = useState<PromptCommand | null>(null);
  const [menuIndex, setMenuIndex] = useState(0);
  const [menuDismissed, setMenuDismissed] = useState(false);
  const menuId = useId();
  const [attachments, setAttachments] = useState<PromptAttachment[]>([]);
  const [responding, setResponding] = useState(false);
  const [isDragging, setIsDragging] = useState(false);
  const abortRef = useRef<AbortController | null>(null);
  const nextIdRef = useRef(0);
  const { textareaRef, adjustHeight } = useAutoResizeTextarea({
    minHeight: 72,
    maxHeight: 300,
  });
  // A default that is not in `models` would show a model the menu can't pick.
  const [selectedModel, setSelectedModel] = useState(() =>
    models.includes(defaultModel) ? defaultModel : (models[0] ?? defaultModel)
  );
  const textareaId = useId();
  const shouldReduceMotion = useReducedMotion();
  const labelOffset = shouldReduceMotion ? 0 : 5;

  useEffect(() => () => abortRef.current?.abort(), []);

  const addFiles = (files: FileList | File[]) => {
    const list = Array.from(files);
    if (list.length === 0) {
      return;
    }
    onAttach?.(files);
    setAttachments((current) => [
      ...current,
      ...list.map((file) => {
        nextIdRef.current += 1;
        return { id: `attachment-${nextIdRef.current}`, file };
      }),
    ]);
  };

  const removeAttachment = (id: string) => {
    setAttachments((current) => current.filter((a) => a.id !== id));
    textareaRef.current?.focus();
  };

  // "/" at the very start opens the menu; the text after it filters it.
  const slashQuery =
    !command && value.startsWith("/") && !WHITESPACE.test(value)
      ? value.slice(1).toLowerCase()
      : null;
  const matches =
    slashQuery === null
      ? []
      : commands.filter((c) => c.label.toLowerCase().includes(slashQuery));
  const menuOpen = matches.length > 0 && !menuDismissed;
  const activeIndex = Math.min(menuIndex, matches.length - 1);

  const pickCommand = (picked: PromptCommand) => {
    setCommand(picked);
    setValue("");
    setMenuIndex(0);
    adjustHeight(true);
    textareaRef.current?.focus();
  };

  const canSend = value.trim().length > 0 && !responding;

  const submit = async () => {
    if (!canSend) {
      return;
    }
    const controller = new AbortController();
    abortRef.current = controller;
    const files = attachments.map((a) => a.file);
    const sentCommand = command;
    setValue("");
    setAttachments([]);
    setCommand(null);
    adjustHeight(true);
    setResponding(true);
    try {
      await (onSubmit
        ? onSubmit(value, selectedModel, {
            files,
            signal: controller.signal,
            command: sentCommand,
          })
        : waitFor(DEMO_REPLY_MS, controller.signal));
    } finally {
      if (abortRef.current === controller) {
        abortRef.current = null;
        setResponding(false);
      }
    }
  };

  const stop = () => {
    abortRef.current?.abort();
    abortRef.current = null;
    setResponding(false);
    onStop?.();
    textareaRef.current?.focus();
  };

  const handleMenuKey = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
    const step = { ArrowDown: 1, ArrowUp: -1 }[e.key];
    if (step) {
      e.preventDefault();
      setMenuIndex((activeIndex + step + matches.length) % matches.length);
      return true;
    }
    if (e.key === "Enter" || e.key === "Tab") {
      e.preventDefault();
      pickCommand(matches[activeIndex]);
      return true;
    }
    if (e.key === "Escape") {
      e.preventDefault();
      setMenuDismissed(true);
      return true;
    }
    return false;
  };

  const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
    if (e.nativeEvent.isComposing) {
      return;
    }
    if (menuOpen && handleMenuKey(e)) {
      return;
    }
    // Backspace in an empty prompt takes the command off first.
    if (e.key === "Backspace" && command && value === "") {
      e.preventDefault();
      setCommand(null);
      return;
    }
    // Enter during IME composition confirms the candidate; it must not submit.
    if (e.key === "Enter" && !e.shiftKey) {
      e.preventDefault();
      submit();
    } else if (e.key === "Escape" && responding) {
      e.preventDefault();
      stop();
    }
  };

  const hasFiles = (e: React.DragEvent) =>
    Array.from(e.dataTransfer.types).includes("Files");

  return (
    <div className={cn("w-full max-w-md py-4", className)}>
      <div className="rounded-2xl bg-black/5 p-1.5 pt-4 dark:bg-white/5">
        <div className="mx-2 mb-2.5 flex items-center gap-2">
          <div className="flex flex-1 items-center gap-2">
            <Anthropic className="h-3.5 w-3.5 text-black dark:hidden" />
            <AnthropicDark className="hidden h-3.5 w-3.5 dark:block" />
            <p className="text-black text-xs tracking-tighter dark:text-white/90">
              {headerText}
            </p>
          </div>
          <p className="text-black text-xs tracking-tighter dark:text-white/90">
            {headerAction}
          </p>
        </div>
        {/* biome-ignore lint/a11y/noStaticElementInteractions lint/a11y/noNoninteractiveElementInteractions: drop target for files; keyboard users have the attach button */}
        <div
          className={cn(
            "relative flex flex-col rounded-xl has-[textarea:focus-visible]:ring-1 has-[textarea:focus-visible]:ring-blue-500",
            isDragging && "ring-1 ring-blue-500"
          )}
          onDragLeave={(e) => {
            if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
              setIsDragging(false);
            }
          }}
          onDragOver={(e) => {
            if (hasFiles(e)) {
              e.preventDefault();
              setIsDragging(true);
            }
          }}
          onDrop={(e) => {
            if (!hasFiles(e)) {
              return;
            }
            e.preventDefault();
            setIsDragging(false);
            addFiles(e.dataTransfer.files);
          }}
        >
          <AnimatePresence initial={false}>
            {(attachments.length > 0 || command) && (
              <motion.div
                animate={{ height: "auto", opacity: 1 }}
                className="overflow-hidden rounded-t-xl bg-black/5 dark:bg-white/5"
                exit={{ height: 0, opacity: 0 }}
                initial={{ height: 0, opacity: 0 }}
                transition={{
                  duration: shouldReduceMotion ? 0 : 0.2,
                  ease: EASE_OUT,
                }}
              >
                <ul
                  aria-label="Attachments"
                  className="flex flex-wrap gap-2 px-3 pt-3"
                >
                  <AnimatePresence initial={false} mode="popLayout">
                    {command && (
                      <CommandPill
                        command={command}
                        key={`command-${command.id}`}
                        onRemove={() => {
                          setCommand(null);
                          textareaRef.current?.focus();
                        }}
                      />
                    )}
                    {attachments.map((attachment) => (
                      <AttachmentChip
                        attachment={attachment}
                        key={attachment.id}
                        onRemove={() => removeAttachment(attachment.id)}
                      />
                    ))}
                  </AnimatePresence>
                </ul>
              </motion.div>
            )}
          </AnimatePresence>
          <label className="sr-only" htmlFor={textareaId}>
            Message
          </label>
          <AnimatePresence>
            {menuOpen && (
              <CommandMenu
                activeIndex={activeIndex}
                commands={matches}
                id={menuId}
                onHover={setMenuIndex}
                onSelect={pickCommand}
              />
            )}
          </AnimatePresence>
          <Textarea
            aria-activedescendant={
              menuOpen ? `${menuId}-${matches[activeIndex].id}` : undefined
            }
            aria-autocomplete="list"
            aria-controls={menuOpen ? menuId : undefined}
            aria-expanded={menuOpen}
            className={cn(
              "w-full resize-none rounded-xl rounded-b-none border-none bg-black/5 px-4 py-3 placeholder:text-black/70 focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-white/5 dark:text-white dark:placeholder:text-white/70",
              "min-h-[72px]",
              (attachments.length > 0 || command) && "rounded-t-none"
            )}
            id={textareaId}
            onBlur={() => setMenuDismissed(true)}
            onChange={(e) => {
              setValue(e.target.value);
              setMenuDismissed(false);
              setMenuIndex(0);
              adjustHeight();
            }}
            onFocus={() => setMenuDismissed(false)}
            onKeyDown={handleKeyDown}
            onPaste={(e) => {
              if (e.clipboardData.files.length > 0) {
                e.preventDefault();
                addFiles(e.clipboardData.files);
              }
            }}
            placeholder={placeholder}
            ref={textareaRef}
            value={value}
          />

          <div className="flex h-14 items-center justify-between rounded-b-xl bg-black/5 px-3 dark:bg-white/5">
            <div className="flex items-center gap-2">
              <DropdownMenu>
                <DropdownMenuTrigger asChild>
                  <Button
                    className="relative flex h-8 items-center gap-1 rounded-md pr-2 pl-1 text-xs hover:bg-black/10 focus-visible:ring-1 focus-visible:ring-blue-500 focus-visible:ring-offset-0 dark:text-white dark:hover:bg-white/10"
                    variant="ghost"
                  >
                    <AnimatePresence initial={false} mode="popLayout">
                      <motion.div
                        animate={{
                          opacity: 1,
                          y: 0,
                        }}
                        className="flex items-center gap-1"
                        exit={{
                          opacity: 0,
                          y: labelOffset,
                        }}
                        initial={{
                          opacity: 0,
                          y: -labelOffset,
                        }}
                        key={selectedModel}
                        transition={{
                          duration: 0.15,
                          ease: EASE_OUT,
                        }}
                      >
                        {getModelIcon(selectedModel)}
                        {selectedModel}
                        <ChevronDown className="h-3 w-3 opacity-50" />
                      </motion.div>
                    </AnimatePresence>
                  </Button>
                </DropdownMenuTrigger>
                <DropdownMenuContent className="min-w-[10rem] border-black/10 dark:border-white/10">
                  {models.map((model) => (
                    <DropdownMenuItem
                      className="flex items-center justify-between gap-2"
                      key={model}
                      onSelect={() => setSelectedModel(model)}
                    >
                      <div className="flex items-center gap-2">
                        {getModelIcon(model)}
                        <span>{model}</span>
                      </div>
                      {selectedModel === model && (
                        <Check className="h-4 w-4 text-blue-500" />
                      )}
                    </DropdownMenuItem>
                  ))}
                </DropdownMenuContent>
              </DropdownMenu>
              <div className="mx-0.5 h-4 w-px bg-black/10 dark:bg-white/10" />
              <label
                className={cn(
                  "cursor-pointer rounded-lg bg-black/5 p-2 dark:bg-white/5",
                  "hover:bg-black/10 has-focus-visible:ring-1 has-focus-visible:ring-blue-500 has-focus-visible:ring-offset-0 dark:hover:bg-white/10",
                  "text-black/40 hover:text-black dark:text-white/40 dark:hover:text-white",
                  "transition-[background-color,color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]"
                )}
              >
                <input
                  aria-label="Attach files"
                  className="sr-only"
                  multiple
                  onChange={(e) => {
                    if (e.target.files?.length) {
                      addFiles(e.target.files);
                    }
                    // Cleared so picking the same file again still fires.
                    e.target.value = "";
                  }}
                  type="file"
                />
                <Paperclip className="h-4 w-4 transition-colors" />
              </label>
            </div>
            <SendButton
              canSend={canSend}
              onSend={submit}
              onStop={stop}
              responding={responding}
            />
          </div>

          {/* Shown while files are dragged over the prompt. */}
          <div
            aria-hidden="true"
            className={cn(
              "pointer-events-none absolute inset-0 flex items-center justify-center rounded-xl border border-blue-500/50 border-dashed bg-white/80 text-blue-600 text-xs transition-opacity duration-150 dark:bg-black/70 dark:text-blue-400",
              isDragging ? "opacity-100" : "opacity-0"
            )}
          >
            Drop files to attach
          </div>
        </div>
      </div>
    </div>
  );
}
/hooks/use-auto-resize-textarea.ts
import { useCallback, useEffect, useRef } from "react";

interface UseAutoResizeTextareaProps {
  minHeight: number;
  maxHeight?: number;
}

export function useAutoResizeTextarea({
  minHeight,
  maxHeight,
}: UseAutoResizeTextareaProps) {
  const textareaRef = useRef<HTMLTextAreaElement>(null);

  const adjustHeight = useCallback(
    (reset?: boolean) => {
      const textarea = textareaRef.current;
      if (!textarea) return;

      if (reset) {
        textarea.style.height = `${minHeight}px`;
        return;
      }

      // Temporarily shrink to get the right scrollHeight
      textarea.style.height = `${minHeight}px`;

      // Calculate new height
      const newHeight = Math.max(
        minHeight,
        Math.min(textarea.scrollHeight, maxHeight ?? Number.POSITIVE_INFINITY)
      );

      textarea.style.height = `${newHeight}px`;
    },
    [minHeight, maxHeight]
  );

  useEffect(() => {
    // Set initial height
    const textarea = textareaRef.current;
    if (textarea) {
      textarea.style.height = `${minHeight}px`;
    }
  }, [minHeight]);

  // Adjust height on window resize
  useEffect(() => {
    const handleResize = () => adjustHeight();
    window.addEventListener("resize", handleResize);
    return () => window.removeEventListener("resize", handleResize);
  }, [adjustHeight]);

  return { textareaRef, adjustHeight };
}
/components/icons/anthropic.tsx
const Anthropic = (props: React.SVGProps<SVGSVGElement>) => (
  <svg
    fill="#000"
    fillRule="evenodd"
    height="1em"
    style={{
      flex: "none",
      lineHeight: 1,
    }}
    viewBox="0 0 24 24"
    width="1em"
    xmlns="http://www.w3.org/2000/svg"
    {...props}
  >
    <title>{"Anthropic"}</title>
    <path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z" />
  </svg>
);
export default Anthropic;
/components/icons/anthropic-dark.tsx
import type { SVGProps } from "react";

const AnthropicDark = (props: SVGProps<SVGSVGElement>) => (
  <svg
    fill="#ffff"
    fillRule="evenodd"
    height="1em"
    style={{
      flex: "none",
      lineHeight: 1,
    }}
    viewBox="0 0 24 24"
    width="1em"
    xmlns="http://www.w3.org/2000/svg"
    {...props}
  >
    <title>{"Anthropic"}</title>
    <path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z" />
  </svg>
);
export default AnthropicDark;

More from Kokonut UI

Browse all components in this library →