InputBar
Collect prompts and attachments in the composer. Supports controlled mode (value/onChange), drag/paste handling, info bar, typing animation, multi-question navigation, and free-form toolbar slots (leftActions/rightActions) for composing model/mode pickers or any custom controls.
Getting Started
$pnpm dlx shadcn@latest add https://agent-elements.21st.dev/r/input-bar.jsonExamples
Basic input
With attachments
spec.md3.1 KB
Focus outline
Info bar
Syncing workspace We will keep watching for changes.
Info bar (bottom)
Syncing workspace We will keep watching for changes.
Question bar
Question
1 of 2
1Which direction should I take?
C
Toolbar actions (model + mode)
Source
Copy and paste the following code into your project, or run the install command above to pull it in automatically.
components/input-bar.tsx
"use client";
import { memo, useState, useCallback, useRef, useEffect } from "react";
import type { ChatStatus } from "ai";
import { cn } from "../utils/cn";
type InputConfig = {
inputBarPlaceholder: string;
attachmentButtonPosition: "left" | "right";
attachmentPreviewStyle: "thumbnail" | "chip" | "hidden";
};
const DEFAULT_INPUT_CONFIG: InputConfig = {
inputBarPlaceholder: "Send a message...",
attachmentButtonPosition: "left",
attachmentPreviewStyle: "thumbnail",
};
import {
IconChevronDown,
IconChevronUp,
IconMessageCircleQuestion,
IconX,
} from "@tabler/icons-react";
import { SendButton } from "./input/send-button";
import { AttachmentButton } from "./input/attachment-button";
import { FileAttachment } from "./input/file-attachment";
import { useInputTyping } from "./input/input-typing";
import { QuestionPrompt } from "./question/question-prompt";
import { Suggestions, type SuggestionItem } from "./input/suggestions";
import type {
QuestionAnswer,
QuestionConfig,
} from "./question/question-prompt";
export type AttachedImage = {
id: string;
filename: string;
url: string;
size?: number;
};
export type AttachedFile = {
id: string;
filename: string;
size?: number;
};
export type InputBarProps = {
onSend: (message: { role: "user"; content: string }) => void;
status: ChatStatus;
onStop: () => void;
placeholder?: string;
className?: string;
// Attachment support
onAttach?: () => void;
attachedImages?: AttachedImage[];
attachedFiles?: AttachedFile[];
onRemoveImage?: (id: string) => void;
onRemoveFile?: (id: string) => void;
onPaste?: (e: React.ClipboardEvent) => void;
isDragOver?: boolean;
// Controlled mode
value?: string;
onChange?: (value: string) => void;
disabled?: boolean;
autoFocus?: boolean;
suggestions?:
| SuggestionItem[]
| {
items: SuggestionItem[];
className?: string;
itemClassName?: string;
};
// Typing animation
typingAnimation?: {
text: string;
duration: number;
image?: string;
isActive: boolean;
onComplete: () => void;
};
infoBar?: {
title?: string;
description?: string;
onClose?: () => void;
position?: "top" | "bottom";
};
questionBar?: {
id: string;
questions: QuestionConfig[];
questionIndex?: number;
totalQuestions?: number;
onPreviousQuestion?: () => void;
onNextQuestion?: () => void;
submitLabel?: string;
skipLabel?: string;
allowSkip?: boolean;
onSubmit: (answer: QuestionAnswer) => void;
onSkip?: () => void;
};
/** Content rendered on the left of the toolbar, next to the attachment button. */
leftActions?: React.ReactNode;
/** Content rendered on the right of the toolbar, before the send button. */
rightActions?: React.ReactNode;
};
export const InputBar = memo(function InputBar({
onSend,
status,
onStop,
placeholder,
className,
onAttach,
attachedImages = [],
attachedFiles = [],
onRemoveImage,
onRemoveFile,
onPaste,
isDragOver,
value: controlledValue,
onChange: controlledOnChange,
disabled,
autoFocus,
suggestions = [],
typingAnimation,
infoBar,
questionBar,
leftActions,
rightActions,
}: InputBarProps) {
const [internalInput, setInternalInput] = useState("");
const [isInfoBarOpen, setIsInfoBarOpen] = useState(true);
const [dismissedQuestionId, setDismissedQuestionId] = useState<string | null>(
null,
);
const [questionBarIndex, setQuestionBarIndex] = useState(1);
const isControlled = controlledValue !== undefined;
const input = isControlled ? controlledValue : internalInput;
const setInput = useCallback(
(v: string) => {
if (isControlled) {
controlledOnChange?.(v);
} else {
setInternalInput(v);
}
},
[isControlled, controlledOnChange],
);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const config = DEFAULT_INPUT_CONFIG;
const isStreaming = status === "streaming" || status === "submitted";
const isTyping = typingAnimation?.isActive ?? false;
const { displayedText, showImage } = useInputTyping(
typingAnimation?.text ?? "",
typingAnimation?.duration ?? 2000,
isTyping,
typingAnimation?.onComplete ?? (() => {}),
);
const effectivePlaceholder = placeholder ?? config.inputBarPlaceholder;
const showAttach = Boolean(onAttach);
const attachRight = config.attachmentButtonPosition === "right";
// Auto-resize textarea
useEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "0";
const nextHeight = Math.min(el.scrollHeight, 120);
el.style.height = `${nextHeight}px`;
el.style.overflowY = el.scrollHeight > 120 ? "auto" : "hidden";
el.style.overflowX = "hidden";
}, [input]);
useEffect(() => {
if (!autoFocus) return;
textareaRef.current?.focus();
}, [autoFocus]);
const handleSubmit = useCallback(() => {
const trimmed = input.trim();
if (!trimmed || isStreaming || disabled) return;
onSend({ role: "user", content: trimmed });
setInput("");
}, [input, isStreaming, disabled, onSend, setInput]);
const handleInfoBarClose = useCallback(() => {
setIsInfoBarOpen(false);
infoBar?.onClose?.();
}, [infoBar]);
const infoBarPosition = infoBar?.position ?? "top";
const shouldShowInfoBar = Boolean(
infoBar && (infoBar.title || infoBar.description),
);
const infoBarData = infoBar ?? {};
const infoBarNode = shouldShowInfoBar ? (
<div
className={cn(
"flex items-center justify-between gap-3 px-3 h-[34px]",
"transition-all duration-150 ease-out overflow-hidden",
isInfoBarOpen ? "opacity-100 max-h-[34px]" : "opacity-0 max-h-0",
infoBarPosition === "top"
? "rounded-t-an-input-border-radius"
: "rounded-b-an-input-border-radius",
)}
>
<div className="min-w-0 truncate text-xs text-an-foreground">
{infoBarData.title && (
<span className="font-medium">{infoBarData.title}</span>
)}
{infoBarData.description && (
<span className="text-an-foreground-muted/80">
{infoBarData.title
? ` ${infoBarData.description}`
: infoBarData.description}
</span>
)}
</div>
{infoBarData.onClose && (
<button
type="button"
onClick={handleInfoBarClose}
className="shrink-0 inline-flex items-center justify-center w-6 h-6 rounded-md text-an-foreground-muted/70 hover:text-an-foreground hover:bg-an-background-secondary"
aria-label="Close"
>
<IconX className="w-3.5 h-3.5" strokeWidth={2} />
</button>
)}
</div>
) : null;
const shouldShowQuestionBar = Boolean(
questionBar && questionBar.id !== dismissedQuestionId,
);
const questionBarData = questionBar;
const questionSet = questionBarData?.questions ?? [];
const hasQuestions = questionSet.length > 0;
const derivedTotal = hasQuestions ? questionSet.length : 1;
const totalQuestions = questionBarData?.totalQuestions ?? derivedTotal;
const hasExternalQuestionNavigation = Boolean(
questionBarData?.onPreviousQuestion || questionBarData?.onNextQuestion,
);
const questionIndex = hasExternalQuestionNavigation
? (questionBarData?.questionIndex ?? 1)
: questionBarIndex;
const clampedQuestionIndex = Math.max(
1,
Math.min(questionIndex, totalQuestions),
);
const activeQuestion = hasQuestions
? questionSet[clampedQuestionIndex - 1]
: undefined;
const showQuestionNavigation = totalQuestions > 1;
const canGoPrev = clampedQuestionIndex > 1;
const canGoNext = clampedQuestionIndex < totalQuestions;
const handleQuestionPrevious = useCallback(() => {
if (!canGoPrev) return;
if (questionBarData?.onPreviousQuestion) {
questionBarData.onPreviousQuestion();
return;
}
setQuestionBarIndex((prev) => Math.max(1, prev - 1));
}, [canGoPrev, questionBarData]);
const handleQuestionNext = useCallback(() => {
if (!canGoNext) return;
if (questionBarData?.onNextQuestion) {
questionBarData.onNextQuestion();
return;
}
setQuestionBarIndex((prev) => Math.min(totalQuestions, prev + 1));
}, [canGoNext, questionBarData, totalQuestions]);
const questionBarNode =
shouldShowQuestionBar && activeQuestion ? (
<div
className={cn(
"border-t border-x border-border max-w-[calc(100%-24px)] w-full mx-auto",
!shouldShowInfoBar || infoBarPosition === "bottom"
? "rounded-t-an-input-border-radius"
: null,
)}
>
<div className="h-7 border-b border-border px-3 flex items-center justify-between text-xs text-an-tool-color-muted">
<div className="inline-flex items-center gap-1.5">
<IconMessageCircleQuestion className="w-3.5 h-3.5" />
Question
</div>
{showQuestionNavigation && (
<div className="inline-flex items-center gap-1">
<button
type="button"
onClick={handleQuestionPrevious}
disabled={!canGoPrev}
className="size-5 inline-flex items-center justify-center rounded-[4px] hover:bg-an-background-secondary disabled:opacity-40"
aria-label="Previous question"
>
<IconChevronUp className="w-3.5 h-3.5" />
</button>
<span>
{clampedQuestionIndex} of {totalQuestions}
</span>
<button
type="button"
onClick={handleQuestionNext}
disabled={!canGoNext}
className="size-5 inline-flex items-center justify-center rounded-[4px] hover:bg-an-background-secondary disabled:opacity-40"
aria-label="Next question"
>
<IconChevronDown className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
<QuestionPrompt
key={`${clampedQuestionIndex}-${activeQuestion?.title ?? "question"}`}
questions={questionSet}
questionIndex={clampedQuestionIndex}
totalQuestions={totalQuestions}
submitLabel={questionBarData!.submitLabel}
skipLabel={questionBarData!.skipLabel}
allowSkip={questionBarData!.allowSkip}
onSubmit={(answer) => {
questionBarData!.onSubmit(answer);
setDismissedQuestionId(questionBarData!.id);
}}
onSkip={() => {
questionBarData!.onSkip?.();
}}
/>
</div>
) : null;
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
},
[handleSubmit],
);
const hasInput = input.trim().length > 0;
const hasContextItems = attachedImages.length > 0 || attachedFiles.length > 0;
const showContextItems =
hasContextItems && config.attachmentPreviewStyle !== "hidden";
const imageDisplayMode =
config.attachmentPreviewStyle === "thumbnail" ? "image-only" : "chip";
const handleContainerClick = useCallback((e: React.MouseEvent) => {
if (
e.target === e.currentTarget ||
!(e.target as HTMLElement).closest("button, textarea")
) {
textareaRef.current?.focus();
}
}, []);
const handleSuggestionSelect = useCallback(
(item: SuggestionItem) => {
if (disabled || isStreaming) return;
setInput(item.value ?? item.label);
requestAnimationFrame(() => {
const el = textareaRef.current;
if (!el) return;
el.focus();
const end = el.value.length;
el.setSelectionRange(end, end);
});
},
[disabled, isStreaming, setInput],
);
const suggestionItems = Array.isArray(suggestions)
? suggestions
: (suggestions?.items ?? []);
const suggestionsClassName = Array.isArray(suggestions)
? undefined
: suggestions?.className;
const suggestionItemClassName = Array.isArray(suggestions)
? undefined
: suggestions?.itemClassName;
return (
<div className={cn("shrink-0 px-3 pb-3", className)}>
<div className="mx-auto max-w-an">
<div
className={cn(
"flex flex-col gap-0",
shouldShowInfoBar
? "bg-an-background-tertiary rounded-an-input-border-radius"
: null,
)}
>
{infoBarPosition === "top" && infoBarNode}
{questionBarNode}
<div
className={cn(
"relative cursor-text rounded-an-input-border-radius bg-an-input-background shadow-2xs ring-1 ring-foreground/10",
isDragOver && "ring-2 ring-an-primary-color",
)}
onClick={handleContainerClick}
>
{/* Context items (attached images/files) */}
<div
className={cn(
"grid transition-[grid-template-rows] duration-200 ease-out grid-rows-[0fr]",
showContextItems && "grid-rows-[1fr]",
)}
>
<div className="overflow-hidden">
{showContextItems && (
<div className="flex flex-wrap items-center gap-[6px] px-an-context-padding pt-an-context-padding pb-0.5">
{attachedImages.map((img) => (
<FileAttachment
key={img.id}
id={img.id}
filename={img.filename}
size={img.size}
isImage
url={img.url}
display={imageDisplayMode}
onRemove={
onRemoveImage
? () => onRemoveImage(img.id)
: undefined
}
/>
))}
{attachedFiles.map((file) => (
<FileAttachment
key={file.id}
id={file.id}
filename={file.filename}
size={file.size}
onRemove={
onRemoveFile ? () => onRemoveFile(file.id) : undefined
}
/>
))}
</div>
)}
</div>
</div>
{/* Typing animation image */}
{isTyping && typingAnimation?.image && showImage && (
<div className="flex gap-2 flex-wrap px-3 pt-3">
<div className="relative overflow-hidden shrink-0 w-16 h-16 rounded-md">
<img
src={typingAnimation.image}
alt=""
className="w-full h-full object-cover"
/>
</div>
</div>
)}
{/* Text input or typing animation text */}
<div className="pt-3 pb-0 pr-3 pl-3.5 min-h-[44px]">
{isTyping ? (
<div className="w-full text-[14px] leading-[1.6] text-an-foreground-muted">
<span>{displayedText}</span>
<span className="inline-block w-[2px] h-[1em] ml-px align-text-bottom bg-an-foreground animate-an-blink" />
</div>
) : (
<>
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={onPaste}
placeholder={effectivePlaceholder}
disabled={disabled}
rows={1}
className={cn(
"peer w-full resize-none bg-transparent border-0 outline-none text-[14px] leading-[1.6] text-an-foreground placeholder:text-an-input-placeholder-color",
"overflow-hidden",
disabled && "opacity-50 cursor-not-allowed",
)}
/>
<div className="pointer-events-none absolute inset-0 rounded-an-input-border-radius outline-2 outline-an-input-focus-outline opacity-0 transition-opacity duration-75 peer-focus-visible:opacity-100 peer-focus:opacity-100 z-20 ease-in-out" />
</>
)}
</div>
{/* Toolbar */}
<div className="flex items-center justify-between gap-3 px-2 pt-1 pb-2">
<div className="flex items-center gap-1 min-w-0">
{!attachRight && showAttach && onAttach && (
<AttachmentButton onClick={onAttach} />
)}
{leftActions}
</div>
<div className="flex items-center gap-1">
{rightActions}
{attachRight && showAttach && onAttach && (
<AttachmentButton onClick={onAttach} />
)}
{/* Send / Stop button */}
<div
onClick={() => {
if (isStreaming) {
onStop();
} else if (hasInput) {
handleSubmit();
}
}}
className="cursor-pointer"
>
<SendButton
state={
isStreaming
? "streaming"
: hasInput && !disabled
? "typing"
: "idle"
}
/>
</div>
</div>
</div>
</div>
{suggestionItems.length > 0 && (
<Suggestions
items={suggestionItems}
onSelect={handleSuggestionSelect}
disabled={disabled || isStreaming}
className={cn("mt-4 px-3", suggestionsClassName)}
itemClassName={suggestionItemClassName}
/>
)}
{infoBarPosition === "bottom" && infoBarNode}
</div>
</div>
</div>
);
});
Also installs:
API reference
Prop
Type
Required
onSend
(message: { role: "user"; content: string }) => void
Yes
status
ChatStatus
Yes
onStop
() => void
Yes
placeholder
string
No
className
string
No
onAttach
() => void
No
attachedImages
AttachedImage[]
No
attachedFiles
AttachedFile[]
No
onRemoveImage
(id: string) => void
No
onRemoveFile
(id: string) => void
No
onPaste
(e: React.ClipboardEvent) => void
No
isDragOver
boolean
No
value
string
No
onChange
(value: string) => void
No
disabled
boolean
No
autoFocus
boolean
No
suggestions
| SuggestionItem[] | { items: SuggestionItem[]; className?: string; itemClassName?: string; }
No
typingAnimation
{ text: string; duration: number; image?: string; isActive: boolean; onComplete: () => void; }
No
infoBar
{ title?: string; description?: string; onClose?: () => void; position?: "top" | "bottom"; }
No
questionBar
{ id: string; questions: QuestionConfig[]; questionIndex?: number; totalQuestions?: number; onPreviousQuestion?: () => void; onNextQuestion?: () => void; submitLabel?: string; skipLabel?: string; allowSkip?: boolean; onSubmit: (answer: QuestionAnswer) => void; onSkip?: () => void; }
No
leftActions
React.ReactNode
No
rightActions
React.ReactNode
No