uselayouts
componentprompt-boxsource: ok · hit
Prompt Box
A collapsed chat composer that expands into a textarea with a model menu and send control.
npx shadcn add "https://uselayouts.com/r/prompt-box.json"free
Where it goes
chat
Interaction
accordiondropdown
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumanimationmotion
ui-primitives@base-ui/react
iconslucide-react
Files2 · source cached 10/8/2026, 11:21:25 PM
registry/default/example/prompt-box.tsx
"use client";
import { Input as InputPrimitive } from "@base-ui/react/input";
import { ArrowUp, Check, ChevronDown, ChevronRight, Mic } from "lucide-react";
import { AnimatePresence, LayoutGroup, motion } from "motion/react";
import {
Fragment,
type ReactNode,
useCallback,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { createPortal, flushSync } from "react-dom";
import { ClaudeAI, Cursor, OpenAI } from "./prompt-box-icons";
const SHELL_SPRING = {
type: "spring" as const,
stiffness: 400,
damping: 25,
mass: 1.5,
};
const SETTINGS_PANEL_SPRING = {
type: "spring" as const,
stiffness: 480,
damping: 24,
mass: 0.85,
};
const EASE_OUT = [0.215, 0.61, 0.355, 1] as const;
const EASE_OUT_QUAD = [0.25, 0.46, 0.45, 0.94] as const;
const FAST_TRANSITION = {
duration: 0.16,
ease: EASE_OUT_QUAD,
};
const COLLAPSED_HEIGHT = 52;
/** Approximate settle time for SHELL_SPRING height collapse before hover affordances. */
const SHELL_COLLAPSE_SETTLE_MS = 320;
const FOOTER_HEIGHT = 52;
/** One line: pt-5 (20px) + leading-5 (20px) */
const SINGLE_LINE_TEXTAREA_HEIGHT = 40;
const MIN_EXPANDED_HEIGHT = SINGLE_LINE_TEXTAREA_HEIGHT + FOOTER_HEIGHT;
const MAX_TEXTAREA_HEIGHT = 254;
const MAX_EXPANDED_HEIGHT = MAX_TEXTAREA_HEIGHT + FOOTER_HEIGHT;
const promptFieldClassName =
"field-sizing-content w-full border-0 bg-transparent text-base leading-5 text-foreground shadow-none outline-none placeholder:font-medium placeholder:text-muted-foreground focus:outline-none focus-visible:outline-none focus-visible:ring-0";
const promptFieldCollapsedClassName =
"w-full border-0 bg-transparent text-base text-muted-foreground shadow-none outline-none placeholder:font-medium placeholder:text-muted-foreground focus:outline-none focus-visible:outline-none focus-visible:ring-0";
const promptFieldCollapsedRowClassName =
"h-8 min-w-0 flex-1 cursor-text border-0 bg-transparent p-0 font-medium leading-8 text-muted-foreground placeholder:text-muted-foreground placeholder:leading-8";
export type PromptSettingOption = {
value: string;
label: string;
description?: string;
icon?: ReactNode;
};
export type PromptSettingGroup = {
id: string;
label: string;
options: PromptSettingOption[];
/** Render options as selectable rows in the main panel. */
display?: "featured" | "submenu";
};
export type PromptMenuAction = {
label: string;
icon?: ReactNode;
onSelect: () => void;
};
export type PromptPlusMenuOption = {
value: string;
label: string;
};
export type PromptPlusMenuItem = {
id: string;
label: string;
icon?: ReactNode;
shortcut?: string;
onSelect?: () => void;
options?: PromptPlusMenuOption[];
onOptionSelect?: (value: string) => void;
};
function getDefaultSettings(
groups: PromptSettingGroup[],
defaults?: Record<string, string>
) {
return groups.reduce<Record<string, string>>((settings, group) => {
const preferred = defaults?.[group.id];
const hasPreferred = group.options.some(
(option) => option.value === preferred
);
settings[group.id] =
hasPreferred && preferred ? preferred : (group.options[0]?.value ?? "");
return settings;
}, {});
}
function getOptionLabel(group: PromptSettingGroup, value: string) {
return group.options.find((option) => option.value === value)?.label ?? value;
}
function getSelectedSettingIcon(
groups: PromptSettingGroup[],
values: Record<string, string>
) {
const featuredGroup = groups.find((group) => group.display === "featured");
const group = featuredGroup ?? groups[0];
if (!group) return null;
const selectedValue = values[group.id] ?? "";
return group.options.find((option) => option.value === selectedValue)?.icon ?? null;
}
function CollapsedSelectedModelIcon({
icon,
reduceMotion,
visible,
}: {
icon: ReactNode;
reduceMotion: boolean;
visible: boolean;
}) {
return (
<AnimatePresence>
{visible ? (
<motion.span
aria-hidden="true"
className="pointer-events-none flex size-4 shrink-0 items-center justify-center text-muted-foreground [&_svg]:block [&_svg]:size-4"
initial={
reduceMotion
? { opacity: 0 }
: { opacity: 0, x: 28, rotate: -360 }
}
animate={
reduceMotion ? { opacity: 1 } : { opacity: 1, x: 0, rotate: 0 }
}
exit={
reduceMotion
? { opacity: 0 }
: { opacity: 0, x: 22, rotate: 120 }
}
transition={
reduceMotion
? { duration: 0.15, ease: EASE_OUT_QUAD }
: { duration: 0.38, ease: EASE_OUT }
}
>
{icon}
</motion.span>
) : null}
</AnimatePresence>
);
}
const dropdownPanelClassName =
"fixed z-[400] w-[min(17rem,calc(100vw-1.5rem))] overflow-y-auto overscroll-contain rounded-lg bg-popover py-1 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none [-webkit-overflow-scrolling:touch]";
const settingsDropdownPanelClassName =
"fixed z-[400] min-w-[15rem] w-max max-w-[calc(100vw-1.5rem)] overflow-y-auto overscroll-contain rounded-lg bg-popover p-1 text-sm leading-none text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none [-webkit-overflow-scrolling:touch]";
const DROPDOWN_SIDE_OFFSET = 8;
const DROPDOWN_VIEWPORT_MARGIN = 12;
const DROPDOWN_SUBMENU_OFFSET = 4;
const DROPDOWN_PANEL_WIDTH = 272;
const DROPDOWN_ESTIMATED_HEIGHT = 280;
const COMPACT_VIEWPORT_WIDTH = 640;
const DROPDOWN_MIN_PANEL_HEIGHT = 160;
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
function getPanelWidth(viewportWidth: number) {
return Math.min(
DROPDOWN_PANEL_WIDTH,
viewportWidth - DROPDOWN_VIEWPORT_MARGIN * 2
);
}
/**
* Starts at `false` on both server and client and is corrected in the effect.
* Matching the query during the initial render instead would make the first
* client render disagree with the server HTML, and buy nothing: the effect
* below already applies the real value on mount.
*/
function useMediaQuery(query: string) {
const [matches, setMatches] = useState(false);
useEffect(() => {
const mediaQuery = window.matchMedia(query);
const update = () => setMatches(mediaQuery.matches);
update();
mediaQuery.addEventListener("change", update);
return () => mediaQuery.removeEventListener("change", update);
}, [query]);
return matches;
}
const COMPACT_VIEWPORT_QUERY = `(max-width: ${COMPACT_VIEWPORT_WIDTH - 1}px)`;
function useCompactViewport() {
return useMediaQuery(COMPACT_VIEWPORT_QUERY);
}
function usePrefersHover() {
return useMediaQuery("(hover: hover) and (pointer: fine)");
}
function usePrefersReducedMotion() {
return useMediaQuery("(prefers-reduced-motion: reduce)");
}
function useIsMounted() {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
return mounted;
}
function getPanelMotion({
offsetX = 0,
offsetY = 0,
openingEase,
reduceMotion = false,
instantDismiss = false,
}: {
offsetX?: number;
offsetY?: number;
openingEase?: {
type: "spring";
stiffness: number;
damping: number;
mass: number;
};
reduceMotion?: boolean;
instantDismiss?: boolean;
}) {
if (reduceMotion) {
return {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0, transition: { duration: instantDismiss ? 0 : 0.12 } },
transition: { duration: instantDismiss ? 0 : 0.12 },
};
}
return {
initial: { opacity: 0, scale: 0.94, x: offsetX, y: offsetY },
animate: { opacity: 1, scale: 1, x: 0, y: 0 },
exit: instantDismiss
? { opacity: 0, scale: 1, x: 0, y: 0, transition: { duration: 0 } }
: {
opacity: 0,
scale: 0.98,
x: offsetX * 0.55,
y: offsetY * 0.65,
transition: { duration: 0.14, ease: EASE_OUT_QUAD },
},
transition: instantDismiss
? { duration: 0 }
: (openingEase ?? { duration: 0.24, ease: EASE_OUT }),
};
}
function isInsideDropdownPanel(node: Node | null) {
return (
node instanceof Element &&
Boolean(node.closest("[data-prompt-dropdown-panel]"))
);
}
function useDropdownDismiss({
open,
onClose,
onEscape,
triggerRef,
}: {
open: boolean;
onClose: () => void;
onEscape?: () => boolean;
triggerRef: React.RefObject<HTMLElement | null>;
}) {
useEffect(() => {
if (!open) return;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Node;
if (triggerRef.current?.contains(target)) return;
if (isInsideDropdownPanel(target)) return;
onClose();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.stopPropagation();
if (onEscape?.()) return;
onClose();
};
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [onClose, onEscape, open, triggerRef]);
}
function preventFocusSteal(event: React.MouseEvent) {
event.preventDefault();
}
function activateOnEnterOrSpace(
event: React.KeyboardEvent,
action: () => void
) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
action();
}
}
const BAR_XS = [1.5, 5.75, 10] as const;
const BAR_WIDTH = 2.5;
const BAR_BOTTOM = 12.5;
type EffortLevel = "low" | "medium" | "high";
const BAR_HEIGHTS = [4.5, 7.5, 10.5] as const;
const EFFORT_BAR_LEVELS: Record<
EffortLevel,
Array<{ height: number; visible: boolean }>
> = {
low: [
{ height: 3.25, visible: true },
{ height: 4.75, visible: true },
{ height: 0, visible: false },
],
medium: [
{ height: BAR_HEIGHTS[0], visible: true },
{ height: BAR_HEIGHTS[1], visible: true },
{ height: 0, visible: false },
],
high: [
{ height: BAR_HEIGHTS[0], visible: true },
{ height: BAR_HEIGHTS[1], visible: true },
{ height: BAR_HEIGHTS[2], visible: true },
],
};
function normalizeEffortLevel(value: string): EffortLevel {
if (value === "low" || value === "medium" || value === "high") {
return value;
}
return "medium";
}
function getEffortGroupId(groups: PromptSettingGroup[]) {
return (
groups.find((group) => group.id === "effort")?.id ??
groups.find((group) => group.display === "submenu")?.id
);
}
function EffortBarsIcon({ level }: { level: EffortLevel }) {
const bars = EFFORT_BAR_LEVELS[level];
return (
<svg
aria-hidden="true"
fill="none"
height="14"
viewBox="0 0 14 14"
width="14"
>
{bars.map((bar, index) => (
<motion.rect
animate={{
height: bar.visible ? bar.height : 0,
opacity: bar.visible ? 1 : 0,
y: bar.visible ? BAR_BOTTOM - bar.height : BAR_BOTTOM,
}}
fill="currentColor"
initial={{
height: bar.visible ? bar.height : 0,
opacity: bar.visible ? 1 : 0,
y: bar.visible ? BAR_BOTTOM - bar.height : BAR_BOTTOM,
}}
key={BAR_XS[index]}
rx={1}
transition={{ duration: 0.26, ease: EASE_OUT }}
width={BAR_WIDTH}
x={BAR_XS[index]}
/>
))}
</svg>
);
}
const dropdownSubmenuTriggerClassName =
"relative flex min-h-11 w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm outline-none transition-colors focus-visible:outline-none";
const dropdownOptionClassName =
"relative flex min-h-11 w-full cursor-pointer scroll-m-1 touch-manipulation items-center justify-between gap-3 rounded-lg px-3 py-2.5 text-left text-sm outline-none transition-colors focus-visible:text-foreground focus-visible:outline-none";
const settingsDropdownOptionClassName =
"relative flex min-h-10 w-full cursor-pointer scroll-m-1 touch-manipulation items-center justify-between gap-2.5 rounded-md py-2 pl-2 pr-2 text-left text-sm outline-none transition-[color,background-color,transform] duration-150 ease-out active:scale-[0.96] focus-visible:text-foreground focus-visible:outline-none";
const settingsDropdownHighlightClassName =
"absolute inset-0 rounded-md bg-accent";
const dropdownOptionHighlightClassName =
"absolute inset-x-1 inset-y-0.5 rounded-lg bg-accent/65";
const dropdownPanelSpring = {
type: "spring" as const,
stiffness: 420,
damping: 34,
mass: 0.72,
};
type SubmenuOrigin = "left" | "right" | "below";
type SubmenuPosition = {
left: number;
top: number;
maxHeight: number;
origin: SubmenuOrigin;
};
function getFlyoutSubmenuMotion(origin: SubmenuOrigin, instantClose = false) {
const offsetX = origin === "right" ? -12 : 12;
return {
animate: {
opacity: 1,
scale: 1,
x: 0,
},
exit: {
opacity: 0,
scale: instantClose ? 1 : 0.98,
x: instantClose ? 0 : offsetX * 0.55,
},
initial: {
opacity: 0,
scale: 0.96,
x: offsetX,
},
transition: instantClose ? { duration: 0 } : dropdownPanelSpring,
};
}
function getSubmenuPosition(
triggerRect: DOMRect,
panelHeight: number,
panelWidth: number,
isCompact: boolean
) {
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
const effectiveWidth = Math.min(panelWidth, getPanelWidth(viewportWidth));
if (isCompact) {
const left = clamp(
triggerRect.left,
DROPDOWN_VIEWPORT_MARGIN,
viewportWidth - effectiveWidth - DROPDOWN_VIEWPORT_MARGIN
);
const top = triggerRect.bottom + DROPDOWN_SIDE_OFFSET;
const maxHeight = Math.max(
DROPDOWN_MIN_PANEL_HEIGHT,
viewportHeight - top - DROPDOWN_VIEWPORT_MARGIN
);
return {
left,
maxHeight,
origin: "below" as const,
top,
};
}
const spaceRight =
viewportWidth -
triggerRect.right -
DROPDOWN_SUBMENU_OFFSET -
DROPDOWN_VIEWPORT_MARGIN;
const openRight = spaceRight >= effectiveWidth;
const left = openRight
? triggerRect.right + DROPDOWN_SUBMENU_OFFSET
: triggerRect.left - effectiveWidth - DROPDOWN_SUBMENU_OFFSET;
const clampedLeft = clamp(
left,
DROPDOWN_VIEWPORT_MARGIN,
viewportWidth - effectiveWidth - DROPDOWN_VIEWPORT_MARGIN
);
const maxTop = Math.max(
DROPDOWN_VIEWPORT_MARGIN,
viewportHeight - panelHeight - DROPDOWN_VIEWPORT_MARGIN
);
const top = clamp(triggerRect.top, DROPDOWN_VIEWPORT_MARGIN, maxTop);
const maxHeight = Math.max(
DROPDOWN_MIN_PANEL_HEIGHT,
viewportHeight - top - DROPDOWN_VIEWPORT_MARGIN
);
return {
left: clampedLeft,
maxHeight,
origin: openRight ? ("right" as const) : ("left" as const),
top,
};
}
function DropdownMenuDivider() {
return <hr className="mx-2 h-px border-0 bg-border" />;
}
function DropdownOption({
compact = false,
icon,
label,
reveal = false,
reduceMotion = false,
selected,
staggerIndex,
onSelect,
activeItemId,
highlightLayoutId,
setActiveItemId,
}: {
compact?: boolean;
icon?: ReactNode;
label: string;
reveal?: boolean;
reduceMotion?: boolean;
selected: boolean;
staggerIndex?: number;
onSelect: () => void;
activeItemId: string | null;
highlightLayoutId: string;
setActiveItemId: (itemId: string | null) => void;
}) {
const itemId = useId();
const isActive = activeItemId === itemId;
const shouldReveal = compact && staggerIndex !== undefined && reveal;
const revealMotion = shouldReveal
? reduceMotion
? {
initial: { opacity: 0 },
animate: { opacity: 1 },
transition: { duration: 0.12, ease: EASE_OUT_QUAD },
}
: {
initial: { opacity: 0, y: 8, filter: "blur(4px)" },
animate: { opacity: 1, y: 0, filter: "blur(0px)" },
transition: {
delay: staggerIndex * 0.08,
duration: 0.22,
ease: EASE_OUT,
},
}
: undefined;
return (
<motion.button
aria-checked={selected}
className={`${compact ? settingsDropdownOptionClassName : dropdownOptionClassName} ${selected ? "text-foreground" : "text-muted-foreground"}`}
onClick={onSelect}
onKeyDown={(event) => activateOnEnterOrSpace(event, onSelect)}
onMouseDown={preventFocusSteal}
onMouseEnter={() => setActiveItemId(itemId)}
onPointerMove={() => setActiveItemId(itemId)}
role="menuitemradio"
tabIndex={-1}
type="button"
{...revealMotion}
>
{isActive ? (
<motion.span
className={
compact
? settingsDropdownHighlightClassName
: dropdownOptionHighlightClassName
}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
layoutId={highlightLayoutId}
transition={FAST_TRANSITION}
/>
) : null}
<span
className={`relative z-10 flex min-w-0 flex-1 items-center ${compact ? "gap-2.5" : "gap-2.5 truncate"}`}
>
{icon ? (
<span
className={`flex shrink-0 items-center justify-center text-muted-foreground ${compact ? "size-4 [&_svg]:size-4" : "size-5"}`}
>
{icon}
</span>
) : null}
<span className="min-w-0 truncate">{label}</span>
</span>
<span
className={`relative z-10 flex shrink-0 items-center justify-center ${compact ? "size-4" : "size-5"}`}
>
<AnimatePresence>
{selected ? (
<motion.span
animate={
reduceMotion
? { opacity: 1 }
: compact
? { opacity: 1, scale: 1, filter: "blur(0px)" }
: { opacity: 1, scale: 1, y: 0 }
}
className="flex size-full items-center justify-center text-foreground"
exit={{ opacity: 0, scale: 0.85 }}
initial={
reduceMotion
? { opacity: 0 }
: compact
? { opacity: 0, scale: 0.25, filter: "blur(4px)" }
: { opacity: 0, scale: 0.78, y: 1 }
}
transition={{ duration: compact ? 0.3 : 0.18, ease: EASE_OUT }}
>
<Check className={compact ? "size-3.5" : "h-4 w-4"} />
</motion.span>
) : null}
</AnimatePresence>
</span>
</motion.button>
);
}
function DropdownActionItem({
label,
icon,
shortcut,
onSelect,
activeItemId,
highlightLayoutId,
setActiveItemId,
}: {
label: string;
icon?: React.ReactNode;
shortcut?: string;
onSelect: () => void;
activeItemId: string | null;
highlightLayoutId: string;
setActiveItemId: (itemId: string | null) => void;
}) {
const itemId = useId();
const isActive = activeItemId === itemId;
return (
<button
className={`${dropdownOptionClassName} text-foreground`}
onClick={onSelect}
onKeyDown={(event) => activateOnEnterOrSpace(event, onSelect)}
onMouseDown={preventFocusSteal}
onMouseEnter={() => setActiveItemId(itemId)}
onPointerMove={() => setActiveItemId(itemId)}
role="menuitem"
tabIndex={-1}
type="button"
>
{isActive ? (
<motion.span
className={dropdownOptionHighlightClassName}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
layoutId={highlightLayoutId}
transition={FAST_TRANSITION}
/>
) : null}
<span
className="relative z-10 flex min-w-0 flex-1 items-center gap-2.5 truncate"
>
{icon ? (
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
{icon}
</span>
) : null}
<span className="truncate">{label}</span>
</span>
{shortcut ? (
<span className="relative z-10 shrink-0 text-muted-foreground text-xs tabular-nums">
{shortcut}
</span>
) : null}
</button>
);
}
function useSubmenuPosition({
isCompact,
isOpen,
submenuRef,
triggerRef,
}: {
isCompact: boolean;
isOpen: boolean;
submenuRef: React.RefObject<HTMLDivElement | null>;
triggerRef: React.RefObject<HTMLButtonElement | null>;
}) {
const [submenuPosition, setSubmenuPosition] = useState<SubmenuPosition>({
left: 0,
maxHeight: DROPDOWN_ESTIMATED_HEIGHT,
origin: "right",
top: 0,
});
const updateSubmenuPosition = useCallback(() => {
const trigger = triggerRef.current;
const submenu = submenuRef.current;
if (!trigger) return;
const panelHeight = submenu?.offsetHeight ?? DROPDOWN_ESTIMATED_HEIGHT;
const panelWidth = submenu?.offsetWidth ?? DROPDOWN_PANEL_WIDTH;
setSubmenuPosition(
getSubmenuPosition(
trigger.getBoundingClientRect(),
panelHeight,
panelWidth,
isCompact
)
);
}, [isCompact, submenuRef, triggerRef]);
useLayoutEffect(() => {
if (!isOpen || isCompact) return;
updateSubmenuPosition();
const submenu = submenuRef.current;
let observer: ResizeObserver | undefined;
if (submenu) {
observer = new ResizeObserver(updateSubmenuPosition);
observer.observe(submenu);
}
window.addEventListener("resize", updateSubmenuPosition);
window.addEventListener("scroll", updateSubmenuPosition, true);
return () => {
observer?.disconnect();
window.removeEventListener("resize", updateSubmenuPosition);
window.removeEventListener("scroll", updateSubmenuPosition, true);
};
}, [isCompact, isOpen, submenuRef, updateSubmenuPosition]);
return { submenuPosition, updateSubmenuPosition };
}
function SubmenuInlinePanel({
isOpen,
submenuOptions,
}: {
isOpen: boolean;
submenuOptions: ReactNode;
}) {
if (!isOpen) return null;
return <div className="px-1 pb-1">{submenuOptions}</div>;
}
function SubmenuFlyoutPanel({
flyoutSubmenuMotion,
groupLabel,
isOpen,
itemId,
onOpenChange,
prefersHover,
setActiveItemId,
submenuHighlightLayoutId,
submenuId,
submenuOptions,
submenuPosition,
submenuRef,
triggerRef,
}: {
flyoutSubmenuMotion: ReturnType<typeof getFlyoutSubmenuMotion>;
groupLabel: string;
isOpen: boolean;
itemId: string;
onOpenChange: (open: boolean) => void;
prefersHover: boolean;
setActiveItemId: (itemId: string | null) => void;
submenuHighlightLayoutId: string;
submenuId: string;
submenuOptions: ReactNode;
submenuPosition: SubmenuPosition;
submenuRef: React.RefObject<HTMLDivElement | null>;
triggerRef: React.RefObject<HTMLButtonElement | null>;
}) {
return (
<AnimatePresence>
{isOpen ? (
<motion.div
animate={flyoutSubmenuMotion.animate}
aria-label={groupLabel}
className={dropdownPanelClassName}
data-prompt-dropdown-panel=""
exit={flyoutSubmenuMotion.exit}
initial={flyoutSubmenuMotion.initial}
key={submenuId}
onMouseEnter={() => setActiveItemId(itemId)}
onMouseLeave={
prefersHover
? (event) => {
const related = event.relatedTarget as Node | null;
if (triggerRef.current?.contains(related)) return;
if (isInsideDropdownPanel(related)) return;
onOpenChange(false);
}
: undefined
}
ref={submenuRef}
role="menu"
style={{
left: submenuPosition.left,
maxHeight: submenuPosition.maxHeight,
top: submenuPosition.top,
transformOrigin:
submenuPosition.origin === "right" ? "left top" : "right top",
zIndex: 401,
}}
transition={flyoutSubmenuMotion.transition}
>
<LayoutGroup id={submenuHighlightLayoutId}>
<div className="space-y-0.5">{submenuOptions}</div>
</LayoutGroup>
</motion.div>
) : null}
</AnimatePresence>
);
}
function DropdownSubmenu({
group,
selectedValue,
triggerLabel,
valueLabel,
icon,
submenuId,
isOpen,
onOpenChange,
onSelect,
activeItemId,
highlightLayoutId,
submenuHighlightLayoutId,
setActiveItemId,
}: {
group: PromptSettingGroup;
selectedValue: string;
triggerLabel: string;
valueLabel?: string;
icon?: ReactNode;
submenuId: string;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (value: string) => void;
activeItemId: string | null;
highlightLayoutId: string;
submenuHighlightLayoutId: string;
setActiveItemId: (itemId: string | null) => void;
}) {
const itemId = useId();
const triggerRef = useRef<HTMLButtonElement>(null);
const submenuRef = useRef<HTMLDivElement>(null);
const isCompact = useCompactViewport();
const prefersHover = usePrefersHover();
const mounted = useIsMounted();
const { submenuPosition, updateSubmenuPosition } = useSubmenuPosition({
isCompact,
isOpen,
submenuRef,
triggerRef,
});
const isActive = activeItemId === itemId || isOpen;
const flyoutSubmenuMotion = getFlyoutSubmenuMotion(submenuPosition.origin);
const wasCompactRef = useRef<boolean | null>(null);
useEffect(() => {
const previousCompact = wasCompactRef.current;
wasCompactRef.current = isCompact;
if (previousCompact === false && isCompact && isOpen) {
onOpenChange(false);
}
}, [isCompact, isOpen, onOpenChange]);
const openSubmenu = useCallback(() => {
if (isCompact) return;
updateSubmenuPosition();
onOpenChange(true);
}, [isCompact, onOpenChange, updateSubmenuPosition]);
const toggleSubmenu = useCallback(() => {
setActiveItemId(itemId);
onOpenChange(!isOpen);
}, [isOpen, itemId, onOpenChange, setActiveItemId]);
const handleTriggerMouseLeave = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
const related = event.relatedTarget as Node | null;
if (submenuRef.current?.contains(related)) return;
if (isInsideDropdownPanel(related)) return;
if (isOpen) onOpenChange(false);
},
[isOpen, onOpenChange]
);
const handleOptionSelect = useCallback(
(value: string) => {
flushSync(() => {
onOpenChange(false);
});
onSelect(value);
},
[onOpenChange, onSelect]
);
const submenuOptions = (
<div className="space-y-0.5">
{group.options.map((option) => (
<DropdownOption
activeItemId={activeItemId}
highlightLayoutId={submenuHighlightLayoutId}
key={option.value}
label={option.label}
onSelect={() => handleOptionSelect(option.value)}
selected={selectedValue === option.value}
setActiveItemId={setActiveItemId}
/>
))}
</div>
);
return (
<>
<button
aria-expanded={isOpen}
aria-haspopup="menu"
className={`${dropdownSubmenuTriggerClassName} touch-manipulation text-foreground`}
onClick={toggleSubmenu}
onKeyDown={(event) => activateOnEnterOrSpace(event, toggleSubmenu)}
onMouseDown={preventFocusSteal}
onMouseEnter={
prefersHover
? () => {
setActiveItemId(itemId);
openSubmenu();
}
: undefined
}
onMouseLeave={prefersHover ? handleTriggerMouseLeave : undefined}
onPointerMove={prefersHover ? () => setActiveItemId(itemId) : undefined}
ref={triggerRef}
tabIndex={-1}
type="button"
>
{isActive ? (
<motion.span
className={dropdownOptionHighlightClassName}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
layoutId={highlightLayoutId}
transition={FAST_TRANSITION}
/>
) : null}
<span
className="relative z-10 flex min-w-0 flex-1 items-center gap-2.5 truncate"
>
{icon ? (
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
{icon}
</span>
) : null}
<span className="truncate">{triggerLabel}</span>
</span>
<span className="relative z-10 flex shrink-0 items-center gap-1.5">
{valueLabel ? (
<span className="max-w-[5.5rem] truncate text-muted-foreground sm:max-w-none">
{valueLabel}
</span>
) : null}
<ChevronRight
aria-hidden="true"
className={`size-4 text-muted-foreground transition-transform ${isCompact && isOpen ? "rotate-90" : ""}`}
/>
</span>
</button>
{isCompact ? (
<SubmenuInlinePanel isOpen={isOpen} submenuOptions={submenuOptions} />
) : null}
{!isCompact && mounted
? createPortal(
<SubmenuFlyoutPanel
flyoutSubmenuMotion={flyoutSubmenuMotion}
groupLabel={group.label}
isOpen={isOpen}
itemId={itemId}
onOpenChange={onOpenChange}
prefersHover={prefersHover}
setActiveItemId={setActiveItemId}
submenuHighlightLayoutId={submenuHighlightLayoutId}
submenuId={submenuId}
submenuOptions={submenuOptions}
submenuPosition={submenuPosition}
submenuRef={submenuRef}
triggerRef={triggerRef}
/>,
document.body
)
: null}
</>
);
}
type DropdownSide = "top" | "bottom";
type DropdownPosition = {
left: number;
side: DropdownSide;
top?: number;
bottom?: number;
maxHeight: number;
};
function getDropdownPosition({
align = "start",
panelHeight,
panelWidth,
triggerRect,
}: {
align?: "end" | "start";
panelHeight: number;
panelWidth: number;
triggerRect: DOMRect;
}): DropdownPosition {
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
const effectiveWidth = Math.min(panelWidth, getPanelWidth(viewportWidth));
const spaceBelow =
viewportHeight -
triggerRect.bottom -
DROPDOWN_SIDE_OFFSET -
DROPDOWN_VIEWPORT_MARGIN;
const spaceAbove =
triggerRect.top - DROPDOWN_SIDE_OFFSET - DROPDOWN_VIEWPORT_MARGIN;
const openBelow =
spaceBelow >= panelHeight
? true
: spaceAbove >= panelHeight
? false
: spaceBelow >= spaceAbove;
const desiredLeft =
align === "end" ? triggerRect.right - effectiveWidth : triggerRect.left;
const left = clamp(
desiredLeft,
DROPDOWN_VIEWPORT_MARGIN,
viewportWidth - effectiveWidth - DROPDOWN_VIEWPORT_MARGIN
);
const maxHeight = Math.max(
DROPDOWN_MIN_PANEL_HEIGHT,
openBelow ? spaceBelow : spaceAbove
);
if (openBelow) {
return {
left,
maxHeight,
side: "bottom",
top: triggerRect.bottom + DROPDOWN_SIDE_OFFSET,
};
}
return {
left,
maxHeight,
side: "top",
bottom: viewportHeight - triggerRect.top + DROPDOWN_SIDE_OFFSET,
};
}
function SettingsDropdown({
groups,
values,
menuActions = [],
onOpenChange,
onValueChange,
}: {
groups: PromptSettingGroup[];
values: Record<string, string>;
menuActions?: PromptMenuAction[];
onOpenChange: (open: boolean) => void;
onValueChange: (groupId: string, value: string) => void;
}) {
const [open, setOpen] = useState(false);
const [openSubmenuId, setOpenSubmenuId] = useState<string | null>(null);
const [activeItemId, setActiveItemId] = useState<string | null>(null);
const highlightLayoutId = useId();
const [position, setPosition] = useState<DropdownPosition>({
left: 0,
maxHeight: DROPDOWN_ESTIMATED_HEIGHT,
side: "bottom",
top: 0,
});
const triggerRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const menuLayoutRef = useRef<HTMLDivElement>(null);
const prefersHover = usePrefersHover();
const prefersReducedMotion = usePrefersReducedMotion();
const isCompact = useCompactViewport();
const mounted = useIsMounted();
const [instantDismiss, setInstantDismiss] = useState(false);
const settingsPanelMotion = getPanelMotion({
instantDismiss,
offsetY: position.side === "bottom" ? -8 : 8,
openingEase: SETTINGS_PANEL_SPRING,
reduceMotion: prefersReducedMotion,
});
const handleActiveItemChange = useCallback((itemId: string | null) => {
setActiveItemId(itemId);
}, []);
const handleOpenChange = useCallback(
(nextOpen: boolean) => {
setOpen(nextOpen);
onOpenChange(nextOpen);
if (!nextOpen) {
setActiveItemId(null);
setOpenSubmenuId(null);
}
},
[onOpenChange]
);
const dismissDropdown = useCallback(
(instant = false) => {
if (instant) {
setInstantDismiss(true);
}
handleOpenChange(false);
},
[handleOpenChange]
);
useEffect(() => {
if (open) {
setInstantDismiss(false);
}
}, [open]);
const handleSubmenuSelect = useCallback(
(groupId: string, value: string) => {
onValueChange(groupId, value);
setOpenSubmenuId(null);
if (!isCompact) {
dismissDropdown(true);
}
},
[dismissDropdown, isCompact, onValueChange]
);
const updatePosition = useCallback(() => {
const trigger = triggerRef.current;
if (!trigger) return;
const triggerRect = trigger.getBoundingClientRect();
const panel = panelRef.current;
const panelHeight = panel?.offsetHeight ?? DROPDOWN_ESTIMATED_HEIGHT;
const panelWidth = panel?.offsetWidth ?? DROPDOWN_PANEL_WIDTH;
setPosition(
getDropdownPosition({
panelHeight,
panelWidth,
triggerRect,
})
);
}, []);
useLayoutEffect(() => {
if (!open) return;
updatePosition();
const panel = panelRef.current;
let observer: ResizeObserver | undefined;
if (panel) {
observer = new ResizeObserver(() => {
updatePosition();
});
observer.observe(panel);
}
window.addEventListener("resize", updatePosition);
window.addEventListener("scroll", updatePosition, true);
return () => {
observer?.disconnect();
window.removeEventListener("resize", updatePosition);
window.removeEventListener("scroll", updatePosition, true);
};
}, [open, updatePosition]);
const handleEscape = useCallback(() => {
if (openSubmenuId) {
setOpenSubmenuId(null);
return true;
}
return false;
}, [openSubmenuId]);
useDropdownDismiss({
onClose: () => handleOpenChange(false),
onEscape: handleEscape,
open,
triggerRef,
});
const toggleOpen = useCallback(() => {
if (open) {
handleOpenChange(false);
return;
}
updatePosition();
handleOpenChange(true);
}, [handleOpenChange, open, updatePosition]);
const selectedLabels = groups.map((group) =>
getOptionLabel(group, values[group.id] ?? "")
);
const triggerLabel = selectedLabels.join(", ");
const effortGroupId = getEffortGroupId(groups);
const effortLevel = normalizeEffortLevel(
effortGroupId ? (values[effortGroupId] ?? "medium") : "medium"
);
return (
<>
<button
aria-expanded={open}
aria-haspopup="dialog"
aria-label={`Select settings: ${triggerLabel}`}
className="flex min-h-10 min-w-0 max-w-[calc(100%-3rem)] cursor-pointer items-center gap-1.5 rounded-full py-1.5 pl-0.5 pr-1.5 text-[15px] leading-none transition-[color,transform] duration-150 ease-out hover:text-foreground active:scale-[0.96]"
onClick={toggleOpen}
onMouseDown={(event) => event.preventDefault()}
ref={triggerRef}
type="button"
>
<span className="shrink-0 text-muted-foreground">
{effortGroupId ? <EffortBarsIcon level={effortLevel} /> : null}
</span>
{groups.map((group, index) => {
const label = getOptionLabel(group, values[group.id] ?? "");
return (
<span
className={`truncate font-medium leading-none ${index === 0 ? "text-foreground" : "text-muted-foreground"}`}
key={group.id}
>
{label}
</span>
);
})}
<ChevronDown
aria-hidden="true"
className={`size-4 shrink-0 text-muted-foreground transition-transform duration-200 ease-out ${open ? "rotate-180 text-foreground" : ""}`}
/>
</button>
{mounted
? createPortal(
<AnimatePresence>
{open ? (
<motion.div
animate={settingsPanelMotion.animate}
aria-label="Prompt settings"
className={settingsDropdownPanelClassName}
data-prompt-dropdown-panel=""
exit={settingsPanelMotion.exit}
initial={settingsPanelMotion.initial}
key="prompt-settings-dropdown"
ref={panelRef}
role="menu"
style={{
left: position.left,
maxHeight: position.maxHeight,
transformOrigin:
position.side === "bottom" ? "top left" : "bottom left",
...(position.side === "bottom"
? { top: position.top }
: { bottom: position.bottom }),
}}
transition={settingsPanelMotion.transition}
>
<LayoutGroup id={highlightLayoutId}>
<div
className="space-y-0"
onMouseLeave={
prefersHover
? (event) => {
const related = event.relatedTarget as Node | null;
if (
related instanceof Element &&
related.closest("[data-prompt-dropdown-panel]")
) {
return;
}
setActiveItemId(null);
setOpenSubmenuId(null);
}
: undefined
}
ref={menuLayoutRef}
>
{menuActions.map((action, index) => (
<DropdownActionItem
activeItemId={activeItemId}
highlightLayoutId={`${highlightLayoutId}-action-${index}`}
icon={action.icon}
key={`${action.label}-${index}`}
label={action.label}
onSelect={() => {
action.onSelect();
handleOpenChange(false);
}}
setActiveItemId={handleActiveItemChange}
/>
))}
{menuActions.length > 0 && groups.length > 0 ? (
<DropdownMenuDivider />
) : null}
{groups
.filter((group) => group.display === "featured")
.map((group) => (
<Fragment key={group.id}>
{group.options.map((option, optionIndex) => (
<DropdownOption
activeItemId={activeItemId}
compact
highlightLayoutId={`${highlightLayoutId}-${group.id}`}
icon={option.icon}
key={option.value}
label={option.label}
onSelect={() => {
onValueChange(group.id, option.value);
handleOpenChange(false);
}}
reduceMotion={prefersReducedMotion}
reveal={open}
selected={values[group.id] === option.value}
setActiveItemId={handleActiveItemChange}
staggerIndex={optionIndex}
/>
))}
</Fragment>
))}
{groups
.filter((group) => group.display === "submenu")
.map((group) => (
<Fragment key={group.id}>
<DropdownMenuDivider />
<DropdownSubmenu
activeItemId={activeItemId}
group={group}
highlightLayoutId={highlightLayoutId}
isOpen={openSubmenuId === group.id}
onOpenChange={(nextOpen) => {
setOpenSubmenuId(nextOpen ? group.id : null);
}}
onSelect={(value) => {
handleSubmenuSelect(group.id, value);
}}
selectedValue={values[group.id] ?? ""}
setActiveItemId={handleActiveItemChange}
submenuHighlightLayoutId={`${highlightLayoutId}-${group.id}-sub`}
submenuId={group.id}
triggerLabel={group.label}
valueLabel={getOptionLabel(
group,
values[group.id] ?? ""
)}
/>
</Fragment>
))}
</div>
</LayoutGroup>
</motion.div>
) : null}
</AnimatePresence>,
document.body
)
: null}
</>
);
}
function PlusMenuDropdown({
items,
onOpenChange,
}: {
items: PromptPlusMenuItem[];
onOpenChange: (open: boolean) => void;
}) {
const [open, setOpen] = useState(false);
const [openSubmenuId, setOpenSubmenuId] = useState<string | null>(null);
const [activeItemId, setActiveItemId] = useState<string | null>(null);
const highlightLayoutId = useId();
const prefersHover = usePrefersHover();
const [position, setPosition] = useState<DropdownPosition>({
left: 0,
maxHeight: DROPDOWN_ESTIMATED_HEIGHT,
side: "top",
top: 0,
});
const triggerRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const menuLayoutRef = useRef<HTMLDivElement>(null);
const isCompact = useCompactViewport();
const mounted = useIsMounted();
const [instantDismiss, setInstantDismiss] = useState(false);
const plusPanelMotion = getPanelMotion({
instantDismiss,
offsetY: position.side === "bottom" ? -10 : 10,
});
const handleActiveItemChange = useCallback((itemId: string | null) => {
setActiveItemId(itemId);
}, []);
const handleOpenChange = useCallback(
(nextOpen: boolean) => {
setOpen(nextOpen);
onOpenChange(nextOpen);
if (!nextOpen) {
setActiveItemId(null);
setOpenSubmenuId(null);
}
},
[onOpenChange]
);
const dismissDropdown = useCallback(
(instant = false) => {
if (instant) {
setInstantDismiss(true);
}
handleOpenChange(false);
},
[handleOpenChange]
);
useEffect(() => {
if (open) {
setInstantDismiss(false);
}
}, [open]);
const updatePosition = useCallback(() => {
const trigger = triggerRef.current;
if (!trigger) return;
const triggerRect = trigger.getBoundingClientRect();
const panel = panelRef.current;
const panelHeight = panel?.offsetHeight ?? DROPDOWN_ESTIMATED_HEIGHT;
const panelWidth = panel?.offsetWidth ?? DROPDOWN_PANEL_WIDTH;
setPosition(
getDropdownPosition({
align: "end",
panelHeight,
panelWidth,
triggerRect,
})
);
}, []);
useLayoutEffect(() => {
if (!open) return;
updatePosition();
const panel = panelRef.current;
let observer: ResizeObserver | undefined;
if (panel) {
observer = new ResizeObserver(updatePosition);
observer.observe(panel);
}
window.addEventListener("resize", updatePosition);
window.addEventListener("scroll", updatePosition, true);
return () => {
observer?.disconnect();
window.removeEventListener("resize", updatePosition);
window.removeEventListener("scroll", updatePosition, true);
};
}, [open, updatePosition]);
const handleEscape = useCallback(() => {
if (openSubmenuId) {
setOpenSubmenuId(null);
return true;
}
return false;
}, [openSubmenuId]);
useDropdownDismiss({
onClose: () => handleOpenChange(false),
onEscape: handleEscape,
open,
triggerRef,
});
const toggleOpen = useCallback(() => {
if (open) {
handleOpenChange(false);
return;
}
updatePosition();
handleOpenChange(true);
}, [handleOpenChange, open, updatePosition]);
return (
<>
<button
aria-expanded={open}
aria-haspopup="menu"
aria-label="Add attachment"
className={`flex size-8 items-center justify-center rounded-full transition-colors ${open ? "text-foreground" : "text-muted-foreground hover:text-foreground"}`}
onClick={toggleOpen}
onMouseDown={(event) => event.preventDefault()}
ref={triggerRef}
type="button"
>
<PlusIcon />
</button>
{mounted
? createPortal(
<AnimatePresence>
{open ? (
<motion.div
animate={plusPanelMotion.animate}
aria-label="Add options"
className={dropdownPanelClassName}
data-prompt-dropdown-panel=""
exit={plusPanelMotion.exit}
initial={plusPanelMotion.initial}
key="prompt-plus-dropdown"
ref={panelRef}
role="menu"
style={{
left: position.left,
maxHeight: position.maxHeight,
transformOrigin:
position.side === "bottom" ? "top right" : "bottom right",
...(position.side === "bottom"
? { top: position.top }
: { bottom: position.bottom }),
}}
transition={plusPanelMotion.transition}
>
<LayoutGroup id={highlightLayoutId}>
<div
className="space-y-0.5"
onMouseLeave={
prefersHover
? (event) => {
const related = event.relatedTarget as Node | null;
if (
related instanceof Element &&
related.closest("[data-prompt-dropdown-panel]")
) {
return;
}
setActiveItemId(null);
setOpenSubmenuId(null);
}
: undefined
}
ref={menuLayoutRef}
>
{items.map((item) => {
if (item.options?.length) {
const group: PromptSettingGroup = {
id: item.id,
label: item.label,
display: "submenu",
options: item.options.map((option) => ({
value: option.value,
label: option.label,
})),
};
return (
<DropdownSubmenu
activeItemId={activeItemId}
group={group}
highlightLayoutId={highlightLayoutId}
icon={item.icon}
isOpen={openSubmenuId === item.id}
key={item.id}
onOpenChange={(nextOpen) => {
setOpenSubmenuId(nextOpen ? item.id : null);
}}
onSelect={(value) => {
item.onOptionSelect?.(value);
setOpenSubmenuId(null);
if (!isCompact) {
dismissDropdown(true);
}
}}
selectedValue=""
setActiveItemId={handleActiveItemChange}
submenuHighlightLayoutId={`${highlightLayoutId}-${item.id}-sub`}
submenuId={item.id}
triggerLabel={item.label}
/>
);
}
return (
<DropdownActionItem
activeItemId={activeItemId}
highlightLayoutId={`${highlightLayoutId}-plus-${item.id}`}
icon={item.icon}
key={item.id}
label={item.label}
onSelect={() => {
item.onSelect?.();
handleOpenChange(false);
}}
setActiveItemId={handleActiveItemChange}
shortcut={item.shortcut}
/>
);
})}
</div>
</LayoutGroup>
</motion.div>
) : null}
</AnimatePresence>,
document.body
)
: null}
</>
);
}
function PlusIcon() {
return (
<svg
aria-hidden="true"
fill="none"
height="14"
viewBox="0 0 14 14"
width="14"
>
<path
d="M7 2.5V11.5M2.5 7H11.5"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.5"
/>
</svg>
);
}
export type PromptInputProps = {
onSubmit?: (value: string) => void;
placeholder?: string;
menuActions?: PromptMenuAction[];
plusMenuItems?: PromptPlusMenuItem[];
settingGroups?: PromptSettingGroup[];
settings?: Record<string, string>;
defaultSettings?: Record<string, string>;
onSettingsChange?: (settings: Record<string, string>) => void;
};
export function PromptInput({
onSubmit,
placeholder,
menuActions = [],
plusMenuItems = [],
settingGroups = [],
settings: settingsProp,
defaultSettings,
onSettingsChange,
}: PromptInputProps) {
const [expanded, setExpanded] = useState(false);
const [value, setValue] = useState("");
const valueRef = useRef(value);
valueRef.current = value;
const [internalSettings, setInternalSettings] = useState(() =>
getDefaultSettings(settingGroups, defaultSettings)
);
const isSettingsControlled = settingsProp !== undefined;
const settings = isSettingsControlled ? settingsProp : internalSettings;
const [settingsOpen, setSettingsOpen] = useState(false);
const [plusOpen, setPlusOpen] = useState(false);
const settingsOpenRef = useRef(settingsOpen);
const plusOpenRef = useRef(plusOpen);
settingsOpenRef.current = settingsOpen;
plusOpenRef.current = plusOpen;
const [expandedHeight, setExpandedHeight] = useState(MIN_EXPANDED_HEIGHT);
const [collapsedHover, setCollapsedHover] = useState(false);
const [collapsedHoverReady, setCollapsedHoverReady] = useState(!expanded);
const hasValue = value.trim() !== "";
const inputRef = useRef<HTMLElement | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const showSettings = settingGroups.length > 0 || menuActions.length > 0;
const prefersHover = usePrefersHover();
const prefersReducedMotion = usePrefersReducedMotion();
const shellTransition = prefersReducedMotion
? { duration: 0.2, ease: EASE_OUT_QUAD }
: SHELL_SPRING;
const selectedModelIcon = useMemo(
() => getSelectedSettingIcon(settingGroups, settings),
[settingGroups, settings]
);
useEffect(() => {
if (expanded) {
setCollapsedHover(false);
setCollapsedHoverReady(false);
return;
}
setCollapsedHover(false);
const timeout = window.setTimeout(() => {
setCollapsedHoverReady(true);
}, SHELL_COLLAPSE_SETTLE_MS);
return () => window.clearTimeout(timeout);
}, [expanded]);
const updateSettings = useCallback(
(groupId: string, nextValue: string) => {
const nextSettings = { ...settings, [groupId]: nextValue };
if (!isSettingsControlled) {
setInternalSettings(nextSettings);
}
onSettingsChange?.(nextSettings);
},
[isSettingsControlled, onSettingsChange, settings]
);
useEffect(() => {
if (isSettingsControlled) return;
setInternalSettings((previous) => {
const defaults = getDefaultSettings(settingGroups, defaultSettings);
const next = { ...defaults };
for (const group of settingGroups) {
const previousValue = previous[group.id];
const isValid = group.options.some(
(option) => option.value === previousValue
);
if (isValid && previousValue) {
next[group.id] = previousValue;
}
}
return next;
});
}, [defaultSettings, isSettingsControlled, settingGroups]);
const syncExpandedHeight = useCallback(() => {
const element = inputRef.current;
if (!(element instanceof HTMLTextAreaElement)) return;
const panelHeight = element.getBoundingClientRect().height;
const nextHeight = Math.min(
MAX_EXPANDED_HEIGHT,
Math.max(MIN_EXPANDED_HEIGHT, Math.ceil(panelHeight) + FOOTER_HEIGHT)
);
setExpandedHeight(nextHeight);
}, []);
useLayoutEffect(() => {
if (!expanded) {
setExpandedHeight(MIN_EXPANDED_HEIGHT);
return;
}
const element = inputRef.current;
if (!(element instanceof HTMLTextAreaElement)) return;
const observer = new ResizeObserver(() => {
syncExpandedHeight();
});
observer.observe(element);
syncExpandedHeight();
return () => {
observer.disconnect();
};
}, [expanded, syncExpandedHeight]);
const handleValueChange = useCallback((nextValue: string) => {
setValue(nextValue);
}, []);
const expand = () => {
setExpanded(true);
requestAnimationFrame(() => {
inputRef.current?.focus();
});
};
const maybeCollapse = useCallback(() => {
requestAnimationFrame(() => {
if (settingsOpenRef.current || plusOpenRef.current) return;
if (valueRef.current.trim() !== "") return;
const active = document.activeElement;
if (active instanceof Element) {
if (isInsideDropdownPanel(active)) return;
if (containerRef.current?.contains(active)) return;
}
setExpanded(false);
});
}, []);
const handleBlur = useCallback(
(event: React.FocusEvent<HTMLDivElement>) => {
const related = event.relatedTarget as Node | null;
if (related instanceof Element) {
if (isInsideDropdownPanel(related)) return;
if (containerRef.current?.contains(related)) return;
}
maybeCollapse();
},
[maybeCollapse]
);
const handleSubmit = useCallback(() => {
if (settingsOpenRef.current || plusOpenRef.current) return;
if (value.trim() === "") return;
onSubmit?.(value);
setValue("");
setExpanded(false);
}, [onSubmit, value]);
const handleTextareaKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
handleSubmit();
return;
}
if (event.key === "Escape") {
if (settingsOpenRef.current || plusOpenRef.current) return;
if (value.trim() === "") {
setExpanded(false);
}
}
},
[handleSubmit, value]
);
useEffect(() => {
if (!expanded) {
setSettingsOpen(false);
setPlusOpen(false);
}
}, [expanded]);
return (
<motion.div
animate={{ maxWidth: expanded ? 440 : 320 }}
className="relative mx-auto w-full"
initial={false}
transition={shellTransition}
>
<motion.div
animate={{ height: expanded ? expandedHeight : COLLAPSED_HEIGHT }}
className="relative overflow-hidden rounded-[24px] border-[0.5px] border-border bg-background p-0.5"
data-prompt-input-root=""
initial={false}
onBlur={handleBlur}
ref={containerRef}
transition={shellTransition}
>
<div className="relative h-full w-full overflow-hidden rounded-[22px] bg-accent">
{expanded ? (
<InputPrimitive
aria-label="Prompt"
className="relative w-full"
key="textarea"
onValueChange={handleValueChange}
placeholder={placeholder}
ref={inputRef}
render={(props) => (
<textarea
{...props}
className={`${props.className ?? ""} ${promptFieldClassName} block w-full resize-none px-6 pt-5 outline-none overflow-y-auto overscroll-contain`}
onKeyDown={(event) => {
props.onKeyDown?.(event);
handleTextareaKeyDown(event);
}}
rows={1}
style={{ maxHeight: MAX_TEXTAREA_HEIGHT }}
/>
)}
value={value}
/>
) : (
<div
className="group/collapsed absolute inset-0 flex items-center overflow-hidden px-6"
key="placeholder"
onMouseEnter={
prefersHover ? () => setCollapsedHover(true) : undefined
}
onMouseLeave={
prefersHover ? () => setCollapsedHover(false) : undefined
}
>
<InputPrimitive
aria-label="Open prompt input"
className={`${promptFieldCollapsedClassName} ${promptFieldCollapsedRowClassName} ${selectedModelIcon && collapsedHoverReady ? "pr-8" : ""}`}
onMouseDown={(event) => {
event.preventDefault();
expand();
}}
placeholder={placeholder}
readOnly
/>
{selectedModelIcon && collapsedHoverReady ? (
<div
aria-hidden="true"
className="pointer-events-none absolute top-1/2 right-6 flex size-4 -translate-y-1/2 items-center justify-center"
>
<CollapsedSelectedModelIcon
icon={selectedModelIcon}
reduceMotion={prefersReducedMotion}
visible={prefersHover && collapsedHover}
/>
</div>
) : null}
</div>
)}
<AnimatePresence>
{expanded ? (
<motion.div
animate={{ opacity: 1 }}
className="absolute inset-x-0 bottom-0 flex items-center pl-6 pr-2.5"
exit={{ opacity: 0, transition: { duration: 0.16, ease: EASE_OUT_QUAD } }}
initial={{ opacity: 0 }}
key="footer"
style={{ height: FOOTER_HEIGHT }}
transition={{ duration: 0.2, delay: 0.08, ease: EASE_OUT_QUAD }}
>
{showSettings ? (
<SettingsDropdown
groups={settingGroups}
menuActions={menuActions}
onOpenChange={(nextOpen) => {
setSettingsOpen(nextOpen);
if (nextOpen) {
setPlusOpen(false);
return;
}
maybeCollapse();
}}
onValueChange={updateSettings}
values={settings}
/>
) : null}
<div className="ml-auto flex items-center gap-0.5">
{plusMenuItems.length > 0 ? (
<PlusMenuDropdown
items={plusMenuItems}
onOpenChange={(nextOpen) => {
setPlusOpen(nextOpen);
if (nextOpen) {
setSettingsOpen(false);
return;
}
maybeCollapse();
}}
/>
) : null}
<motion.button
animate={{ opacity: 1, scale: 1 }}
aria-label={hasValue ? "Send prompt" : "Use voice input"}
className="flex size-8 cursor-pointer items-center justify-center rounded-full bg-foreground text-background shadow-xs transition-opacity hover:opacity-90"
exit={{ opacity: 0, scale: 0.85 }}
initial={{ opacity: 0, scale: 0.85 }}
onClick={handleSubmit}
transition={{ duration: 0.15, ease: EASE_OUT_QUAD }}
type="button"
>
<AnimatePresence initial={false} mode="wait">
{hasValue ? (
<motion.span
animate={{ opacity: 1, scale: 1 }}
className="flex items-center justify-center"
exit={{ opacity: 0, scale: 0.5 }}
initial={{ opacity: 0, scale: 0.5 }}
key="arrow"
transition={{ duration: 0.15, ease: EASE_OUT_QUAD }}
>
<ArrowUp className="size-4" strokeWidth={2.25} />
</motion.span>
) : (
<motion.span
animate={{ opacity: 1, scale: 1 }}
className="flex items-center justify-center"
exit={{ opacity: 0, scale: 0.5 }}
initial={{ opacity: 0, scale: 0.5 }}
key="mic"
transition={{ duration: 0.15, ease: EASE_OUT_QUAD }}
>
<Mic className="size-4" strokeWidth={2.25} />
</motion.span>
)}
</AnimatePresence>
</motion.button>
</div>
</motion.div>
) : null}
</AnimatePresence>
</div>
</motion.div>
</motion.div>
);
}
const MODEL_PLACEHOLDERS: Record<string, string> = {
"fable-5": "Send to Claude",
"composer-2.5": "Send to Cursor",
"gpt-5.5": "Send to Codex",
};
const defaultSettingGroups: PromptSettingGroup[] = [
{
id: "model",
label: "Model",
display: "featured",
options: [
{
value: "fable-5",
label: "Fable 5",
icon: <ClaudeAI className="size-4 shrink-0" />,
},
{
value: "composer-2.5",
label: "Composer 2.5",
icon: <Cursor className="size-4 shrink-0" />,
},
{
value: "gpt-5.5",
label: "GPT 5.5",
icon: <OpenAI className="size-4 shrink-0" />,
},
],
},
];
const defaultPromptSettings = { model: "fable-5" };
export default function PromptBox() {
const [settings, setSettings] =
useState<Record<string, string>>(defaultPromptSettings);
const placeholder =
MODEL_PLACEHOLDERS[settings.model ?? ""] ?? "Ask Anything";
return (
<div className="flex h-full w-full items-center justify-center px-4">
<PromptInput
defaultSettings={defaultPromptSettings}
onSettingsChange={setSettings}
placeholder={placeholder}
settingGroups={defaultSettingGroups}
/>
</div>
);
}
registry/default/example/prompt-box-icons.tsx
import type { SVGProps } from "react";
export const ClaudeAI = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} preserveAspectRatio="xMidYMid" viewBox="0 0 256 257">
<path
fill="currentColor"
d="m50.228 170.321 50.357-28.257.843-2.463-.843-1.361h-2.462l-8.426-.518-28.775-.778-24.952-1.037-24.175-1.296-6.092-1.297L0 125.796l.583-3.759 5.12-3.434 7.324.648 16.202 1.101 24.304 1.685 17.629 1.037 26.118 2.722h4.148l.583-1.685-1.426-1.037-1.101-1.037-25.147-17.045-27.22-18.017-14.258-10.37-7.713-5.25-3.888-4.925-1.685-10.758 7-7.713 9.397.649 2.398.648 9.527 7.323 20.35 15.75L94.817 91.9l3.889 3.24 1.555-1.102.195-.777-1.75-2.917-14.453-26.118-15.425-26.572-6.87-11.018-1.814-6.61c-.648-2.723-1.102-4.991-1.102-7.778l7.972-10.823L71.42 0 82.05 1.426l4.472 3.888 6.61 15.101 10.694 23.786 16.591 32.34 4.861 9.592 2.592 8.879.973 2.722h1.685v-1.556l1.36-18.211 2.528-22.36 2.463-28.776.843-8.1 4.018-9.722 7.971-5.25 6.222 2.981 5.12 7.324-.713 4.73-3.046 19.768-5.962 30.98-3.889 20.739h2.268l2.593-2.593 10.499-13.934 17.628-22.036 7.778-8.749 9.073-9.657 5.833-4.601h11.018l8.1 12.055-3.628 12.443-11.342 14.388-9.398 12.184-13.48 18.147-8.426 14.518.778 1.166 2.01-.194 30.46-6.481 16.462-2.982 19.637-3.37 8.88 4.148.971 4.213-3.5 8.62-20.998 5.184-24.628 4.926-36.682 8.685-.454.324.519.648 16.526 1.555 7.065.389h17.304l32.21 2.398 8.426 5.574 5.055 6.805-.843 5.184-12.962 6.611-17.498-4.148-40.83-9.721-14-3.5h-1.944v1.167l11.666 11.406 21.387 19.314 26.767 24.887 1.36 6.157-3.434 4.86-3.63-.518-23.526-17.693-9.073-7.972-20.545-17.304h-1.36v1.814l4.73 6.935 25.017 37.59 1.296 11.536-1.814 3.76-6.481 2.268-7.13-1.297-14.647-20.544-15.1-23.138-12.185-20.739-1.49.843-7.194 77.448-3.37 3.953-7.778 2.981-6.48-4.925-3.436-7.972 3.435-15.749 4.148-20.544 3.37-16.333 3.046-20.285 1.815-6.74-.13-.454-1.49.194-15.295 20.999-23.267 31.433-18.406 19.702-4.407 1.75-7.648-3.954.713-7.064 4.277-6.286 25.47-32.405 15.36-20.092 9.917-11.6-.065-1.686h-.583L44.07 198.125l-12.055 1.555-5.185-4.86.648-7.972 2.463-2.593 20.35-13.999-.064.065Z"
/>
</svg>
);
export const Cursor = (props: SVGProps<SVGSVGElement>) => (
<svg
{...props}
preserveAspectRatio="xMidYMid"
viewBox="0 0 150 150"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill="currentColor"
d="m74.9 6.3c6-0.1 16.2 7.9 31.7 16.7l26.4 17.7c1.9 1.4 2.1 1.7 2.1 3.3v61.9c-0.1 1.8-0.6 2.7-1.4 3.2l-56.3 33.7c-1.7 1-3.1 0.9-4.6 0.1l-56.3-33.8c-0.9-0.7-1.6-1.2-1.6-3.5v-62.2c0-1 0.7-1.9 1.4-2.2l55.9-33.8c1-0.6 1.9-1.1 2.7-1.1zm-3.4 9.2c-0.4-0.2-1.9 0.9-2.9 1.5l-41.7 25.5c-0.4 0.3-0.7 0.6-0.3 0.7h44.3c0.6 0 0.6-0.1 0.6-0.8v-26.2-0.7zm51.9 27.7c0.1-0.2-0.9-0.8-1.2-1l-43-26.4c-1-0.6-1.1-0.1-1.1 0.2v26.9c0.1 0.7 0 0.5 1.4 0.4l43.9-0.1zm-101.3 6.9c-0.4 0.2-0.3 0.6-0.3 0.8v47.7c0 0.7 0.3 0.7 1.3 0.1l44.6-23c1.4-0.7 1.3-0.6-0.5-1.8l-44-23.7c-0.4-0.2-0.8-0.4-1.1-0.1zm14.8 0c-1.5 0.2 2.2 1.8 3.1 2.3l36.8 20.3c1.3 0.6 1.2 1.2 1.2 1.9v51.3c0.3 0.5 43.4-72.2 43.7-73 0.9-1.8 0.7-2.8 0.4-2.8h-85.2zm91.1 4c-0.5 0-0.8 1-1.3 1.8l-18.5 31.4c-0.6 0.7 0.1 1.1 0.6 1.4l18.7 9.5c0.4 0.4 0.5-0.8 0.5-0.9v-43.2zm-56.7 28.2-45 23.3c-1.2 0.5-0.4 1 0.3 1.4l44.2 26.8c1 0.6 0.7 0.3 0.7 0v-51.1c0-0.3 0-0.4-0.2-0.4zm33.1 12.1-22.5 37.8 43.6-25.9c0.9-0.5-0.1-0.9-0.9-1.4l-19.5-10.4c-0.6-0.5-0.7-0.1-0.7-0.1z"
/>
</svg>
);
export const OpenAI = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} preserveAspectRatio="xMidYMid" viewBox="0 0 256 260">
<path
fill="currentColor"
d="M239.184 106.203a64.716 64.716 0 0 0-5.576-53.103C219.452 28.459 191 15.784 163.213 21.74A65.586 65.586 0 0 0 52.096 45.22a64.716 64.716 0 0 0-43.23 31.36c-14.31 24.602-11.061 55.634 8.033 76.74a64.665 64.665 0 0 0 5.525 53.102c14.174 24.65 42.644 37.324 70.446 31.36a64.72 64.72 0 0 0 48.754 21.744c28.481.025 53.714-18.361 62.414-45.481a64.767 64.767 0 0 0 43.229-31.36c14.137-24.558 10.875-55.423-8.083-76.483Zm-97.56 136.338a48.397 48.397 0 0 1-31.105-11.255l1.535-.87 51.67-29.825a8.595 8.595 0 0 0 4.247-7.367v-72.85l21.845 12.636c.218.111.37.32.409.563v60.367c-.056 26.818-21.783 48.545-48.601 48.601Zm-104.466-44.61a48.345 48.345 0 0 1-5.781-32.589l1.534.921 51.722 29.826a8.339 8.339 0 0 0 8.441 0l63.181-36.425v25.221a.87.87 0 0 1-.358.665l-52.335 30.184c-23.257 13.398-52.97 5.431-66.404-17.803ZM23.549 85.38a48.499 48.499 0 0 1 25.58-21.333v61.39a8.288 8.288 0 0 0 4.195 7.316l62.874 36.272-21.845 12.636a.819.819 0 0 1-.767 0L41.353 151.53c-23.211-13.454-31.171-43.144-17.804-66.405v.256Zm179.466 41.695-63.08-36.63L161.73 77.86a.819.819 0 0 1 .768 0l52.233 30.184a48.6 48.6 0 0 1-7.316 87.635v-61.391a8.544 8.544 0 0 0-4.4-7.213Zm21.742-32.69-1.535-.922-51.619-30.081a8.39 8.39 0 0 0-8.492 0L99.98 99.808V74.587a.716.716 0 0 1 .307-.665l52.233-30.133a48.652 48.652 0 0 1 72.236 50.391v.205ZM88.061 139.097l-21.845-12.585a.87.87 0 0 1-.41-.614V65.685a48.652 48.652 0 0 1 79.757-37.346l-1.535.87-51.67 29.825a8.595 8.595 0 0 0-4.246 7.367l-.051 72.697Zm11.868-25.58 28.138-16.217 28.188 16.218v32.434l-28.086 16.218-28.188-16.218-.052-32.434Z"
/>
</svg>
);