shadimage
motokoui
uiai-prompt-inputsource: ok · hit

AI Prompt Input

A premium AI prompt composer with rotating placeholders, floating toolbar, and animated model selector.

npx shadcn add "https://www.motokoui.com/r/ai-prompt-input.json"
free

Interaction

dropdown

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

Dependencies

Bundle weight · medium
animationmotion
iconslucide-react

Registry dependencies

utilsai-model-selector

Files1 · source cached 10/8/2026, 12:19:06 PM

registry/motokoui/ai-prompt-input.tsx
"use client"

import * as React from "react"
import {
  ArrowUpIcon,
  AudioLinesIcon,
  CheckIcon,
  GlobeIcon,
  Loader2Icon,
  MicIcon,
  PlusIcon,
  PuzzleIcon,
  SquareIcon,
  TelescopeIcon,
  UnplugIcon,
  UploadIcon,
  XIcon,
} from "lucide-react"
import { AnimatePresence, motion, type HTMLMotionProps } from "motion/react"
import { createPortal } from "react-dom"

import { cn } from "@/lib/utils"
import {
  DEFAULT_AI_MODELS,
  defaultSelectionFor,
  ModelSelector,
  ModelSelectorContent,
  ModelSelectorTrigger,
  ModelSelectorValue,
  type AiModel,
  type AiModelSelection,
} from "@/components/ui/ai-model-selector"

/* =============================================================================
 * AiPromptInput — Motoko UI
 *
 * A premium AI prompt composer: auto-growing textarea, rotating placeholder,
 * floating toolbar, Cursor-style model selector, and stateful send control.
 *
 *   <AiPromptInput onSubmit={(value, selection) => console.log(value, selection)} />
 * ============================================================================= */

// ---------------------------------------------------------------------------
// Motion tokens
// ---------------------------------------------------------------------------

const EASE = [0.2, 0, 0, 1] as const
const SPRING_SOFT = { type: "spring" as const, stiffness: 420, damping: 32 }
const SPRING_HEIGHT = { type: "spring" as const, stiffness: 380, damping: 34 }
const SPRING_PRESS = { type: "spring" as const, stiffness: 500, damping: 28 }
const SPRING_ICON = { type: "spring" as const, duration: 0.3, bounce: 0 }

const MENU_PANEL_CLASS = cn(
  "bg-b-pop origin-bottom-left overflow-hidden rounded-2xl border-2 p-1.5",
  "shadow-[0_8px_30px_-8px_rgba(8,8,8,0.18),0_2px_8px_-2px_rgba(8,8,8,0.08)]",
  "dark:shadow-[0_8px_30px_-8px_rgba(0,0,0,0.45),0_2px_8px_-2px_rgba(0,0,0,0.3)]"
)

const CHIP_SURFACE_CLASS = cn(
  "bg-b-surface1 text-t-primary inline-flex items-center gap-1.5 rounded-full py-1 pr-1 pl-2 text-xs font-medium",
  "shadow-[inset_0_0_0_1px_rgba(123,123,123,0.12)]"
)

const TOOLBAR_BTN_CLASS = cn(
  "relative flex size-9 cursor-pointer items-center justify-center rounded-xl",
  "transition-[background-color,color,box-shadow,opacity,transform] duration-200 ease-[cubic-bezier(0.2,0,0,1)]",
  "hover:bg-b-surface1 hover:text-t-primary",
  "focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
  "disabled:pointer-events-none disabled:opacity-40"
)

type PresenceProps = Pick<
  HTMLMotionProps<"span">,
  "initial" | "animate" | "exit" | "transition"
>

const FADE_ONLY: PresenceProps = {
  initial: { opacity: 0 },
  animate: { opacity: 1 },
  exit: { opacity: 0 },
}

const ICON_SWAP: PresenceProps = {
  initial: { opacity: 0, scale: 0.25, filter: "blur(4px)" },
  animate: { opacity: 1, scale: 1, filter: "blur(0px)" },
  exit: { opacity: 0, scale: 0.25, filter: "blur(4px)" },
  transition: SPRING_ICON,
}

function scaleBlurPresence(reduceMotion: boolean): PresenceProps {
  if (reduceMotion) return FADE_ONLY
  return {
    initial: { opacity: 0, scale: 0.9, filter: "blur(4px)" },
    animate: { opacity: 1, scale: 1, filter: "blur(0px)" },
    exit: { opacity: 0, scale: 0.9, filter: "blur(4px)" },
    transition: SPRING_ICON,
  }
}

function menuPresence(reduceMotion: boolean): PresenceProps {
  if (reduceMotion) return FADE_ONLY
  return {
    initial: { opacity: 0, y: 6, scale: 0.96, filter: "blur(4px)" },
    animate: { opacity: 1, y: 0, scale: 1, filter: "blur(0px)" },
    exit: { opacity: 0, y: 4, scale: 0.98, filter: "blur(2px)" },
    transition: { duration: 0.2, ease: EASE },
  }
}

function placeholderPresence(reduceMotion: boolean): PresenceProps {
  if (reduceMotion) return FADE_ONLY
  return {
    initial: { opacity: 0, y: 6, filter: "blur(4px)" },
    animate: { opacity: 1, y: 0, filter: "blur(0px)" },
    exit: { opacity: 0, y: -6, filter: "blur(4px)" },
    transition: { duration: 0.35, ease: EASE },
  }
}

function iconPresence(reduceMotion: boolean): PresenceProps {
  return reduceMotion ? FADE_ONLY : ICON_SWAP
}

function statusPresence(reduceMotion: boolean): PresenceProps {
  if (reduceMotion) return FADE_ONLY
  return {
    initial: { opacity: 0, scale: 0.92, filter: "blur(4px)" },
    animate: { opacity: 1, scale: 1, filter: "blur(0px)" },
    exit: { opacity: 0, scale: 0.94, filter: "blur(4px)" },
    transition: { type: "spring", duration: 0.35, bounce: 0 },
  }
}

// ---------------------------------------------------------------------------
// Public types & defaults
// ---------------------------------------------------------------------------

export type { AiModel, AiModelSelection }

/** @deprecated Use `AiModel` from the model selector. */
export type AiPromptModel = AiModel

export type AiPromptSendStatus = "idle" | "loading" | "success"

export type DictationPhase = "idle" | "recording" | "processing"

export const DEFAULT_PLACEHOLDERS = [
  "Ask anything...",
  "Explain this React component...",
  "Generate a landing page...",
  "Fix this TypeScript error...",
  "Create a pricing section...",
  "Write SQL query...",
] as const

/** Default models — same set as `DEFAULT_AI_MODELS` from AI Model Selector. */
export const DEFAULT_MODELS: AiModel[] = DEFAULT_AI_MODELS

export interface AiPromptInputProps {
  /** Controlled prompt value. */
  value?: string
  /** Uncontrolled initial value. */
  defaultValue?: string
  /** Fires on every value change. */
  onChange?: (value: string) => void
  /** Fires when the user sends (Enter or Send button). */
  onSubmit?: (value: string, selection: AiModelSelection) => void
  /** Rotating placeholder phrases. Falls back to Motoko defaults. */
  placeholders?: readonly string[]
  /** Interval between placeholder rotates, in ms. */
  placeholderInterval?: number
  /** Available models for the selector. */
  models?: AiModel[]
  /** Controlled model selection (id + effort / context / modes). */
  modelSelection?: AiModelSelection
  /** Uncontrolled initial model selection. */
  defaultModelSelection?: AiModelSelection
  /** Fires when the model selection changes. */
  onModelSelectionChange?: (selection: AiModelSelection) => void
  /** Disable the whole composer. */
  disabled?: boolean
  /** External send status — drives loader / success on the send button. */
  status?: AiPromptSendStatus
  /** Soft limit for the textarea `maxLength` attribute. */
  maxLength?: number
  /** Minimum textarea rows. */
  minRows?: number
  /** Maximum textarea rows before scrolling. */
  maxRows?: number
  /** Show the floating toolbar. */
  showToolbar?: boolean
  /** Show the + actions menu. */
  showActions?: boolean
  /** Show the model selector. */
  showModelSelector?: boolean
  /** Controlled deep research toggle. */
  deepResearch?: boolean
  /** Uncontrolled initial deep research. */
  defaultDeepResearch?: boolean
  /** Fires when deep research is toggled. */
  onDeepResearchChange?: (active: boolean) => void
  /** Controlled web search toggle. */
  webSearch?: boolean
  /** Uncontrolled initial web search. */
  defaultWebSearch?: boolean
  /** Fires when web search is toggled. */
  onWebSearchChange?: (active: boolean) => void
  /** Fires when Upload file is chosen. */
  onUploadFile?: () => void
  /** Fires when Skills is chosen. */
  onSkills?: () => void
  /** Fires when Connectors is chosen. */
  onConnectors?: () => void
  /**
   * Optional transcript provider for mic dictation.
   * Defaults to cycling Motoko demo phrases when omitted.
   */
  getDictationTranscript?: () => string
  /** Fires when mic dictation starts or stops. */
  onDictationChange?: (listening: boolean) => void
  /** Fires when talk-with-AI voice mode starts or stops. */
  onVoiceChange?: (active: boolean) => void
  /** Extra classes on the shell. */
  className?: string
  /** Extra classes on the textarea. */
  textareaClassName?: string
  /** Accessible name for the prompt field. */
  "aria-label"?: string
  /** Test id for the shell. */
  "data-testid"?: string
}

// ---------------------------------------------------------------------------
// Shared utilities
// ---------------------------------------------------------------------------

function assignRef<T>(
  node: T | null,
  ...refs: Array<React.Ref<T> | undefined>
) {
  for (const ref of refs) {
    if (typeof ref === "function") ref(node)
    else if (ref) (ref as React.MutableRefObject<T | null>).current = node
  }
}

function usePrefersReducedMotion() {
  const [reduceMotion, setReduceMotion] = React.useState(false)

  React.useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)")
    const sync = () => setReduceMotion(mq.matches)
    sync()
    mq.addEventListener("change", sync)
    return () => mq.removeEventListener("change", sync)
  }, [])

  return reduceMotion
}

function useControllableState<T>({
  value,
  defaultValue,
  onChange,
}: {
  value: T | undefined
  defaultValue: T
  onChange?: (value: T) => void
}): [T, (next: T) => void] {
  const [uncontrolled, setUncontrolled] = React.useState(defaultValue)
  const isControlled = value !== undefined
  const current = isControlled ? value : uncontrolled

  const setValue = React.useCallback(
    (next: T) => {
      if (!isControlled) setUncontrolled(next)
      onChange?.(next)
    },
    [isControlled, onChange]
  )

  return [current, setValue]
}

type MenuCoords = { top: number; left: number }

/** Shared open/close, portal coords, outside-click, and Escape for anchored menus. */
function useAnchoredMenu(disabled = false) {
  const [open, setOpen] = React.useState(false)
  const [mounted, setMounted] = React.useState(false)
  const [coords, setCoords] = React.useState<MenuCoords | null>(null)
  const triggerRef = React.useRef<HTMLButtonElement>(null)
  const contentRef = React.useRef<HTMLDivElement>(null)
  const menuId = React.useId()

  React.useEffect(() => {
    setMounted(true)
  }, [])

  React.useLayoutEffect(() => {
    if (!open) return

    const update = () => {
      const trigger = triggerRef.current
      if (!trigger) return
      const rect = trigger.getBoundingClientRect()
      setCoords({
        top: rect.top + window.scrollY - 8,
        left: rect.left + window.scrollX,
      })
    }

    update()
    window.addEventListener("resize", update)
    window.addEventListener("scroll", update, true)
    return () => {
      window.removeEventListener("resize", update)
      window.removeEventListener("scroll", update, true)
    }
  }, [open])

  React.useEffect(() => {
    if (!open) return

    const onPointer = (event: MouseEvent | TouchEvent) => {
      const target = event.target as Node | null
      if (!target) return
      const inTrigger = triggerRef.current?.contains(target)
      const inContent = contentRef.current?.contains(target)
      if (!inTrigger && !inContent) setOpen(false)
    }

    const onKey = (event: KeyboardEvent) => {
      if (event.key !== "Escape") return
      event.preventDefault()
      setOpen(false)
      triggerRef.current?.focus()
    }

    document.addEventListener("mousedown", onPointer)
    document.addEventListener("touchstart", onPointer)
    document.addEventListener("keydown", onKey)
    return () => {
      document.removeEventListener("mousedown", onPointer)
      document.removeEventListener("touchstart", onPointer)
      document.removeEventListener("keydown", onKey)
    }
  }, [open])

  React.useEffect(() => {
    if (disabled) setOpen(false)
  }, [disabled])

  return { open, setOpen, mounted, coords, triggerRef, contentRef, menuId }
}

function AnchoredMenuPortal({
  mounted,
  open,
  coords,
  contentRef,
  id,
  role,
  "aria-label": ariaLabel,
  reduceMotion,
  className,
  children,
}: {
  mounted: boolean
  open: boolean
  coords: MenuCoords | null
  contentRef: React.RefObject<HTMLDivElement | null>
  id: string
  role: "menu" | "listbox"
  "aria-label": string
  reduceMotion: boolean
  className?: string
  children: React.ReactNode
}) {
  if (!mounted) return null

  return createPortal(
    <AnimatePresence>
      {open && coords ? (
        <motion.div
          key={id}
          ref={contentRef}
          id={id}
          role={role}
          aria-label={ariaLabel}
          {...menuPresence(reduceMotion)}
          style={{
            position: "absolute",
            top: coords.top,
            left: coords.left,
            transform: "translateY(-100%)",
            zIndex: 50,
          }}
          className={cn(MENU_PANEL_CLASS, className)}
        >
          {children}
        </motion.div>
      ) : null}
    </AnimatePresence>,
    document.body
  )
}

function MenuCheckmark({
  visible,
  reduceMotion,
  className,
}: {
  visible: boolean
  reduceMotion: boolean
  className?: string
}) {
  return (
    <AnimatePresence initial={false}>
      {visible ? (
        <motion.span
          key="check"
          {...scaleBlurPresence(reduceMotion)}
          className={cn("flex shrink-0", className)}
        >
          <CheckIcon className="size-3.5" aria-hidden />
        </motion.span>
      ) : null}
    </AnimatePresence>
  )
}

function IconSwapFrame({
  swapKey,
  reduceMotion,
  children,
}: {
  swapKey: string
  reduceMotion: boolean
  children: React.ReactNode
}) {
  return (
    <motion.span
      key={swapKey}
      {...iconPresence(reduceMotion)}
      className="relative z-10 flex"
    >
      {children}
    </motion.span>
  )
}

// ---------------------------------------------------------------------------
// Rotating placeholder
// ---------------------------------------------------------------------------

function RotatingPlaceholder({
  phrases,
  interval,
  active,
  reduceMotion,
}: {
  phrases: readonly string[]
  interval: number
  active: boolean
  reduceMotion: boolean
}) {
  const [index, setIndex] = React.useState(0)
  const safePhrases = phrases.length > 0 ? phrases : DEFAULT_PLACEHOLDERS
  const phraseCount = safePhrases.length

  React.useEffect(() => {
    if (!active || reduceMotion || phraseCount <= 1) return
    const id = window.setInterval(() => {
      setIndex((i) => (i + 1) % phraseCount)
    }, interval)
    return () => window.clearInterval(id)
  }, [active, interval, reduceMotion, phraseCount])

  const current = safePhrases[index % phraseCount] ?? safePhrases[0]

  if (!active) return null

  return (
    <div
      aria-hidden
      className="pointer-events-none absolute inset-x-0 top-0 px-1"
    >
      <AnimatePresence mode="wait" initial={false}>
        <motion.span
          key={current}
          className="text-t-secondary/70 block truncate text-[15px] leading-7 sm:text-base"
          {...placeholderPresence(reduceMotion)}
        >
          {current}
        </motion.span>
      </AnimatePresence>
    </div>
  )
}

// ---------------------------------------------------------------------------
// Active tool chip
// ---------------------------------------------------------------------------

function ActiveToolChip({
  label,
  icon,
  onRemove,
  reduceMotion,
}: {
  label: string
  icon: React.ReactNode
  onRemove: () => void
  reduceMotion: boolean
}) {
  return (
    <motion.span
      layout
      {...scaleBlurPresence(reduceMotion)}
      className={CHIP_SURFACE_CLASS}
    >
      <span className="flex shrink-0 [&_svg]:size-3.5">{icon}</span>
      <span>{label}</span>
      <button
        type="button"
        aria-label={`Disable ${label}`}
        onClick={onRemove}
        className={cn(
          "text-t-secondary hover:text-t-primary flex size-5 cursor-pointer items-center justify-center rounded-full",
          "transition-colors duration-150 ease-[cubic-bezier(0.2,0,0,1)]",
          "focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none"
        )}
      >
        <XIcon className="size-3" aria-hidden />
      </button>
    </motion.span>
  )
}

// ---------------------------------------------------------------------------
// Plus actions menu
// ---------------------------------------------------------------------------

function actionsItemClass(active = false) {
  return cn(
    "relative flex w-full cursor-pointer items-center gap-2.5 rounded-xl px-2.5 py-2 text-left text-sm",
    "transition-colors duration-150 ease-[cubic-bezier(0.2,0,0,1)]",
    "focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
    active
      ? "bg-b-surface1 text-t-primary"
      : "text-t-secondary hover:bg-b-surface1/70 hover:text-t-primary"
  )
}

function PlusActionsMenu({
  disabled,
  reduceMotion,
  deepResearch,
  webSearch,
  onUploadFile,
  onToggleDeepResearch,
  onToggleWebSearch,
  onSkills,
  onConnectors,
}: {
  disabled?: boolean
  reduceMotion: boolean
  deepResearch: boolean
  webSearch: boolean
  onUploadFile?: () => void
  onToggleDeepResearch: () => void
  onToggleWebSearch: () => void
  onSkills?: () => void
  onConnectors?: () => void
}) {
  const { open, setOpen, mounted, coords, triggerRef, contentRef, menuId } =
    useAnchoredMenu(disabled)

  const runAndClose = (action?: () => void) => {
    action?.()
    setOpen(false)
  }

  return (
    <div className="relative">
      <motion.button
        ref={triggerRef}
        type="button"
        disabled={disabled}
        aria-haspopup="menu"
        aria-expanded={open}
        aria-controls={menuId}
        aria-label="Open actions"
        onClick={() => setOpen((v) => !v)}
        whileHover={disabled ? undefined : { scale: 1.05, y: -1 }}
        whileTap={disabled ? undefined : { scale: 0.96 }}
        transition={SPRING_PRESS}
        className={cn(
          TOOLBAR_BTN_CLASS,
          "text-t-secondary",
          open && "bg-b-surface1 text-t-primary"
        )}
      >
        <motion.span
          animate={{ rotate: open ? 45 : 0 }}
          transition={{ duration: 0.2, ease: EASE }}
          className="flex [&_svg]:size-4"
        >
          <PlusIcon aria-hidden />
        </motion.span>
      </motion.button>

      <AnchoredMenuPortal
        mounted={mounted}
        open={open}
        coords={coords}
        contentRef={contentRef}
        id={menuId}
        role="menu"
        aria-label="Actions"
        reduceMotion={reduceMotion}
        className="min-w-56"
      >
        <div className="flex flex-col gap-0.5">
          <motion.button
            type="button"
            role="menuitem"
            whileTap={{ scale: 0.98 }}
            transition={SPRING_PRESS}
            className={actionsItemClass()}
            onClick={() => runAndClose(onUploadFile)}
          >
            <UploadIcon className="size-4 shrink-0 opacity-70" aria-hidden />
            <span className="font-medium">Upload file</span>
          </motion.button>

          <motion.button
            type="button"
            role="menuitemcheckbox"
            aria-checked={deepResearch}
            whileTap={{ scale: 0.98 }}
            transition={SPRING_PRESS}
            className={actionsItemClass(deepResearch)}
            onClick={() => runAndClose(onToggleDeepResearch)}
          >
            <TelescopeIcon className="size-4 shrink-0 opacity-70" aria-hidden />
            <span className="min-w-0 flex-1 font-medium">Deep research</span>
            <MenuCheckmark visible={deepResearch} reduceMotion={reduceMotion} />
          </motion.button>

          <motion.button
            type="button"
            role="menuitemcheckbox"
            aria-checked={webSearch}
            whileTap={{ scale: 0.98 }}
            transition={SPRING_PRESS}
            className={actionsItemClass(webSearch)}
            onClick={() => runAndClose(onToggleWebSearch)}
          >
            <GlobeIcon className="size-4 shrink-0 opacity-70" aria-hidden />
            <span className="min-w-0 flex-1 font-medium">Web search</span>
            <MenuCheckmark visible={webSearch} reduceMotion={reduceMotion} />
          </motion.button>

          <div role="separator" className="bg-s-stroke2 my-1.5 h-px" />

          <motion.button
            type="button"
            role="menuitem"
            whileTap={{ scale: 0.98 }}
            transition={SPRING_PRESS}
            className={actionsItemClass()}
            onClick={() => runAndClose(onSkills)}
          >
            <PuzzleIcon className="size-4 shrink-0 opacity-70" aria-hidden />
            <span className="font-medium">Skills</span>
          </motion.button>

          <motion.button
            type="button"
            role="menuitem"
            whileTap={{ scale: 0.98 }}
            transition={SPRING_PRESS}
            className={actionsItemClass()}
            onClick={() => runAndClose(onConnectors)}
          >
            <UnplugIcon className="size-4 shrink-0 opacity-70" aria-hidden />
            <span className="font-medium">Connectors</span>
          </motion.button>
        </div>
      </AnchoredMenuPortal>
    </div>
  )
}

// ---------------------------------------------------------------------------
// Dictation / voice UI
// ---------------------------------------------------------------------------

const WAVE_BARS = [0.35, 0.7, 0.45, 0.9, 0.55, 0.8, 0.4] as const

const DEMO_TRANSCRIPTS = [
  "Explain how this Motoko component handles focus and motion.",
  "Generate a pricing section with three tiers and a soft Motoko surface.",
  "Refactor this TypeScript module to use stricter types.",
] as const

function DictationWaveform({
  processing,
  reduceMotion,
}: {
  processing: boolean
  reduceMotion: boolean
}) {
  return (
    <motion.div
      layout
      {...statusPresence(reduceMotion)}
      className={cn(
        "bg-b-surface1 text-t-primary flex h-9 items-center gap-2 rounded-xl px-2.5",
        "shadow-[inset_0_0_0_1px_rgba(123,123,123,0.12)]"
      )}
      aria-hidden
    >
      {processing ? (
        <span className="text-t-secondary flex items-center gap-1.5 text-xs font-medium">
          <Loader2Icon className="size-3.5 animate-spin" />
          Processing
        </span>
      ) : (
        <div className="flex h-4 items-end gap-[3px]">
          {WAVE_BARS.map((base, index) => (
            <motion.span
              key={index}
              className="bg-t-primary w-[3px] origin-bottom rounded-full"
              initial={{ height: 4 }}
              animate={
                reduceMotion
                  ? { height: Math.max(4, base * 14) }
                  : {
                      height: [
                        Math.max(3, base * 6),
                        Math.max(8, base * 16),
                        Math.max(4, base * 9),
                        Math.max(10, base * 14),
                      ],
                    }
              }
              transition={
                reduceMotion
                  ? { duration: 0 }
                  : {
                      duration: 0.7 + index * 0.08,
                      repeat: Infinity,
                      ease: "easeInOut",
                      delay: index * 0.05,
                    }
              }
            />
          ))}
        </div>
      )}
    </motion.div>
  )
}

function VoiceModeBadge({ reduceMotion }: { reduceMotion: boolean }) {
  return (
    <motion.div
      layout
      {...statusPresence(reduceMotion)}
      className={cn(
        "bg-b-surface1 text-t-primary flex h-9 items-center gap-2 rounded-xl px-2.5",
        "shadow-[inset_0_0_0_1px_rgba(123,123,123,0.12)]"
      )}
      role="status"
      aria-live="polite"
    >
      <motion.span
        className="flex"
        animate={reduceMotion ? undefined : { opacity: [0.55, 1, 0.55] }}
        transition={{ duration: 1.4, ease: EASE, repeat: Infinity }}
      >
        <AudioLinesIcon className="size-3.5" aria-hidden />
      </motion.span>
      <span className="text-xs font-medium tracking-tight">Voice mode</span>
    </motion.div>
  )
}

function MicButton({
  disabled,
  phase,
  onToggle,
  reduceMotion,
}: {
  disabled?: boolean
  phase: DictationPhase
  onToggle: () => void
  reduceMotion: boolean
}) {
  const active = phase === "recording"
  const processing = phase === "processing"
  const label =
    phase === "recording"
      ? "Stop recording"
      : phase === "processing"
        ? "Processing voice"
        : "Start dictation"

  return (
    <motion.button
      type="button"
      aria-label={label}
      aria-pressed={active}
      aria-busy={processing || undefined}
      disabled={disabled || processing}
      onClick={onToggle}
      whileHover={disabled || processing ? undefined : { scale: 1.05, y: -1 }}
      whileTap={disabled || processing ? undefined : { scale: 0.96 }}
      transition={SPRING_PRESS}
      className={cn(
        TOOLBAR_BTN_CLASS,
        "overflow-visible",
        active || processing
          ? "bg-b-surface1 text-t-primary"
          : "text-t-secondary"
      )}
    >
      <AnimatePresence>
        {active && !reduceMotion ? (
          <motion.span
            key="mic-ring"
            aria-hidden
            className="pointer-events-none absolute inset-0 rounded-xl border border-current"
            initial={{ opacity: 0.45, scale: 1 }}
            animate={{ opacity: 0, scale: 1.55 }}
            exit={{ opacity: 0 }}
            transition={{ duration: 1.2, ease: EASE, repeat: Infinity }}
          />
        ) : null}
      </AnimatePresence>
      <AnimatePresence mode="wait" initial={false}>
        {processing ? (
          <IconSwapFrame swapKey="processing" reduceMotion={reduceMotion}>
            <Loader2Icon className="size-4 animate-spin" aria-hidden />
          </IconSwapFrame>
        ) : active ? (
          <IconSwapFrame swapKey="stop" reduceMotion={reduceMotion}>
            <SquareIcon className="size-3.5 fill-current" aria-hidden />
          </IconSwapFrame>
        ) : (
          <IconSwapFrame swapKey="mic" reduceMotion={reduceMotion}>
            <MicIcon className="size-4" aria-hidden />
          </IconSwapFrame>
        )}
      </AnimatePresence>
    </motion.button>
  )
}

function ActionButton({
  disabled,
  status,
  hasText,
  talking,
  onSend,
  onTalkToggle,
  reduceMotion,
}: {
  disabled?: boolean
  status: AiPromptSendStatus
  hasText: boolean
  talking: boolean
  onSend: () => void
  onTalkToggle: () => void
  reduceMotion: boolean
}) {
  const isLoading = status === "loading"
  const isSuccess = status === "success"
  const showSend = !talking && (hasText || isLoading || isSuccess)
  // Talk/stop stays clickable even when the rest of the shell is locked
  const isDisabled = talking ? false : disabled || isLoading

  const label = talking
    ? "Stop voice conversation"
    : isLoading
      ? "Sending"
      : isSuccess
        ? "Sent"
        : showSend
          ? "Send message"
          : "Talk with AI"

  return (
    <motion.button
      type="button"
      aria-label={label}
      aria-pressed={talking || undefined}
      aria-busy={isLoading || undefined}
      disabled={isDisabled}
      onClick={() => {
        if (talking || !showSend) onTalkToggle()
        else onSend()
      }}
      whileHover={
        isDisabled ? undefined : { scale: 1.06, transition: SPRING_SOFT }
      }
      whileTap={isDisabled ? undefined : { scale: 0.94 }}
      animate={{
        scale: isSuccess && !talking ? [1, 1.08, 1] : 1,
      }}
      transition={SPRING_PRESS}
      className={cn(
        "relative flex size-10 cursor-pointer items-center justify-center overflow-hidden rounded-full",
        "transition-[background-color,box-shadow,opacity,color] duration-200 ease-[cubic-bezier(0.2,0,0,1)]",
        "focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
        "disabled:pointer-events-none",
        showSend || talking
          ? [
              "bg-linear-to-b from-[#2C2C2C] to-[#282828] text-white",
              "shadow-[inset_2px_0_8px_2px_rgba(248,248,248,0.18)]",
              "hover:shadow-[0_0_0_4px_rgba(20,20,20,0.08),inset_2px_0_8px_2px_rgba(248,248,248,0)]",
              "dark:from-[#f7f7f7] dark:to-white dark:text-black",
              "dark:shadow-[inset_2px_0_8px_2px_rgba(24,24,24,0.1)]",
              "dark:hover:shadow-[0_0_0_4px_rgba(255,255,255,0.1)]",
            ]
          : "bg-b-surface1 text-t-secondary hover:text-t-primary",
        talking &&
          "shadow-[0_0_0_4px_rgba(20,20,20,0.1),inset_2px_0_8px_2px_rgba(248,248,248,0.18)] dark:shadow-[0_0_0_4px_rgba(255,255,255,0.12)]"
      )}
    >
      <AnimatePresence>
        {talking && !reduceMotion ? (
          <>
            <motion.span
              key="ring-1"
              aria-hidden
              className="pointer-events-none absolute inset-0 rounded-full border border-current"
              initial={{ opacity: 0.5, scale: 1 }}
              animate={{ opacity: 0, scale: 1.85 }}
              exit={{ opacity: 0 }}
              transition={{ duration: 1.4, ease: EASE, repeat: Infinity }}
            />
            <motion.span
              key="ring-2"
              aria-hidden
              className="pointer-events-none absolute inset-0 rounded-full border border-current"
              initial={{ opacity: 0.35, scale: 1 }}
              animate={{ opacity: 0, scale: 1.55 }}
              exit={{ opacity: 0 }}
              transition={{
                duration: 1.4,
                ease: EASE,
                repeat: Infinity,
                delay: 0.35,
              }}
            />
          </>
        ) : null}
      </AnimatePresence>

      <AnimatePresence mode="wait" initial={false}>
        {talking ? (
          <IconSwapFrame swapKey="stop" reduceMotion={reduceMotion}>
            <SquareIcon className="size-3.5 fill-current" aria-hidden />
          </IconSwapFrame>
        ) : isLoading ? (
          <IconSwapFrame swapKey="loader" reduceMotion={reduceMotion}>
            <Loader2Icon className="size-4 animate-spin" aria-hidden />
          </IconSwapFrame>
        ) : isSuccess ? (
          <IconSwapFrame swapKey="check" reduceMotion={reduceMotion}>
            <CheckIcon className="size-4" aria-hidden />
          </IconSwapFrame>
        ) : showSend ? (
          <IconSwapFrame swapKey="arrow" reduceMotion={reduceMotion}>
            <ArrowUpIcon className="size-4" aria-hidden />
          </IconSwapFrame>
        ) : (
          <IconSwapFrame swapKey="waves" reduceMotion={reduceMotion}>
            <AudioLinesIcon className="size-4" aria-hidden />
          </IconSwapFrame>
        )}
      </AnimatePresence>
    </motion.button>
  )
}

// ---------------------------------------------------------------------------
// Root
// ---------------------------------------------------------------------------

const AiPromptInput = React.forwardRef<HTMLTextAreaElement, AiPromptInputProps>(
  (
    {
      value: valueProp,
      defaultValue = "",
      onChange,
      onSubmit,
      placeholders = DEFAULT_PLACEHOLDERS,
      placeholderInterval = 3200,
      models = DEFAULT_MODELS,
      modelSelection: modelSelectionProp,
      defaultModelSelection,
      onModelSelectionChange,
      disabled = false,
      status = "idle",
      maxLength = 4000,
      minRows = 1,
      maxRows = 8,
      showToolbar = true,
      showActions = true,
      showModelSelector = true,
      deepResearch: deepResearchProp,
      defaultDeepResearch = false,
      onDeepResearchChange,
      webSearch: webSearchProp,
      defaultWebSearch = false,
      onWebSearchChange,
      onUploadFile,
      onSkills,
      onConnectors,
      getDictationTranscript,
      onDictationChange,
      onVoiceChange,
      className,
      textareaClassName,
      "aria-label": ariaLabel = "AI prompt",
      "data-testid": dataTestId,
    },
    ref
  ) => {
    const [value, setValue] = useControllableState({
      value: valueProp,
      defaultValue,
      onChange,
    })

    const initialSelection =
      defaultModelSelection ??
      (models[0] ? defaultSelectionFor(models[0]) : { id: "" })
    const [modelSelection, setModelSelection] = useControllableState({
      value: modelSelectionProp,
      defaultValue: initialSelection,
      onChange: onModelSelectionChange,
    })

    const [deepResearch, setDeepResearch] = useControllableState({
      value: deepResearchProp,
      defaultValue: defaultDeepResearch,
      onChange: onDeepResearchChange,
    })

    const [webSearch, setWebSearch] = useControllableState({
      value: webSearchProp,
      defaultValue: defaultWebSearch,
      onChange: onWebSearchChange,
    })

    const [focused, setFocused] = React.useState(false)
    const [height, setHeight] = React.useState<number | "auto">("auto")
    const reduceMotion = usePrefersReducedMotion()
    const [dictationPhase, setDictationPhase] =
      React.useState<DictationPhase>("idle")
    const [talking, setTalking] = React.useState(false)

    const valueRef = React.useRef(value)
    valueRef.current = value
    const transcriptIndex = React.useRef(0)
    const processTimer = React.useRef<number | null>(null)
    const getTranscriptRef = React.useRef(getDictationTranscript)
    getTranscriptRef.current = getDictationTranscript

    const textareaRef = React.useRef<HTMLTextAreaElement | null>(null)
    const mirrorRef = React.useRef<HTMLDivElement | null>(null)
    const fieldId = React.useId()

    const trimmed = value.trim()
    const hasText = trimmed.length > 0
    const showPlaceholder = value.length === 0 && !focused
    const hasActiveTools = deepResearch || webSearch
    const dictating = dictationPhase !== "idle"
    const sessionLocked = dictating || talking

    React.useEffect(() => {
      if (!hasText || !talking) return
      setTalking(false)
      onVoiceChange?.(false)
    }, [hasText, talking, onVoiceChange])

    React.useEffect(() => {
      return () => {
        if (processTimer.current !== null) {
          window.clearTimeout(processTimer.current)
        }
      }
    }, [])

    const resize = React.useCallback(() => {
      const el = textareaRef.current
      const mirror = mirrorRef.current
      if (!el) return

      const styles = window.getComputedStyle(el)
      const lineHeight = Number.parseFloat(styles.lineHeight) || 28
      const paddingY =
        Number.parseFloat(styles.paddingTop) +
        Number.parseFloat(styles.paddingBottom)
      const minH = lineHeight * minRows + paddingY
      const maxH = lineHeight * maxRows + paddingY

      if (mirror) {
        mirror.style.width = `${el.clientWidth}px`
        mirror.textContent = value.endsWith("\n") ? `${value} ` : value || " "
        const next = Math.min(Math.max(mirror.scrollHeight, minH), maxH)
        setHeight(next)
        el.style.overflowY = mirror.scrollHeight > maxH ? "auto" : "hidden"
      } else {
        el.style.height = "auto"
        const next = Math.min(Math.max(el.scrollHeight, minH), maxH)
        setHeight(next)
        el.style.overflowY = el.scrollHeight > maxH ? "auto" : "hidden"
      }
    }, [value, minRows, maxRows])

    React.useLayoutEffect(() => {
      resize()
    }, [resize])

    const submit = React.useCallback(() => {
      if (disabled || status === "loading" || !trimmed) return
      onSubmit?.(trimmed, modelSelection)
    }, [disabled, status, trimmed, onSubmit, modelSelection])

    const finishDictation = React.useCallback(() => {
      setDictationPhase("processing")
      onDictationChange?.(false)
      if (processTimer.current !== null) {
        window.clearTimeout(processTimer.current)
      }
      processTimer.current = window.setTimeout(() => {
        const sample =
          getTranscriptRef.current?.() ??
          DEMO_TRANSCRIPTS[transcriptIndex.current % DEMO_TRANSCRIPTS.length]
        transcriptIndex.current += 1
        const current = valueRef.current
        const nextValue = current.trim()
          ? `${current.trim()} ${sample}`
          : sample
        setValue(nextValue)
        setDictationPhase("idle")
        processTimer.current = null
      }, 1100)
    }, [onDictationChange, setValue])

    const toggleDictation = React.useCallback(() => {
      if (disabled || dictationPhase === "processing") return
      if (dictationPhase === "recording") {
        finishDictation()
        return
      }
      if (talking) {
        setTalking(false)
        onVoiceChange?.(false)
      }
      setDictationPhase("recording")
      onDictationChange?.(true)
    }, [
      disabled,
      dictationPhase,
      talking,
      finishDictation,
      onDictationChange,
      onVoiceChange,
    ])

    const toggleTalk = React.useCallback(() => {
      if (disabled || dictationPhase === "processing") return
      const next = !talking
      if (next && dictationPhase === "recording") {
        setDictationPhase("idle")
        onDictationChange?.(false)
      }
      setTalking(next)
      onVoiceChange?.(next)
    }, [disabled, dictationPhase, talking, onVoiceChange, onDictationChange])

    const onKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
      if (
        event.key === "Enter" &&
        !event.shiftKey &&
        !event.nativeEvent.isComposing
      ) {
        event.preventDefault()
        submit()
      }
    }

    return (
      <motion.div
        data-slot="ai-prompt-input"
        data-focused={focused || undefined}
        data-disabled={disabled || undefined}
        data-status={status}
        data-dictating={dictating || undefined}
        data-dictation-phase={
          dictationPhase === "idle" ? undefined : dictationPhase
        }
        data-talking={talking || undefined}
        animate={
          reduceMotion
            ? undefined
            : {
                boxShadow: focused
                  ? "0 2px 8px rgba(8,8,8,0.06), 0 16px 48px -12px rgba(8,8,8,0.22)"
                  : "0 1px 2px rgba(8,8,8,0.04), 0 8px 24px -12px rgba(8,8,8,0.14)",
              }
        }
        transition={{ duration: 0.28, ease: EASE }}
        className={cn(
          "bg-b-pop border-s-subtle relative w-full overflow-visible rounded-[1.75rem] border-2 p-3.5 sm:p-4",
          "transition-[background-color,opacity] duration-200 ease-[cubic-bezier(0.2,0,0,1)]",
          disabled && "pointer-events-none opacity-55",
          talking && "ring-t-primary/15 ring-2",
          className
        )}
        data-testid={dataTestId}
      >
        <div
          ref={mirrorRef}
          aria-hidden
          className="invisible absolute top-0 left-0 -z-10 px-1 text-[15px] leading-7 break-words whitespace-pre-wrap sm:text-base"
        />

        <AnimatePresence initial={false}>
          {hasActiveTools ? (
            <motion.div
              key="active-tools"
              initial={false}
              className="mb-2.5 flex flex-wrap items-center gap-1.5 px-0.5"
            >
              <AnimatePresence initial={false} mode="popLayout">
                {deepResearch ? (
                  <ActiveToolChip
                    key="deep-research"
                    label="Deep research"
                    icon={<TelescopeIcon aria-hidden />}
                    reduceMotion={reduceMotion}
                    onRemove={() => setDeepResearch(false)}
                  />
                ) : null}
                {webSearch ? (
                  <ActiveToolChip
                    key="web-search"
                    label="Web search"
                    icon={<GlobeIcon aria-hidden />}
                    reduceMotion={reduceMotion}
                    onRemove={() => setWebSearch(false)}
                  />
                ) : null}
              </AnimatePresence>
            </motion.div>
          ) : null}
        </AnimatePresence>

        <div
          className={cn(
            "relative min-h-7 transition-opacity duration-200 ease-[cubic-bezier(0.2,0,0,1)]",
            talking && "pointer-events-none opacity-40"
          )}
        >
          <RotatingPlaceholder
            phrases={placeholders}
            interval={placeholderInterval}
            active={showPlaceholder && !talking}
            reduceMotion={reduceMotion}
          />

          <motion.textarea
            id={fieldId}
            ref={(node) => assignRef(node, ref, textareaRef)}
            value={value}
            disabled={disabled || sessionLocked}
            rows={minRows}
            maxLength={maxLength}
            aria-label={ariaLabel}
            aria-multiline="true"
            onChange={(event) => setValue(event.target.value)}
            onKeyDown={onKeyDown}
            onFocus={() => setFocused(true)}
            onBlur={() => setFocused(false)}
            animate={
              reduceMotion
                ? undefined
                : { height: typeof height === "number" ? height : undefined }
            }
            transition={SPRING_HEIGHT}
            className={cn(
              "text-t-primary relative z-10 block w-full resize-none bg-transparent px-1",
              "text-[15px] leading-7 sm:text-base",
              "placeholder:text-transparent",
              "outline-none focus-visible:outline-none",
              "disabled:cursor-not-allowed",
              "caret-t-primary",
              textareaClassName
            )}
            style={
              reduceMotion && typeof height === "number"
                ? { height }
                : undefined
            }
          />
        </div>

        <AnimatePresence initial={false}>
          {showToolbar ? (
            <motion.div
              key="toolbar"
              initial={false}
              animate={{
                opacity:
                  focused ||
                  value.length > 0 ||
                  hasActiveTools ||
                  dictating ||
                  talking
                    ? 1
                    : 0.78,
                y: 0,
              }}
              transition={{ duration: 0.22, ease: EASE }}
              className="border-s-stroke2 mt-3 flex items-center justify-between gap-2 border-t pt-3 sm:mt-3.5"
            >
              <div
                className={cn(
                  "flex min-w-0 items-center gap-0.5 transition-opacity duration-200 ease-[cubic-bezier(0.2,0,0,1)] sm:gap-1",
                  sessionLocked && "pointer-events-none opacity-40"
                )}
              >
                {showActions ? (
                  <PlusActionsMenu
                    disabled={disabled || sessionLocked}
                    reduceMotion={reduceMotion}
                    deepResearch={deepResearch}
                    webSearch={webSearch}
                    onUploadFile={onUploadFile}
                    onToggleDeepResearch={() => setDeepResearch(!deepResearch)}
                    onToggleWebSearch={() => setWebSearch(!webSearch)}
                    onSkills={onSkills}
                    onConnectors={onConnectors}
                  />
                ) : null}

                {showModelSelector && models.length > 0 ? (
                  <ModelSelector
                    models={models}
                    value={modelSelection}
                    onValueChange={setModelSelection}
                    disabled={disabled || sessionLocked}
                  >
                    <ModelSelectorTrigger className="h-9">
                      <ModelSelectorValue className="max-w-40 sm:max-w-52" />
                    </ModelSelectorTrigger>
                    <ModelSelectorContent side="top" />
                  </ModelSelector>
                ) : null}
              </div>

              <div className="flex shrink-0 items-center gap-1.5">
                <AnimatePresence initial={false}>
                  {dictating ? (
                    <DictationWaveform
                      key="dictation-wave"
                      processing={dictationPhase === "processing"}
                      reduceMotion={reduceMotion}
                    />
                  ) : null}
                  {talking ? (
                    <VoiceModeBadge
                      key="voice-mode"
                      reduceMotion={reduceMotion}
                    />
                  ) : null}
                </AnimatePresence>

                <MicButton
                  disabled={disabled || status === "loading" || talking}
                  phase={dictationPhase}
                  onToggle={toggleDictation}
                  reduceMotion={reduceMotion}
                />
                <ActionButton
                  disabled={disabled || dictationPhase !== "idle"}
                  status={status}
                  hasText={hasText}
                  talking={talking}
                  onSend={submit}
                  onTalkToggle={toggleTalk}
                  reduceMotion={reduceMotion}
                />
              </div>
            </motion.div>
          ) : null}
        </AnimatePresence>
      </motion.div>
    )
  }
)

AiPromptInput.displayName = "AiPromptInput"

export { AiPromptInput }

More from motokoui

Browse all components in this library →