Skip to main content
Back to discovery

Live preview recorded from Mellow UI; component and demo published by Mellow UI. Preview: platform recorded · rights authorized.

AI Input II

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 UI

Why 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.

Prompt

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

Continue comparing

More from Mellow UI