# AI Input II (`orbit-composer`)

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

- **Docs:** https://www.mellowui.com/components/orbit-composer
- **Markdown:** https://www.mellowui.com/components/orbit-composer.md
- **Registry:** https://www.mellowui.com/r/orbit-composer.json
- **Tool prompt:** https://www.mellowui.com/api/prompt/orbit-composer
- **Categories:** interactive, form, ai, animation
- **Dependencies:** motion

## AI prompt

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.

## Install

```bash
npx shadcn@latest add https://mellowui.com/r/orbit-composer.json
```

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `models` | `string[]` | `["orbit-2-pro", "orbit-2-flash", "orbit-2-lite"]` | Model ids shown in the selector dropdown. |
| `defaultModel` | `string` | `models[0]` | Initially selected model. |
| `placeholder` | `string` | `"Ask anything…"` | Placeholder text for the prompt textarea. |
| `maxLength` | `number` | `2000` | Character limit — usage is drawn as the ring around the send button. |
| `disabled` | `boolean` | `false` | Disables all input while a response is streaming. |
| `onSubmit` | `(message: OrbitMessage) => void` | — | Called on send with { text, model, attachments, webSearch, deepThink }. |
| `className` | `string` | — | Additional CSS classes. |

## Design notes

- Client component (`"use client"`) — required for animation and refs.
- Uses Mellow design tokens: `--ink`, `--ink-rgb`, `--background`, `--background-rgb`, `--rule`, `--font-sans`, `--font-serif`, `--font-mono`. Tokens auto-flip between light and dark themes — never hardcode colors.
- Respects `prefers-reduced-motion: reduce` where applicable.
- Self-contained — no shared utilities. Copy-paste, not a package.

## Source

```tsx
"use client";

import React, {
  useCallback,
  useEffect,
  useRef,
  useState,
  type CSSProperties,
  type ReactNode,
} from "react";
import { AnimatePresence, motion, useReducedMotion } 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;
  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;

const spring = { type: "spring", stiffness: 500, damping: 32, mass: 0.7 } as const;

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,
  className,
  style,
}: OrbitComposerProps) {
  const reduced = useReducedMotion();
  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}
            onToggle={() => setWebSearch((v) => !v)}
          />
          <OrbitToggle
            label="Think"
            icon={PATHS.sparkle}
            pressed={deepThink}
            reduced={!!reduced}
            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 -inset-1"
              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,
  onToggle,
}: {
  label: string;
  icon: ReactNode;
  pressed: boolean;
  reduced: boolean;
  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;

```

## Demo

```tsx
"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>
  );
}

```
