Message composer
A chat composer that starts as a pill, stacks into a card when the text wraps, and carries attachments with an upload ring. Drop files on it to attach them; while a reply is working, send becomes stop.
- Uses
- motion
Code
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type DragEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { InputGroup, InputGroupButton } from "@manikrana/ui/components/ui/input-group";
import { cn } from "@manikrana/ui/lib/utils";
import { ArrowUpIcon, LoaderIcon, PlusLargeIcon, StopIcon, UploadIcon } from "@/components/icons";
import { PendingAttachmentStrip, type MessageComposerAttachment } from "./attachments";
export type { MessageComposerAttachment } from "./attachments";
const MIN_TEXTAREA_HEIGHT = 40;
const MAX_TEXTAREA_HEIGHT = 200;
// Must match the grid's two 2.5rem button columns and gap-x-1.
const NARROW_COLUMN_RESERVED = 40 * 2 + 4 * 2;
const EASE = [0.16, 1, 0.3, 1] as const;
const RADIUS = 24;
const MotionInputGroup = motion.create(InputGroup);
export type MessageComposerSubmit = {
text: string;
attachments: MessageComposerAttachment[];
};
export type MessageComposerProps = {
attachments?: MessageComposerAttachment[];
className?: string;
defaultValue?: string;
onAddAttachment?: () => void;
onChange?: (value: string) => void;
onDropFiles?: (files: File[]) => void;
onInterrupt?: () => void;
onRemoveAttachment?: (id: string) => void;
onSubmit?: (draft: MessageComposerSubmit) => void;
placeholder?: string;
sending?: boolean;
value?: string;
working?: boolean;
};
export function MessageComposer({
attachments = [],
className,
defaultValue = "",
onAddAttachment,
onChange,
onDropFiles,
onInterrupt,
onRemoveAttachment,
onSubmit,
placeholder = "Message…",
sending = false,
value,
working = false,
}: MessageComposerProps) {
const [internalValue, setInternalValue] = useState(defaultValue);
const [textareaHeight, setTextareaHeight] = useState(MIN_TEXTAREA_HEIGHT);
const [isDraggingFile, setIsDraggingFile] = useState(false);
const [isTextOverflowing, setIsTextOverflowing] = useState(false);
const [stackEntryAnimating, setStackEntryAnimating] = useState(false);
const dragDepthRef = useRef(0);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const gridRef = useRef<HTMLFormElement>(null);
const measureRef = useRef<HTMLTextAreaElement>(null);
const textareaHeightRef = useRef(MIN_TEXTAREA_HEIGHT);
const wasTextareaStackedRef = useRef(false);
const overflowTimeoutRef = useRef<number | null>(null);
const heightAnimationFrameRef = useRef<number | null>(null);
const stackEntryTimeoutRef = useRef<number | null>(null);
const prefersReducedMotion = useReducedMotion() === true;
const draft = value ?? internalValue;
const uploadInProgress = attachments.some((attachment) => attachment.status === "uploading");
const canSend =
!sending && !uploadInProgress && (draft.trim().length > 0 || attachments.length > 0);
const isTextareaStacked = draft.includes("\n") || isTextOverflowing;
const isEnteringTextareaStack = isTextareaStacked && !wasTextareaStackedRef.current;
const animateLayout = !isTextareaStacked || isEnteringTextareaStack || stackEntryAnimating;
const layoutTransition = (duration: number) =>
animateLayout ? { duration, ease: EASE } : { duration: 0 };
const clearPendingTimers = useCallback(() => {
if (overflowTimeoutRef.current !== null) {
window.clearTimeout(overflowTimeoutRef.current);
overflowTimeoutRef.current = null;
}
if (heightAnimationFrameRef.current !== null) {
window.cancelAnimationFrame(heightAnimationFrameRef.current);
heightAnimationFrameRef.current = null;
}
if (stackEntryTimeoutRef.current !== null) {
window.clearTimeout(stackEntryTimeoutRef.current);
stackEntryTimeoutRef.current = null;
}
}, []);
useEffect(() => clearPendingTimers, [clearPendingTimers]);
const syncTextareaHeight = useCallback(
(scrollToBottom = false, animateHeight = false) => {
const el = textareaRef.current;
if (!el) return;
clearPendingTimers();
// Layout height, not the bounding box, so a scaled ancestor can't skew it.
const previousHeight = el.offsetHeight || textareaHeightRef.current;
el.style.height = "auto";
const scrollHeight = el.scrollHeight;
const nextHeight = Math.min(Math.max(scrollHeight, MIN_TEXTAREA_HEIGHT), MAX_TEXTAREA_HEIGHT);
const isCapped = scrollHeight > MAX_TEXTAREA_HEIGHT;
const enableOverflowWhenReady = (delay: number) => {
if (!isCapped) return;
overflowTimeoutRef.current = window.setTimeout(() => {
if (textareaRef.current !== el) return;
el.style.overflowY = "auto";
if (scrollToBottom) {
el.scrollTop = el.scrollHeight;
}
}, delay);
};
el.style.overflowY = "hidden";
// Pin the old height for a frame so the CSS height transition has a start value.
if (animateHeight && previousHeight !== nextHeight) {
setStackEntryAnimating(true);
textareaHeightRef.current = previousHeight;
setTextareaHeight(previousHeight);
el.style.height = `${previousHeight}px`;
void el.offsetHeight;
heightAnimationFrameRef.current = window.requestAnimationFrame(() => {
heightAnimationFrameRef.current = null;
if (textareaRef.current !== el) return;
textareaHeightRef.current = nextHeight;
setTextareaHeight(nextHeight);
el.style.height = `${nextHeight}px`;
enableOverflowWhenReady(160);
stackEntryTimeoutRef.current = window.setTimeout(() => {
stackEntryTimeoutRef.current = null;
setStackEntryAnimating(false);
}, 180);
});
return;
}
textareaHeightRef.current = nextHeight;
setTextareaHeight(nextHeight);
el.style.height = `${nextHeight}px`;
enableOverflowWhenReady(0);
},
[clearPendingTimers],
);
const resetTextareaHeight = useCallback(() => {
clearPendingTimers();
setStackEntryAnimating(false);
textareaHeightRef.current = MIN_TEXTAREA_HEIGHT;
setTextareaHeight(MIN_TEXTAREA_HEIGHT);
const el = textareaRef.current;
if (!el) return;
el.style.height = `${MIN_TEXTAREA_HEIGHT}px`;
el.style.overflowY = "hidden";
el.scrollTop = 0;
}, [clearPendingTimers]);
const measureOverflow = useCallback(() => {
const grid = gridRef.current;
const mirror = measureRef.current;
if (!grid || !mirror) return false;
const narrowWidth = grid.clientWidth - NARROW_COLUMN_RESERVED;
if (narrowWidth <= 0) return false;
if (mirror.value !== draft) mirror.value = draft;
mirror.style.width = `${narrowWidth}px`;
mirror.style.height = "auto";
return mirror.scrollHeight > MIN_TEXTAREA_HEIGHT + 1;
}, [draft]);
useLayoutEffect(() => {
setIsTextOverflowing(measureOverflow());
}, [measureOverflow]);
useEffect(() => {
const grid = gridRef.current;
if (!grid) return;
const observer = new ResizeObserver(() => {
setIsTextOverflowing(measureOverflow());
});
observer.observe(grid);
return () => observer.disconnect();
}, [measureOverflow]);
useLayoutEffect(() => {
syncTextareaHeight(true, isEnteringTextareaStack);
}, [isEnteringTextareaStack, syncTextareaHeight, draft]);
useLayoutEffect(() => {
wasTextareaStackedRef.current = isTextareaStacked;
}, [isTextareaStacked]);
function setDraft(nextValue: string) {
if (value === undefined) {
setInternalValue(nextValue);
}
onChange?.(nextValue);
}
function submit() {
if (!canSend || working) return;
onSubmit?.({
text: draft,
attachments,
});
if (value === undefined) {
setInternalValue("");
}
resetTextareaHeight();
}
// The depth counter ignores the enter/leave pairs fired by child elements.
const acceptsDrag = (event: DragEvent) =>
Boolean(onDropFiles) && event.dataTransfer.types.includes("Files");
return (
<div
className={cn("relative mx-auto w-full max-w-3xl px-4 pb-4", className)}
onDragEnter={(event) => {
if (!acceptsDrag(event)) return;
dragDepthRef.current += 1;
setIsDraggingFile(true);
}}
onDragOver={(event) => {
if (!acceptsDrag(event)) return;
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
}}
onDragLeave={(event) => {
if (!acceptsDrag(event)) return;
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (dragDepthRef.current === 0) setIsDraggingFile(false);
}}
onDrop={(event) => {
if (!acceptsDrag(event)) return;
event.preventDefault();
dragDepthRef.current = 0;
setIsDraggingFile(false);
onDropFiles?.(Array.from(event.dataTransfer.files));
}}
>
<div className="relative">
{/* A motion style, not a class, so layout animation doesn't distort the corners. */}
<MotionInputGroup
layout
style={{ borderRadius: RADIUS }}
transition={layoutTransition(0.24)}
className="relative h-auto flex-col items-stretch overflow-hidden p-1 has-[[data-slot=input-group-control]:focus-visible]:ring-0"
>
<PendingAttachmentStrip
attachments={attachments}
onRemove={onRemoveAttachment}
transition={layoutTransition(0.24)}
/>
<motion.form
ref={gridRef}
layout
className={cn(
"grid min-h-10 grid-cols-[2.5rem_minmax(0,1fr)_2.5rem] items-end gap-x-1",
isTextareaStacked ? "grid-rows-[auto_2.5rem]" : "grid-rows-[2.5rem]",
)}
onSubmit={(event) => {
event.preventDefault();
submit();
}}
transition={layoutTransition(0.22)}
>
<motion.div
layout
className={cn(
"col-start-1 row-start-1 size-10 shrink-0",
isTextareaStacked && "row-start-2",
)}
transition={layoutTransition(0.22)}
>
<InputGroupButton
variant="ghost"
size="icon-sm"
className="size-full [&_svg]:size-5"
onClick={onAddAttachment}
aria-label="Add attachment"
>
<PlusLargeIcon />
</InputGroupButton>
</motion.div>
<motion.textarea
layout="position"
ref={textareaRef}
data-slot="input-group-control"
value={draft}
rows={1}
placeholder={placeholder}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
submit();
}
}}
style={{ height: textareaHeight }}
className={cn(
"min-h-10 resize-none overflow-y-hidden bg-transparent text-base text-foreground outline-none placeholder:text-muted-foreground motion-reduce:transition-none",
animateLayout && "transition-[height] duration-150 ease-out",
isTextareaStacked
? "col-span-3 row-start-1 w-full px-3 pt-3 pb-1 leading-6"
: "col-start-2 row-start-1 w-full px-1 py-2.5 leading-5",
)}
aria-label="Message"
transition={layoutTransition(0.22)}
/>
<textarea
ref={measureRef}
aria-hidden="true"
tabIndex={-1}
readOnly
rows={1}
className="pointer-events-none invisible absolute top-0 left-0 -z-10 box-border min-h-10 w-full resize-none overflow-hidden bg-transparent px-1 py-2.5 text-base leading-5"
/>
<motion.div
layout
className={cn(
"col-start-3 row-start-1 size-10 shrink-0 justify-self-end",
isTextareaStacked && "row-start-2",
)}
transition={layoutTransition(0.22)}
>
{working ? (
<InputGroupButton
variant="red"
size="icon-sm"
className="size-full [&_svg]:size-3.5"
onClick={onInterrupt}
aria-label="Stop response"
>
<StopIcon />
</InputGroupButton>
) : (
<InputGroupButton
type="submit"
variant="default"
size="icon-sm"
className="size-full [&_svg]:size-5"
disabled={!canSend}
aria-label="Send message"
>
{sending ? <LoaderIcon className="animate-spin" /> : <ArrowUpIcon />}
</InputGroupButton>
)}
</motion.div>
</motion.form>
</MotionInputGroup>
<DropOverlay active={isDraggingFile} prefersReducedMotion={prefersReducedMotion} />
</div>
</div>
);
}
function DropOverlay({
active,
prefersReducedMotion,
}: {
active: boolean;
prefersReducedMotion: boolean;
}) {
return (
<AnimatePresence initial={false}>
{active ? (
<motion.div
className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-3xl bg-black/72 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.16, ease: EASE }}
>
<motion.div
className="flex items-center gap-2 text-sm leading-none font-medium text-white"
initial={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 4, scale: 0.98 }}
animate={prefersReducedMotion ? { opacity: 1 } : { opacity: 1, y: 0, scale: 1 }}
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 2, scale: 0.99 }}
transition={{ duration: 0.18, ease: EASE }}
>
<UploadIcon />
Drop files to add them
</motion.div>
</motion.div>
) : null}
</AnimatePresence>
);
}