Signature
An animated SVG signature effect that draws out text as if hand-written.
Live preview recorded from Mellow UI; component and demo published by Mellow UI. Preview: platform recorded · rights authorized.
A rounded, motion-driven AI chat input — springy attachment menu with inline image previews, toggle pills that expand their label when active, a model selector…
MSource LandMellow UIWhy it stands out
A rounded, motion-driven AI chat input — springy attachment menu with inline image previews, toggle pills that expand their label when active, a model selector, and a send button ringed by a character-usage orbit.
You are given a task to integrate a React component into your codebase.
Please verify your project has the following setup:
- shadcn/ui project structure
- Tailwind CSS v4.0
- TypeScript
- motion/react v11 (install: `pnpm add motion`)
If any of these are missing, provide instructions on how to setup project via shadcn CLI, install Tailwind or TypeScript.
Determine the default path for components and styles. Mellow components live in `components/mellow/`. If that folder does not exist yet, create it.
Copy-paste these files:
File location: components/orbit-composer-demo.tsx
File content: "use client";
import React, { useState } from "react";
import { OrbitComposer, type OrbitMessage } from "../mellow/orbit-composer";
export default function OrbitComposerDemo() {
const [last, setLast] = useState<OrbitMessage | null>(null);
return (
<div className="w-full max-w-xl p-4">
<OrbitComposer
models={["orbit-2-pro", "orbit-2-flash", "orbit-2-lite"]}
onSubmit={setLast}
/>
{last && (
<p className="mt-4 text-center text-xs text-[rgba(var(--ink-rgb),0.5)]">
sent “{last.text || "(attachments only)"}” with {last.model}
{last.webSearch ? " · search" : ""}
{last.deepThink ? " · think" : ""}
{last.attachments.length > 0 ? ` · ${last.attachments.length} file(s)` : ""}
</p>
)}
</div>
);
}
File location: components/mellow/orbit-composer.tsx
File content: "use client";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type ReactNode,
} from "react";
import { AnimatePresence, motion, useReducedMotion, type Transition } from "motion/react";
export interface OrbitAttachment {
id: string;
file: File;
kind: "image" | "document";
/** Object URL for image previews — revoked on removal. */
previewUrl?: string;
}
export interface OrbitMessage {
text: string;
model: string;
attachments: OrbitAttachment[];
webSearch: boolean;
deepThink: boolean;
}
export interface OrbitComposerProps {
models?: string[];
defaultModel?: string;
placeholder?: string;
maxLength?: number;
disabled?: boolean;
onSubmit?: (message: OrbitMessage) => void;
/** Menu / pill spring stiffness. */
stiffness?: number;
/** Menu / pill spring damping. */
damping?: number;
/** Menu / pill spring mass. */
mass?: number;
className?: string;
style?: CSSProperties;
}
const DEFAULT_MODELS = ["orbit-2-pro", "orbit-2-flash", "orbit-2-lite"];
const ACCENT = "oklch(0.65 0.25 250)";
let orbitSeq = 0;
function useClickOutside(ref: React.RefObject<HTMLElement | null>, onOutside: () => void) {
useEffect(() => {
const handler = (event: PointerEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) onOutside();
};
document.addEventListener("pointerdown", handler);
return () => document.removeEventListener("pointerdown", handler);
}, [ref, onOutside]);
}
function Icon({ path, size = 15 }: { path: ReactNode; size?: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.9"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="shrink-0"
>
{path}
</svg>
);
}
const PATHS = {
plus: <path d="M12 5v14M5 12h14" />,
image: (
<>
<rect x="3" y="3" width="18" height="18" rx="4" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.5-3.5L6 23" />
</>
),
document: (
<>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<path d="M14 2v6h6M9 13h6M9 17h6" />
</>
),
globe: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M3 12h18M12 3a15 15 0 0 1 0 18 15 15 0 0 1 0-18" />
</>
),
sparkle: (
<path d="M12 3l1.9 5.6L19.5 10l-5.6 1.9L12 17.5l-1.9-5.6L4.5 10l5.6-1.4zM19 17l.8 2.2L22 20l-2.2.8L19 23l-.8-2.2L16 20l2.2-.8z" />
),
chevron: <path d="m6 9 6 6 6-6" />,
arrowUp: <path d="M12 19V5m-6 6 6-6 6 6" />,
x: <path d="M6 6l12 12M18 6 6 18" />,
check: <path d="m5 13 4 4L19 7" />,
};
export function OrbitComposer({
models = DEFAULT_MODELS,
defaultModel,
placeholder = "Ask anything…",
maxLength = 2000,
disabled = false,
onSubmit,
stiffness = 500,
damping = 32,
mass = 0.7,
className,
style,
}: OrbitComposerProps) {
const reduced = useReducedMotion();
const spring = useMemo<Transition>(
() => ({ type: "spring", stiffness, damping, mass }),
[stiffness, damping, mass]
);
const [text, setText] = useState("");
const [model, setModel] = useState(defaultModel ?? models[0] ?? "");
const [webSearch, setWebSearch] = useState(false);
const [deepThink, setDeepThink] = useState(false);
const [attachments, setAttachments] = useState<OrbitAttachment[]>([]);
const [attachOpen, setAttachOpen] = useState(false);
const [modelOpen, setModelOpen] = useState(false);
const [sendPulse, setSendPulse] = useState(0);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const attachMenuRef = useRef<HTMLDivElement>(null);
const modelMenuRef = useRef<HTMLDivElement>(null);
const imageInputRef = useRef<HTMLInputElement>(null);
const docInputRef = useRef<HTMLInputElement>(null);
useClickOutside(attachMenuRef, useCallback(() => setAttachOpen(false), []));
useClickOutside(modelMenuRef, useCallback(() => setModelOpen(false), []));
useEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 180)}px`;
}, [text]);
// Revoke all preview object URLs on unmount only.
const attachmentsRef = useRef(attachments);
attachmentsRef.current = attachments;
useEffect(
() => () => {
attachmentsRef.current.forEach((a) => a.previewUrl && URL.revokeObjectURL(a.previewUrl));
},
[]
);
const addFiles = (files: FileList | null, kind: "image" | "document") => {
if (!files) return;
const next = Array.from(files).map((file) => ({
id: `orb-${++orbitSeq}`,
file,
kind,
previewUrl: kind === "image" ? URL.createObjectURL(file) : undefined,
}));
setAttachments((prev) => [...prev, ...next]);
};
const removeAttachment = (id: string) => {
setAttachments((prev) => {
const target = prev.find((a) => a.id === id);
if (target?.previewUrl) URL.revokeObjectURL(target.previewUrl);
return prev.filter((a) => a.id !== id);
});
};
const canSend = !disabled && (text.trim().length > 0 || attachments.length > 0);
const usage = Math.min(text.length / maxLength, 1);
const submit = () => {
if (!canSend) return;
onSubmit?.({ text: text.trim(), model, attachments, webSearch, deepThink });
setSendPulse((p) => p + 1);
setText("");
setAttachments([]);
};
return (
<div
className={[
"group/composer relative rounded-[26px] border border-[rgba(var(--ink-rgb),0.1)] bg-[rgba(var(--ink-rgb),0.03)] text-(--ink) backdrop-blur-sm transition-shadow duration-300",
"focus-within:border-[rgba(var(--ink-rgb),0.18)] focus-within:shadow-[0_0_0_4px_rgba(var(--ink-rgb),0.04),0_12px_40px_-12px_rgba(var(--ink-rgb),0.18)]",
disabled ? "pointer-events-none opacity-50" : "",
className,
]
.filter(Boolean)
.join(" ")}
style={style}
>
<input
ref={imageInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => {
addFiles(e.target.files, "image");
e.target.value = "";
}}
/>
<input
ref={docInputRef}
type="file"
accept=".pdf,.doc,.docx,.txt,.md,.csv,.json"
multiple
className="hidden"
onChange={(e) => {
addFiles(e.target.files, "document");
e.target.value = "";
}}
/>
<AnimatePresence initial={false}>
{attachments.length > 0 && (
<motion.ul
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={reduced ? { duration: 0 } : spring}
className="flex flex-wrap gap-2 overflow-hidden px-4 pt-4"
>
<AnimatePresence initial={false}>
{attachments.map((att) => (
<motion.li
key={att.id}
layout
initial={{ scale: 0.7, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.7, opacity: 0 }}
transition={reduced ? { duration: 0 } : spring}
className="group/chip relative flex items-center gap-2 rounded-2xl border border-[rgba(var(--ink-rgb),0.1)] bg-(--background) py-1.5 pl-1.5 pr-3"
>
{att.previewUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={att.previewUrl}
alt=""
className="h-8 w-8 rounded-xl object-cover"
/>
) : (
<span className="flex h-8 w-8 items-center justify-center rounded-xl bg-[rgba(var(--ink-rgb),0.06)] text-[rgba(var(--ink-rgb),0.6)]">
<Icon path={PATHS.document} size={14} />
</span>
)}
<span className="max-w-32 truncate text-xs font-medium">
{att.file.name}
</span>
<button
type="button"
aria-label={`Remove ${att.file.name}`}
onClick={() => removeAttachment(att.id)}
className="absolute -right-1.5 -top-1.5 flex h-5 w-5 scale-75 cursor-pointer items-center justify-center rounded-full border border-[rgba(var(--ink-rgb),0.1)] bg-(--background) text-[rgba(var(--ink-rgb),0.6)] opacity-0 shadow-sm transition-all duration-150 hover:text-(--ink) group-hover/chip:scale-100 group-hover/chip:opacity-100 focus-visible:scale-100 focus-visible:opacity-100"
>
<Icon path={PATHS.x} size={9} />
</button>
</motion.li>
))}
</AnimatePresence>
</motion.ul>
)}
</AnimatePresence>
<textarea
ref={textareaRef}
value={text}
rows={1}
maxLength={maxLength}
disabled={disabled}
placeholder={placeholder}
aria-label="Prompt"
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
}}
className="block w-full resize-none bg-transparent px-5 pb-2 pt-4 text-[0.9375rem] leading-6 outline-hidden! placeholder:text-[rgba(var(--ink-rgb),0.35)]"
/>
<div className="flex items-center justify-between gap-2 px-3 pb-3">
<div className="flex items-center gap-1.5">
{/* attach */}
<div ref={attachMenuRef} className="relative">
<motion.button
type="button"
aria-label="Add attachment"
aria-haspopup="menu"
aria-expanded={attachOpen}
onClick={() => setAttachOpen((o) => !o)}
whileTap={reduced ? undefined : { scale: 0.88 }}
animate={reduced ? undefined : { rotate: attachOpen ? 45 : 0 }}
transition={spring}
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-full text-[rgba(var(--ink-rgb),0.6)] transition-colors hover:bg-[rgba(var(--ink-rgb),0.07)] hover:text-(--ink)"
>
<Icon path={PATHS.plus} size={17} />
</motion.button>
<AnimatePresence>
{attachOpen && (
<motion.div
role="menu"
aria-label="Attachment type"
initial={{ opacity: 0, scale: 0.88, y: 6 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.92, y: 4 }}
transition={reduced ? { duration: 0 } : spring}
className="absolute bottom-full left-0 z-10 mb-2 w-48 origin-bottom-left overflow-hidden rounded-2xl border border-[rgba(var(--ink-rgb),0.1)] bg-(--background) p-1.5"
>
{(
[
{ label: "Attach image", icon: PATHS.image, ref: imageInputRef },
{ label: "Attach document", icon: PATHS.document, ref: docInputRef },
] as const
).map((item, i) => (
<motion.button
key={item.label}
type="button"
role="menuitem"
initial={reduced ? false : { opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ ...spring, delay: 0.04 * i }}
onClick={() => {
setAttachOpen(false);
item.ref.current?.click();
}}
className="flex w-full cursor-pointer items-center gap-3 rounded-xl px-3 py-2.5 text-left text-[0.8125rem] font-medium transition-colors hover:bg-[rgba(var(--ink-rgb),0.06)]"
>
<span className="text-[rgba(var(--ink-rgb),0.55)]">
<Icon path={item.icon} size={15} />
</span>
{item.label}
</motion.button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
<OrbitToggle
label="Search"
icon={PATHS.globe}
pressed={webSearch}
reduced={!!reduced}
spring={spring}
onToggle={() => setWebSearch((v) => !v)}
/>
<OrbitToggle
label="Think"
icon={PATHS.sparkle}
pressed={deepThink}
reduced={!!reduced}
spring={spring}
onToggle={() => setDeepThink((v) => !v)}
/>
</div>
<div className="flex items-center gap-1.5">
{/* model */}
<div ref={modelMenuRef} className="relative">
<motion.button
type="button"
aria-haspopup="menu"
aria-expanded={modelOpen}
aria-label={`Model: ${model}`}
onClick={() => setModelOpen((o) => !o)}
whileTap={reduced ? undefined : { scale: 0.95 }}
className="flex h-9 cursor-pointer items-center gap-1 rounded-full px-3 text-xs font-medium text-[rgba(var(--ink-rgb),0.65)] transition-colors hover:bg-[rgba(var(--ink-rgb),0.07)] hover:text-(--ink)"
>
{model}
<motion.span
animate={reduced ? undefined : { rotate: modelOpen ? 180 : 0 }}
transition={spring}
className="text-[rgba(var(--ink-rgb),0.45)]"
>
<Icon path={PATHS.chevron} size={12} />
</motion.span>
</motion.button>
<AnimatePresence>
{modelOpen && (
<motion.div
role="menu"
aria-label="Model"
initial={{ opacity: 0, scale: 0.88, y: 6 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.92, y: 4 }}
transition={reduced ? { duration: 0 } : spring}
className="absolute bottom-full right-0 z-10 mb-2 w-52 origin-bottom-right overflow-hidden rounded-2xl border border-[rgba(var(--ink-rgb),0.1)] bg-(--background) p-1.5"
>
{models.map((m) => {
const active = m === model;
return (
<button
key={m}
type="button"
role="menuitemradio"
aria-checked={active}
onClick={() => {
setModel(m);
setModelOpen(false);
}}
className="relative flex w-full cursor-pointer items-center justify-between rounded-xl px-3 py-2.5 text-left text-[0.8125rem] font-medium transition-colors hover:bg-[rgba(var(--ink-rgb),0.06)]"
>
{active && (
<motion.span
layoutId="orbit-model-active"
transition={reduced ? { duration: 0 } : spring}
className="absolute inset-0 rounded-xl bg-[rgba(var(--ink-rgb),0.06)]"
/>
)}
<span className="relative">{m}</span>
{active && (
<span className="relative" style={{ color: ACCENT }}>
<Icon path={PATHS.check} size={13} />
</span>
)}
</button>
);
})}
</motion.div>
)}
</AnimatePresence>
</div>
{/* send — character usage drawn as an orbit ring */}
<motion.button
type="button"
aria-label="Send message"
disabled={!canSend}
onClick={submit}
whileTap={canSend && !reduced ? { scale: 0.85 } : undefined}
whileHover={canSend && !reduced ? { scale: 1.06 } : undefined}
transition={spring}
className={[
"relative flex h-9 w-9 items-center justify-center rounded-full transition-colors duration-200",
canSend
? "cursor-pointer text-white"
: "bg-[rgba(var(--ink-rgb),0.06)] text-[rgba(var(--ink-rgb),0.3)]",
].join(" ")}
style={canSend ? { background: ACCENT } : undefined}
>
<svg
className="pointer-events-none absolute top-1/2 left-1/2 size-11 -translate-x-1/2 -translate-y-1/2"
viewBox="0 0 44 44"
fill="none"
aria-hidden="true"
>
<circle
cx="22"
cy="22"
r="20"
stroke={usage > 0 ? ACCENT : "transparent"}
strokeOpacity="0.5"
strokeWidth="2"
strokeLinecap="round"
strokeDasharray={`${usage * 125.6} 125.6`}
transform="rotate(-90 22 22)"
className="transition-all duration-300"
/>
</svg>
<motion.span
key={sendPulse}
initial={reduced || sendPulse === 0 ? false : { y: 14, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={spring}
>
<Icon path={PATHS.arrowUp} size={16} />
</motion.span>
</motion.button>
</div>
</div>
</div>
);
}
function OrbitToggle({
label,
icon,
pressed,
reduced,
spring,
onToggle,
}: {
label: string;
icon: ReactNode;
pressed: boolean;
reduced: boolean;
spring: Transition;
onToggle: () => void;
}) {
// Non-overshooting tween — springs overshoot width, which clips the label
// and reads as jitter. Grid-track collapse (0fr → 1fr) beats width:auto
// because the label keeps its natural width the whole time.
const expand = reduced ? { duration: 0 } : ({ duration: 0.3, ease: [0.32, 0.72, 0, 1] } as const);
return (
<motion.button
type="button"
aria-pressed={pressed}
onClick={onToggle}
whileTap={reduced ? undefined : { scale: 0.92 }}
transition={spring}
className={[
"flex h-9 cursor-pointer items-center rounded-full px-3 text-xs font-medium transition-colors duration-200",
pressed
? "text-white"
: "text-[rgba(var(--ink-rgb),0.6)] hover:bg-[rgba(var(--ink-rgb),0.07)] hover:text-(--ink)",
].join(" ")}
style={pressed ? { background: ACCENT } : undefined}
>
<Icon path={icon} size={14} />
<motion.span
animate={{
gridTemplateColumns: pressed ? "1fr" : "0fr",
opacity: pressed ? 1 : 0,
}}
initial={false}
transition={expand}
className="grid"
>
<span className="overflow-hidden whitespace-nowrap">
<span className="pl-1.5">{label}</span>
</span>
</motion.span>
</motion.button>
);
}
export default OrbitComposer;
Usage notes:
Add an OrbitComposer component from the mellow library — a rounded AI chat input built on motion/react. It has a spring-animated attachment menu (image / document pickers, images show inline thumbnail previews), 'Search' and 'Think' toggle pills that expand to reveal their label when active, a model dropdown, and a circular send button wrapped in an SVG ring that fills with character usage. Enter submits, Shift+Enter adds a newline. Pass a `models` string array and handle `onSubmit`, which receives { text, model, attachments, webSearch, deepThink }. Attachments are File objects with an optional previewUrl.Discovery vocabulary
Related by governed terms
An animated SVG signature effect that draws out text as if hand-written.
More from Mellow UI