Todos os componentes

AI Prompt Input

AI Prompt Input é um componente de formulário para React e Tailwind CSS, da biblioteca SmoothUI, com licença MIT. Copia e cola no teu projeto.

SmoothUI MIT forms

O que é

AI Prompt Input é um componente de formulário para React e Tailwind CSS, da biblioteca SmoothUI, com licença MIT. Copia e cola no teu projeto.

Como usar

Abre o componente no catálogo, carrega em copiar, e cola no teu projeto. O prompt copiado leva o código e as regras para o teu agente o reproduzir sem alterar nada.

Abrir no catálogo

Cinco cópias grátis por mês. Sem cartão.

Precisa de

npm i lucide-react motion

Licença

Este componente vem de SmoothUI e é redistribuído sob a licença MIT, que permite uso comercial. O aviso de copyright viaja com o código.

AI Prompt Input

index.tsx
"use client";

import { cn } from "@/lib/utils";
import { ArrowUp, Paperclip, Square, X } from "lucide-react";
import {
  AnimatePresence,
  type MotionStyle,
  motion,
  type Transition,
  useReducedMotion,
} from "motion/react";
import {
  type ChangeEvent,
  type KeyboardEvent,
  type ReactNode,
  useCallback,
  useLayoutEffect,
  useRef,
  useState,
} from "react";
import {
  type AIState,
  getAIStateAccentColor,
  getAIStateMotion,
} from "../ai-core";

const SUBMIT_ICON_SIZE = 17;
const STOP_ICON_SIZE = 13;

const MIN_ROWS = 1;
const MAX_ROWS = 8;
const COUNTER_VISIBLE_RATIO = 0.8;
const SPRING_DEFAULT: Transition = {
  bounce: 0.1,
  duration: 0.25,
  type: "spring",
};
const SPRING_SNAPPY: Transition = {
  bounce: 0,
  duration: 0.2,
  type: "spring",
};
const EASE_OUT = [0.23, 1, 0.32, 1] as const;
const ATTACHMENT_STAGGER = 0.035;

export type AIPromptAttachment = {
  id: string;
  /** Bytes. Rendered as a human-readable size when present. */
  size?: number;
  name: string;
};

export type AIPromptInputProps = {
  /** Files already attached to the draft. */
  attachments?: AIPromptAttachment[];
  /** Extra controls rendered on the left of the toolbar — model pickers etc. */
  children?: ReactNode;
  className?: string;
  disabled?: boolean;
  /** Shows a counter once the draft passes 80% of the limit. */
  maxLength?: number;
  onAttach?: () => void;
  onRemoveAttachment?: (id: string) => void;
  /** Called when the submit control is pressed while `state` is `streaming`. */
  onStop?: () => void;
  onSubmit?: (value: string) => void;
  onValueChange?: (value: string) => void;
  placeholder?: string;
  /** Shared AI state. `streaming` turns submit into stop. */
  state?: AIState;
  /** Controlled draft. Leave undefined to let the component own it. */
  value?: string;
};

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

/**
 * The prompt composer.
 *
 * Growth is animated with a layout spring rather than a CSS height transition,
 * so a pasted paragraph settles instead of snapping — and because the height is
 * measured from the textarea's own scroll height, the surrounding page never
 * reflows mid-keystroke.
 */
const AIPromptInput = ({
  attachments = [],
  children,
  className,
  disabled = false,
  maxLength,
  onAttach,
  onRemoveAttachment,
  onStop,
  onSubmit,
  onValueChange,
  placeholder = "Ask anything…",
  state = "idle",
  value,
}: AIPromptInputProps) => {
  const shouldReduceMotion = useReducedMotion();
  const stateMotion = getAIStateMotion(state);
  const accentColor = getAIStateAccentColor(state, "transparent");

  const textareaRef = useRef<HTMLTextAreaElement | null>(null);
  const [internalValue, setInternalValue] = useState("");
  const [isFocused, setIsFocused] = useState(false);

  const isControlled = value !== undefined;
  const draft = isControlled ? value : internalValue;
  const isStreaming = state === "streaming";
  const canSubmit = draft.trim().length > 0 && !disabled;

  const resize = useCallback(() => {
    const textarea = textareaRef.current;
    if (!textarea) {
      return;
    }
    // Collapse first, otherwise scrollHeight only ever grows.
    textarea.style.height = "auto";
    const lineHeight = Number.parseFloat(
      getComputedStyle(textarea).lineHeight || "20"
    );
    const maxHeight = lineHeight * MAX_ROWS;
    textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeight)}px`;
    textarea.style.overflowY =
      textarea.scrollHeight > maxHeight ? "auto" : "hidden";
  }, []);

  useLayoutEffect(() => {
    // Collapsing on an empty draft matters after submit: the box has to return
    // to one row instead of holding the height of the message just sent.
    if (draft.length === 0 && textareaRef.current) {
      textareaRef.current.style.height = "";
    }
    resize();
  }, [draft, resize]);

  const handleChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
    const next = event.target.value;
    if (!isControlled) {
      setInternalValue(next);
    }
    onValueChange?.(next);
  };

  const submit = () => {
    if (isStreaming) {
      onStop?.();
      return;
    }
    if (!canSubmit) {
      return;
    }
    onSubmit?.(draft.trim());
    if (!isControlled) {
      setInternalValue("");
    }
  };

  const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
    // Enter sends, Shift+Enter breaks the line — the convention every chat UI
    // has trained users on.
    if (event.key === "Enter" && !event.shiftKey) {
      event.preventDefault();
      submit();
    }
  };

  const counterVisible =
    maxLength !== undefined &&
    draft.length >= maxLength * COUNTER_VISIBLE_RATIO;
  const overLimit = maxLength !== undefined && draft.length > maxLength;

  return (
    <motion.div
      className={cn(
        "relative w-full rounded-2xl border bg-background",
        isFocused ? "border-foreground/40" : "border-border",
        disabled && "opacity-60",
        className
      )}
      layout={shouldReduceMotion ? false : "position"}
      style={
        {
          // The accent ring is the only place status shows here: a composer that
          // changes shape per state would fight the text the user is writing.
          boxShadow:
            state === "error" || state === "done"
              ? `0 0 0 1px ${accentColor}`
              : undefined,
        } as MotionStyle
      }
      transition={shouldReduceMotion ? { duration: 0 } : SPRING_DEFAULT}
    >
      <AnimatePresence initial={false}>
        {attachments.length > 0 && (
          <motion.ul
            animate={{ height: "auto", opacity: 1 }}
            className="flex list-none flex-wrap gap-2 overflow-hidden px-3 pt-3"
            exit={
              shouldReduceMotion
                ? { opacity: 0, transition: { duration: 0 } }
                : { height: 0, opacity: 0 }
            }
            initial={
              shouldReduceMotion
                ? { height: "auto", opacity: 1 }
                : { height: 0, opacity: 0 }
            }
            transition={shouldReduceMotion ? { duration: 0 } : SPRING_DEFAULT}
          >
            {attachments.map((attachment, index) => (
              <motion.li
                animate={{ opacity: 1, scale: 1, y: 0 }}
                className="flex items-center gap-1.5 rounded-lg border border-border bg-muted/50 py-1 pr-1 pl-2 text-xs"
                exit={
                  shouldReduceMotion
                    ? { opacity: 0, transition: { duration: 0 } }
                    : { opacity: 0, scale: 0.9 }
                }
                initial={
                  shouldReduceMotion
                    ? { opacity: 1, scale: 1, y: 0 }
                    : { opacity: 0, scale: 0.9, y: 4 }
                }
                key={attachment.id}
                layout={!shouldReduceMotion}
                transition={
                  shouldReduceMotion
                    ? { duration: 0 }
                    : { ...SPRING_DEFAULT, delay: index * ATTACHMENT_STAGGER }
                }
              >
                <span className="max-w-40 truncate">{attachment.name}</span>
                {attachment.size !== undefined && (
                  <span className="text-muted-foreground">
                    {formatSize(attachment.size)}
                  </span>
                )}
                {onRemoveAttachment ? (
                  <button
                    aria-label={`Remove ${attachment.name}`}
                    className="cursor-pointer rounded-md p-0.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
                    onClick={() => onRemoveAttachment(attachment.id)}
                    type="button"
                  >
                    <X aria-hidden="true" size={12} />
                  </button>
                ) : null}
              </motion.li>
            ))}
          </motion.ul>
        )}
      </AnimatePresence>

      <textarea
        className="max-h-64 w-full resize-none bg-transparent px-4 pt-3 pb-2 text-sm outline-none placeholder:text-muted-foreground"
        disabled={disabled}
        onBlur={() => setIsFocused(false)}
        onChange={handleChange}
        onFocus={() => setIsFocused(true)}
        onKeyDown={handleKeyDown}
        placeholder={placeholder}
        ref={textareaRef}
        rows={MIN_ROWS}
        value={draft}
      />

      <div className="flex items-center justify-between gap-2 px-2 pb-2">
        <div className="flex min-w-0 items-center gap-1">
          {onAttach ? (
            <button
              aria-label="Attach a file"
              className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
              disabled={disabled}
              onClick={onAttach}
              type="button"
            >
              <Paperclip aria-hidden="true" size={16} />
            </button>
          ) : null}
          {children}
        </div>

        <div className="flex items-center gap-2">
          <AnimatePresence initial={false}>
            {counterVisible ? (
              <motion.span
                animate={{ opacity: 1, y: 0 }}
                className={cn(
                  "text-xs tabular-nums",
                  overLimit ? "text-destructive" : "text-muted-foreground"
                )}
                exit={
                  shouldReduceMotion
                    ? { opacity: 0, transition: { duration: 0 } }
                    : { opacity: 0, y: 4 }
                }
                initial={
                  shouldReduceMotion
                    ? { opacity: 1, y: 0 }
                    : { opacity: 0, y: 4 }
                }
                transition={
                  shouldReduceMotion ? { duration: 0 } : SPRING_SNAPPY
                }
              >
                {draft.length}/{maxLength}
              </motion.span>
            ) : null}
          </AnimatePresence>

          <AIPromptSubmit
            disabled={disabled || !(canSubmit || isStreaming)}
            isStreaming={isStreaming}
            onClick={submit}
            shouldReduceMotion={Boolean(shouldReduceMotion)}
            speed={stateMotion.speed}
          />
        </div>
      </div>
    </motion.div>
  );
};

type AIPromptSubmitProps = {
  disabled: boolean;
  isStreaming: boolean;
  onClick: () => void;
  shouldReduceMotion: boolean;
  speed: number;
};

const AIPromptSubmit = ({
  disabled,
  isStreaming,
  onClick,
  shouldReduceMotion,
  speed,
}: AIPromptSubmitProps) => (
  <motion.button
    aria-label={isStreaming ? "Stop generating" : "Send message"}
    className={cn(
      "flex size-9 items-center justify-center rounded-xl",
      disabled
        ? "bg-muted text-muted-foreground"
        : "bg-foreground text-background"
    )}
    disabled={disabled}
    onClick={onClick}
    transition={shouldReduceMotion ? { duration: 0 } : SPRING_DEFAULT}
    type="button"
    whileHover={disabled || shouldReduceMotion ? undefined : { scale: 1.05 }}
    whileTap={disabled || shouldReduceMotion ? undefined : { scale: 0.95 }}
  >
    {/* Real lucide glyphs, swapped rather than morphed.
        An earlier version interpolated one hand-authored `d` into another, which
        bought a nice fold at the cost of not being a lucide icon at all — and a
        filled wedge reads as "play", not "send". A thin stroked arrow is the
        convention, and lucide's own geometry is what the rest of the library
        uses. The swap is scale-and-fade so it still reads as one control. */}
    <AnimatePresence initial={false} mode="popLayout">
      <motion.span
        animate={{ opacity: 1, scale: 1 }}
        className="flex items-center justify-center"
        exit={
          shouldReduceMotion
            ? { opacity: 0, transition: { duration: 0 } }
            : { opacity: 0, scale: 0.6 }
        }
        initial={
          shouldReduceMotion ? { opacity: 1 } : { opacity: 0, scale: 0.6 }
        }
        key={isStreaming ? "stop" : "send"}
        transition={
          shouldReduceMotion
            ? { duration: 0 }
            : { duration: 0.18 / speed, ease: EASE_OUT }
        }
      >
        {isStreaming ? (
          <Square
            aria-hidden="true"
            className="fill-current"
            size={STOP_ICON_SIZE}
            strokeWidth={0}
          />
        ) : (
          <ArrowUp aria-hidden="true" size={SUBMIT_ICON_SIZE} />
        )}
      </motion.span>
    </AnimatePresence>
  </motion.button>
);

export default AIPromptInput;

Componentes parecidos