shadimage
skecher-ui
uiai-chat-boxsource: ok · hit

AI Chat Box

A polished AI prompt composer with model controls, attachments, and animated interaction states.

npx shadcn add "https://skecher-ui.com/r/ai-chat-box.json"
free

Where it goes

statschat

Interaction

animated-counter

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

Dependencies

Bundle weight · medium
iconslucide-react
animationmotion

Files1 · source cached 10/8/2026, 7:44:11 PM

components/ui-components/ai-chat-box.tsx
"use client";

import { ArrowUp, Check, ChevronDown, type LucideIcon, MoreHorizontal } from "lucide-react";
import { AnimatePresence, LayoutGroup, MotionConfig, motion } from "motion/react";
import { IconEarthFillDuo18, IconLinkFillDuo18 } from "nucleo-ui-essential-fill-duo-18";
import {
  type FormEvent,
  type KeyboardEvent,
  type ReactNode,
  useCallback,
  useEffect,
  useId,
  useLayoutEffect,
  useRef,
  useState,
} from "react";

import { cn } from "@/lib/utils";

const getMessageSurfaceLayoutId = (id: string) => `chat-message-surface-${id}`;

const sharedLayoutTransition = {
  type: "spring" as const,
  duration: 0.3,
  bounce: 0,
};

const easeOut = [0.23, 1, 0.32, 1] as const;

export type AiChatModel = {
  id: string;
  name: string;
  description: string;
  capability: string;
  icon?: LucideIcon;
  iconClassName?: string;
};

const DEFAULT_MODELS: AiChatModel[] = [
  {
    id: "nova",
    name: "Nova",
    description: "Balanced for everyday ideas",
    capability: "Balanced",
  },
  {
    id: "atlas",
    name: "Atlas",
    description: "Plans and complex reasoning",
    capability: "Reasoning",
  },
  {
    id: "swift",
    name: "Swift",
    description: "Quick drafts and edits",
    capability: "Fast",
  },
];

export type AiChatMessage = {
  id: string;
  text: string;
};

export type AiChatAction = {
  id: string;
  label: string;
  icon: ReactNode;
  onClick?: () => void;
  disabled?: boolean;
};

export type AiChatBoxLabels = {
  empty: ReactNode;
  input: string;
  inputAriaLabel: string;
  send: string;
  modelPicker: string;
};

export type AiChatBoxProps = {
  className?: string;
  models?: AiChatModel[];
  messages?: AiChatMessage[];
  defaultMessages?: AiChatMessage[];
  onMessagesChange?: (messages: AiChatMessage[]) => void;
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  selectedModelId?: string;
  defaultSelectedModelId?: string;
  onSelectedModelChange?: (modelId: string) => void;
  onSend?: (message: AiChatMessage, model?: AiChatModel) => void | Promise<void>;
  actions?: AiChatAction[];
  labels?: Partial<AiChatBoxLabels>;
  renderMessage?: (message: AiChatMessage) => ReactNode;
  emptyState?: ReactNode;
  disabled?: boolean;
  isSending?: boolean;
  maxLength?: number;
  submitOnEnter?: boolean;
};

const DEFAULT_LABELS: AiChatBoxLabels = {
  empty: "Let's Begin",
  input: "Ask anything",
  inputAriaLabel: "Message input",
  send: "Send message",
  modelPicker: "Choose a model",
};

function ComposerIconButton({
  children,
  label,
  onClick,
  disabled,
}: {
  children: ReactNode;
  label: string;
  onClick?: () => void;
  disabled?: boolean;
}) {
  return (
    <button
      type="button"
      aria-label={label}
      onClick={onClick}
      disabled={disabled}
      className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-white text-neutral-500 transition-[color,background-color,transform] duration-150 hover:bg-neutral-50 hover:text-neutral-800 active:scale-[0.97] disabled:cursor-not-allowed disabled:opacity-40 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-neutral-200"
    >
      {children}
    </button>
  );
}

function MessageSurface({
  id,
  text,
  isMorphing,
  onSurfaceAnimationComplete,
  className,
  children,
}: {
  id: string;
  text: string;
  isMorphing: boolean;
  onSurfaceAnimationComplete?: () => void;
  className?: string;
  children?: ReactNode;
}) {
  return (
    <motion.div
      layoutId={getMessageSurfaceLayoutId(id)}
      initial={false}
      animate={{ opacity: 1 }}
      transition={sharedLayoutTransition}
      onLayoutAnimationComplete={isMorphing ? onSurfaceAnimationComplete : undefined}
      data-message-surface={id}
      data-morphing={isMorphing || undefined}
      className={cn(
        "!opacity-100 relative isolate w-full overflow-hidden rounded-[1.25rem] bg-white px-4 py-2.5 text-sm leading-relaxed text-neutral-900 shadow-sm will-change-transform dark:bg-neutral-800 dark:text-neutral-100",
        className,
      )}
    >
      <motion.div
        aria-hidden="true"
        initial={false}
        animate={{ opacity: isMorphing ? 1 : 0 }}
        transition={{
          opacity: {
            duration: isMorphing ? 0.18 : 0.28,
            ease: "easeInOut",
          },
        }}
        className="pointer-events-none absolute inset-0 -z-10 bg-neutral-50 dark:bg-neutral-600"
      />
      {children ?? <p className="whitespace-pre-wrap">{text}</p>}
    </motion.div>
  );
}

function MessageBubble({
  id,
  text,
  isMorphing,
  onSurfaceAnimationComplete,
  renderMessage,
}: {
  id: string;
  text: string;
  isMorphing: boolean;
  onSurfaceAnimationComplete?: () => void;
  renderMessage?: (message: AiChatMessage) => ReactNode;
}) {
  return (
    <div className="w-full">
      <MessageSurface
        id={id}
        text={text}
        isMorphing={isMorphing}
        onSurfaceAnimationComplete={onSurfaceAnimationComplete}
      >
        {renderMessage?.({ id, text })}
      </MessageSurface>
    </div>
  );
}

function ModelPickerPanel({
  panelId,
  selectorId,
  selectedModelId,
  motionEnabled,
  onSelect,
  onClose,
  onKeyboardInteraction,
  onReturnFocus,
  models,
  ariaLabel,
}: {
  panelId: string;
  selectorId: string;
  selectedModelId: string;
  motionEnabled: boolean;
  onSelect: (modelId: string) => void;
  onClose: () => void;
  onKeyboardInteraction: () => void;
  onReturnFocus: () => void;
  models: AiChatModel[];
  ariaLabel: string;
}) {
  const optionRefs = useRef<Array<HTMLInputElement | null>>([]);
  const selectedIndex = Math.max(
    0,
    models.findIndex((model) => model.id === selectedModelId),
  );
  const [focusedModelId, setFocusedModelId] = useState(selectedModelId);
  const [cursorMotionEnabled, setCursorMotionEnabled] = useState(motionEnabled);

  useEffect(() => {
    optionRefs.current[selectedIndex]?.focus();
  }, [selectedIndex]);

  const selectAndFocusOption = (index: number) => {
    const normalizedIndex = (index + models.length) % models.length;
    const nextModel = models[normalizedIndex];
    setFocusedModelId(nextModel.id);
    onSelect(nextModel.id);
    optionRefs.current[normalizedIndex]?.focus();
  };

  const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    const focusedIndex = Math.max(
      0,
      models.findIndex((model) => model.id === focusedModelId),
    );

    setCursorMotionEnabled(false);
    onKeyboardInteraction();

    if (event.key === "ArrowRight" || event.key === "ArrowDown") {
      event.preventDefault();
      selectAndFocusOption(focusedIndex + 1);
    } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
      event.preventDefault();
      selectAndFocusOption(focusedIndex - 1);
    } else if (event.key === "Home") {
      event.preventDefault();
      selectAndFocusOption(0);
    } else if (event.key === "End") {
      event.preventDefault();
      selectAndFocusOption(models.length - 1);
    } else if (event.key === "Escape") {
      event.preventDefault();
      onClose();
      requestAnimationFrame(onReturnFocus);
    } else if (event.key === "Enter") {
      event.preventDefault();
      onSelect(focusedModelId);
      onClose();
      requestAnimationFrame(onReturnFocus);
    }
  };

  return (
    <motion.div
      id={panelId}
      role="radiogroup"
      aria-label={ariaLabel}
      onKeyDown={handleKeyDown}
      onPointerMoveCapture={() => setCursorMotionEnabled(true)}
      initial={motionEnabled ? { opacity: 0, transform: "translateY(7px) scale(0.98)" } : false}
      animate={{ opacity: 1, transform: "translateY(0px) scale(1)" }}
      exit={
        motionEnabled ? { opacity: 0, transform: "translateY(4px) scale(0.985)" } : { opacity: 0 }
      }
      transition={{ duration: motionEnabled ? 0.18 : 0, ease: easeOut }}
      className="relative z-10 grid grid-cols-3 gap-1 pt-2 dark:border-neutral-700/70  bg-neutral-100 dark:bg-neutral-900 rounded-[1rem] px-2 pb-2 text-center text-xs font-medium text-neutral-900 dark:text-neutral-100"
    >
      {models.map((model, index) => {
        const isFocused = focusedModelId === model.id;
        const isSelected = selectedModelId === model.id;

        return (
          <motion.label
            key={model.id}
            onPointerMove={() => setFocusedModelId(model.id)}
            onPointerUp={() => {
              onSelect(model.id);
              onClose();
              requestAnimationFrame(onReturnFocus);
            }}
            initial={motionEnabled ? { opacity: 0, transform: "translateY(5px)" } : false}
            animate={{ opacity: 1, transform: "translateY(0px)" }}
            transition={{
              duration: motionEnabled ? 0.16 : 0,
              delay: motionEnabled ? index * 0.035 : 0,
              ease: easeOut,
            }}
            className="relative flex h-[72px] min-w-0 cursor-pointer flex-col items-center justify-center gap-1 rounded-md px-1 outline-none has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-sky-500/40"
          >
            <input
              ref={(element) => {
                optionRefs.current[index] = element;
              }}
              type="radio"
              name={`${selectorId}-model`}
              value={model.id}
              checked={isSelected}
              aria-label={`${model.name}: ${model.description}`}
              onFocus={() => setFocusedModelId(model.id)}
              onChange={() => onSelect(model.id)}
              className="sr-only"
            />

            {isFocused && (
              <motion.span
                layoutId={`${selectorId}-model-focus`}
                transition={
                  cursorMotionEnabled
                    ? { type: "spring", duration: 0.22, bounce: 0 }
                    : { duration: 0 }
                }
                className="pointer-events-none absolute inset-0 rounded-[1rem]  bg-neutral-200 dark:bg-neutral-800"
              />
            )}
            <div className="relative flex items-center ">
              {/*<span
              className={cn(
                "relative flex size-6 items-center justify-start rounded-md shadow-sm",
                model.iconClassName,
              )}
            >
              <ModelIcon className="size-3.5" strokeWidth={2} />
            </span>*/}

              <span className="relative flex items-center gap-1 text-sm font-medium text-neutral-900 dark:text-neutral-100">
                {model.name}
                {isSelected && (
                  <Check
                    className="size-3 text-white  bg-sky-500 rounded-full p-[1px]"
                    strokeWidth={2.4}
                  />
                )}
              </span>
            </div>
            <span className="relative text-xs leading-none text-neutral-500 dark:text-neutral-400">
              {model.capability}
            </span>
          </motion.label>
        );
      })}
    </motion.div>
  );
}

export function AiChatBox({
  className,
  models = DEFAULT_MODELS,
  messages: controlledMessages,
  defaultMessages = [],
  onMessagesChange,
  value,
  defaultValue = "",
  onValueChange,
  selectedModelId: controlledSelectedModelId,
  defaultSelectedModelId,
  onSelectedModelChange,
  onSend,
  actions,
  labels: labelOverrides,
  renderMessage,
  emptyState,
  disabled = false,
  isSending = false,
  maxLength,
  submitOnEnter = true,
}: AiChatBoxProps) {
  const baseId = useId();
  const modelSelectorId = useId();
  const messageIdRef = useRef(1);
  const modelKeyboardToggleRef = useRef(false);
  const modelTriggerRef = useRef<HTMLButtonElement>(null);
  const previousMessageCountRef = useRef(0);
  const scrollRef = useRef<HTMLDivElement>(null);
  const shouldStickToBottomRef = useRef(true);
  const textareaRef = useRef<HTMLTextAreaElement>(null);

  const [internalMessages, setInternalMessages] = useState<AiChatMessage[]>(defaultMessages);
  const [internalInput, setInternalInput] = useState(defaultValue);
  const [isModelPickerOpen, setIsModelPickerOpen] = useState(false);
  const [internalSelectedModelId, setInternalSelectedModelId] = useState(
    defaultSelectedModelId ?? models[0]?.id ?? "",
  );
  const [modelMotionEnabled, setModelMotionEnabled] = useState(true);
  const [composerSurfaceId, setComposerSurfaceId] = useState<string | null>(
    () => `${baseId}-msg-1`,
  );
  const [departingMessage, setDepartingMessage] = useState<AiChatMessage | null>(null);
  const [morphingMessageId, setMorphingMessageId] = useState<string | null>(null);

  const messages = controlledMessages ?? internalMessages;
  const input = value ?? internalInput;
  const selectedModelId = controlledSelectedModelId ?? internalSelectedModelId;
  const labels = { ...DEFAULT_LABELS, ...labelOverrides };
  const hasMessages = messages.length > 0;
  const trimmedInput = input.trim();
  const canSend = trimmedInput.length > 0 && !disabled && !isSending;
  const selectedModel = models.find((model) => model.id === selectedModelId) ?? models[0];
  const modelPanelId = `${modelSelectorId}-options`;
  const composerActions: AiChatAction[] = actions ?? [
    {
      id: "attach",
      label: "Attach file",
      icon: <IconLinkFillDuo18 className="size-3.5" strokeWidth={1.75} />,
    },
    {
      id: "search",
      label: "Search web",
      icon: <IconEarthFillDuo18 className="size-3.5" strokeWidth={1.75} />,
    },
    {
      id: "more",
      label: "More options",
      icon: <MoreHorizontal className="size-3.5" strokeWidth={1.75} />,
    },
  ];

  const updateInput = useCallback(
    (nextValue: string) => {
      if (value === undefined) setInternalInput(nextValue);
      onValueChange?.(nextValue);
    },
    [onValueChange, value],
  );

  const updateSelectedModel = useCallback(
    (modelId: string) => {
      if (controlledSelectedModelId === undefined) {
        setInternalSelectedModelId(modelId);
      }
      onSelectedModelChange?.(modelId);
    },
    [controlledSelectedModelId, onSelectedModelChange],
  );

  const updateMessages = useCallback(
    (nextMessages: AiChatMessage[]) => {
      if (controlledMessages === undefined) setInternalMessages(nextMessages);
      onMessagesChange?.(nextMessages);
    },
    [controlledMessages, onMessagesChange],
  );

  const scrollToBottom = useCallback(() => {
    const el = scrollRef.current;
    if (!el) return;

    shouldStickToBottomRef.current = true;
    el.scrollTop = el.scrollHeight;
  }, []);

  const handleMessagesScroll = useCallback(() => {
    const el = scrollRef.current;
    if (!el) return;

    const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
    shouldStickToBottomRef.current = distanceFromBottom <= 24;
  }, []);

  useLayoutEffect(() => {
    const messageCount = messages.length;

    if (messageCount !== previousMessageCountRef.current) {
      scrollToBottom();
      previousMessageCountRef.current = messageCount;
    }
  }, [messages.length, scrollToBottom]);

  const resizeTextarea = useCallback(() => {
    const textarea = textareaRef.current;
    if (!textarea) return;
    textarea.style.height = "auto";
    textarea.style.height = `${Math.min(textarea.scrollHeight, 120)}px`;
  }, []);

  const nextComposerMessageId = useCallback(() => {
    messageIdRef.current += 1;
    return `${baseId}-msg-${messageIdRef.current}`;
  }, [baseId]);

  const sendMessage = useCallback(
    (text: string) => {
      const trimmed = text.trim();
      if (!trimmed) return;

      scrollToBottom();

      const id = composerSurfaceId ?? nextComposerMessageId();
      const nextSurfaceId = nextComposerMessageId();

      setDepartingMessage({ id, text: trimmed });
      setMorphingMessageId(id);
      updateInput("");
      requestAnimationFrame(() => {
        requestAnimationFrame(() => {
          const message = { id, text: trimmed };
          updateMessages([...messages, message]);
          void onSend?.(message, selectedModel);
          setDepartingMessage(null);
          setComposerSurfaceId(nextSurfaceId);
          requestAnimationFrame(() => {
            resizeTextarea();
          });
        });
      });
    },
    [
      composerSurfaceId,
      messages,
      nextComposerMessageId,
      onSend,
      resizeTextarea,
      scrollToBottom,
      selectedModel,
      updateInput,
      updateMessages,
    ],
  );

  const handleSubmit = (event: FormEvent) => {
    event.preventDefault();
    sendMessage(input);
  };

  const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
    if (submitOnEnter && event.key === "Enter" && !event.shiftKey) {
      event.preventDefault();
      sendMessage(input);
    }
  };

  const handleSurfaceAnimationComplete = useCallback(() => {
    setMorphingMessageId(null);
    scrollToBottom();
  }, [scrollToBottom]);

  return (
    <MotionConfig reducedMotion="user" transition={sharedLayoutTransition}>
      <LayoutGroup id="ai-chat">
        <div
          className={cn(
            "flex h-full max-h-[500px] w-full max-w-[360px] flex-col overflow-hidden rounded-[2rem] bg-neutral-100 dark:bg-neutral-900",
            className,
          )}
        >
          <div
            ref={scrollRef}
            onScroll={handleMessagesScroll}
            className="relative flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto overscroll-contain px-3 pt-4 pb-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
          >
            <AnimatePresence mode="popLayout" initial={false}>
              {!hasMessages && (
                <motion.div
                  key="empty"
                  className="flex flex-1 flex-col items-center justify-center px-2 py-6"
                >
                  <motion.h2
                    layout
                    className="text-center text-xl font-semibold tracking-tight text-neutral-900 dark:text-neutral-50"
                  >
                    {emptyState ?? labels.empty}
                  </motion.h2>
                </motion.div>
              )}
              </AnimatePresence>

              <div className="flex flex-col gap-2">
                <AnimatePresence initial={false}>
                  {messages.map((message) => (
                  <MessageBubble
                    key={message.id}
                    id={message.id}
                    text={message.text}
                    isMorphing={morphingMessageId === message.id}
                    onSurfaceAnimationComplete={handleSurfaceAnimationComplete}
                    renderMessage={renderMessage}
                  />
                ))}
              </AnimatePresence>
              </div>
            </div>

          <div className="shrink-0 px-3 pb-3">
            <form onSubmit={handleSubmit} className="relative bg-none">
              <motion.div
                layout
                transition={modelMotionEnabled ? sharedLayoutTransition : { duration: 0 }}
                className={cn("relative flex flex-col gap-3 overflow-hidden rounded-b-[1.75rem] p-3")}
              >
                <div className="absolute inset-0 rounded-b-[1.75rem] bg-white shadow-sm dark:bg-neutral-900 dark:shadow-neutral-950/50  " />
                <AnimatePresence initial={false}>
                  {departingMessage ? (
                    <MessageSurface
                      key={departingMessage.id}
                      id={departingMessage.id}
                      text={departingMessage.text}
                      isMorphing
                      className="pointer-events-none absolute inset-0 z-20 rounded-[1.75rem] px-4 py-3 dark:shadow-neutral-950/50"
                    />
                  ) : composerSurfaceId ? (
                    <motion.div
                      initial={false}
                      animate={{ opacity: 1 }}
                      exit={{ opacity: 1 }}
                      key={composerSurfaceId}
                      layoutId={getMessageSurfaceLayoutId(composerSurfaceId)}
                      transition={sharedLayoutTransition}
                      className="!opacity-100 absolute inset-0 rounded-[1.75rem] bg-white shadow-sm dark:bg-neutral-800 dark:shadow-neutral-950/50"
                    />
                  ) : null}
                </AnimatePresence>
                <motion.div layout="position" className="relative z-10">
                  <textarea
                    ref={textareaRef}
                    value={input}
                    onChange={(e) => {
                      updateInput(e.target.value);
                      requestAnimationFrame(resizeTextarea);
                    }}
                    onFocus={() => setIsModelPickerOpen(false)}
                    onKeyDown={handleKeyDown}
                    placeholder={labels.input}
                    disabled={disabled || isSending}
                    maxLength={maxLength}
                    rows={1}
                    className="min-h-[1.5rem] w-full resize-none border-0 bg-transparent px-1 text-sm leading-relaxed text-neutral-900 placeholder:text-neutral-400 focus:outline-none focus:ring-0 dark:text-neutral-100 dark:placeholder:text-neutral-500"
                    aria-label={labels.inputAriaLabel}
                  />
                </motion.div>

                <AnimatePresence initial={false} mode="popLayout">
                  {isModelPickerOpen && models.length > 0 && (
                    <ModelPickerPanel
                      key="model-picker"
                      panelId={modelPanelId}
                      selectorId={modelSelectorId}
                      selectedModelId={selectedModelId}
                      models={models}
                      ariaLabel={labels.modelPicker}
                      motionEnabled={modelMotionEnabled}
                      onSelect={updateSelectedModel}
                      onClose={() => setIsModelPickerOpen(false)}
                      onKeyboardInteraction={() => setModelMotionEnabled(false)}
                      onReturnFocus={() => modelTriggerRef.current?.focus()}
                    />
                  )}
                </AnimatePresence>

                <motion.div
                  layout="position"
                  className="relative z-10 flex items-center justify-between gap-2"
                >
                  <div className="flex items-center gap-1">
                    {selectedModel ? (
                      <button
                        ref={modelTriggerRef}
                        type="button"
                        aria-label={`Model: ${selectedModel?.name ?? "Unavailable"}`}
                        aria-haspopup="dialog"
                        aria-expanded={isModelPickerOpen}
                        aria-controls={modelPanelId}
                        onPointerDown={() => setModelMotionEnabled(true)}
                        onKeyDown={(event) => {
                          if (event.key === "Enter" || event.key === " ") {
                            event.preventDefault();
                            modelKeyboardToggleRef.current = true;
                            setModelMotionEnabled(false);
                            setIsModelPickerOpen((isOpen) => !isOpen);
                            requestAnimationFrame(() => {
                              modelKeyboardToggleRef.current = false;
                            });
                          } else if (event.key === "ArrowUp" || event.key === "ArrowDown") {
                            event.preventDefault();
                            setModelMotionEnabled(false);
                            setIsModelPickerOpen(true);
                          } else if (event.key === "Escape") {
                            event.preventDefault();
                            setModelMotionEnabled(false);
                            setIsModelPickerOpen(false);
                          }
                        }}
                        onClick={() => {
                          if (modelKeyboardToggleRef.current) return;
                          setIsModelPickerOpen((isOpen) => !isOpen);
                        }}
                        className={cn(
                          "flex h-8 min-w-[96px] items-center gap-1.5 rounded-lg bg-neutral-100 px-2 text-left text-xs font-medium text-neutral-700 outline-none transition-[color,background-color,transform] duration-150 hover:bg-neutral-200/80 dark:bg-neutral-800 dark:text-neutral-200 dark:hover:bg-neutral-700",
                          modelMotionEnabled && "active:scale-[0.97]",
                        )}
                      >
                        <span className="relative h-4 min-w-0 flex-1 overflow-hidden">
                          <AnimatePresence initial={false} mode="popLayout">
                            <motion.span
                              key={selectedModel.id}
                              initial={
                                modelMotionEnabled
                                  ? {
                                      opacity: 0,
                                      transform: "translateY(6px)",
                                    }
                                  : false
                              }
                              animate={{
                                opacity: 1,
                                transform: "translateY(0px)",
                              }}
                              exit={
                                modelMotionEnabled
                                  ? {
                                      opacity: 0,
                                      transform: "translateY(-6px)",
                                    }
                                  : { opacity: 0 }
                              }
                              transition={{
                                duration: modelMotionEnabled ? 0.16 : 0,
                                ease: easeOut,
                              }}
                              className="absolute inset-0 flex items-center"
                            >
                              {selectedModel.name}
                            </motion.span>
                          </AnimatePresence>
                        </span>

                        <motion.span
                          aria-hidden="true"
                          animate={{
                            transform: isModelPickerOpen ? "rotate(180deg)" : "rotate(0deg)",
                          }}
                          transition={{
                            duration: modelMotionEnabled ? 0.16 : 0,
                            ease: easeOut,
                          }}
                          className="flex shrink-0 text-neutral-400"
                        >
                          <ChevronDown className="size-3" strokeWidth={1.8} />
                        </motion.span>
                      </button>
                    ) : null}

                    {composerActions.map((action) => (
                      <ComposerIconButton
                        key={action.id}
                        label={action.label}
                        onClick={action.onClick}
                        disabled={disabled || isSending || action.disabled}
                      >
                        {action.icon}
                      </ComposerIconButton>
                    ))}
                  </div>

                  <button
                    type="submit"
                    disabled={!canSend}
                    aria-label={labels.send}
                    onFocus={() => setIsModelPickerOpen(false)}
                    onPointerDown={() => setIsModelPickerOpen(false)}
                    className={cn(
                      "flex size-9 shrink-0 items-center justify-center rounded-xl transition-[color,background-color,transform] duration-150 active:scale-[0.97]",
                      canSend
                        ? "bg-neutral-900 text-white hover:bg-neutral-800 dark:bg-neutral-100 dark:text-neutral-900 dark:hover:bg-neutral-200"
                        : "cursor-not-allowed bg-neutral-200 text-neutral-400 dark:bg-neutral-800 dark:text-neutral-600",
                    )}
                  >
                    <ArrowUp className="size-4" strokeWidth={2.25} />
                  </button>
                </motion.div>
              </motion.div>
            </form>
          </div>
        </div>
      </LayoutGroup>
    </MotionConfig>
  );
}

export default AiChatBox;

More from skecher-ui

Browse all components in this library →