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 I

An editorial AI chat input — attachment dropdown for images and documents, web-search and deep-think toggles, a model selector, and a live character counter.

MSource LandMellow UI

Why it stands out

An editorial AI chat input — attachment dropdown for images and documents, web-search and deep-think toggles, a model selector, and a live character counter.

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

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/prompt-composer-demo.tsx

File content: "use client";

import React, { useState } from "react";
import { PromptComposer, type PromptMessage } from "../mellow/prompt-composer";

export default function PromptComposerDemo() {
  const [last, setLast] = useState<PromptMessage | null>(null);

  return (
    <div className="w-full max-w-xl p-4">
      <p className="mb-3 font-mono text-[0.62rem] uppercase tracking-[0.22em] text-[rgba(var(--ink-rgb),0.45)]">
        AI Input I — ask anything
      </p>

      <PromptComposer
        models={["mellow-4-opus", "mellow-4-sonnet", "mellow-4-mini"]}
        onSubmit={setLast}
      />

      {last && (
        <p className="mt-3 font-mono text-[0.68rem] leading-5 text-[rgba(var(--ink-rgb),0.55)]">
          sent → “{last.text || "(attachments only)"}” · {last.model}
          {last.webSearch ? " · web" : ""}
          {last.deepThink ? " · deep-think" : ""}
          {last.attachments.length > 0 ? ` · ${last.attachments.length} file(s)` : ""}
        </p>
      )}
    </div>
  );
}



File location: components/mellow/prompt-composer.tsx

File content: "use client";

import React, {
  useCallback,
  useEffect,
  useRef,
  useState,
  type CSSProperties,
  type ReactNode,
} from "react";

export interface PromptAttachment {
  id: string;
  file: File;
  kind: "image" | "document";
}

export interface PromptMessage {
  text: string;
  model: string;
  attachments: PromptAttachment[];
  webSearch: boolean;
  deepThink: boolean;
}

export interface PromptComposerProps {
  models?: string[];
  defaultModel?: string;
  placeholder?: string;
  maxLength?: number;
  disabled?: boolean;
  /** Play a typewriter carriage-return sound on submit. */
  enableSound?: boolean;
  onSubmit?: (message: PromptMessage) => void;
  className?: string;
  style?: CSSProperties;
}

const DEFAULT_MODELS = ["mellow-4-opus", "mellow-4-sonnet", "mellow-4-mini"];

let attachmentSeq = 0;

/** Typewriter carriage return — a low filtered-noise thock, then a faint bell. */
function playCarriageReturn(ctx: AudioContext) {
  const thockLen = Math.floor(ctx.sampleRate * 0.06);
  const buf = ctx.createBuffer(1, thockLen, ctx.sampleRate);
  const data = buf.getChannelData(0);
  for (let i = 0; i < thockLen; i++) {
    data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / thockLen, 5) * 0.85;
  }
  const src = ctx.createBufferSource();
  src.buffer = buf;
  const lp = ctx.createBiquadFilter();
  lp.type = "lowpass";
  lp.frequency.value = 1100;
  const thockGain = ctx.createGain();
  thockGain.gain.value = 0.55;
  src.connect(lp);
  lp.connect(thockGain);
  thockGain.connect(ctx.destination);
  src.start();

  const bell = ctx.createOscillator();
  bell.type = "sine";
  bell.frequency.value = 1870;
  const bellGain = ctx.createGain();
  const t = ctx.currentTime + 0.05;
  bellGain.gain.setValueAtTime(0, t);
  bellGain.gain.linearRampToValueAtTime(0.05, t + 0.008);
  bellGain.gain.exponentialRampToValueAtTime(0.0001, t + 0.45);
  bell.connect(bellGain);
  bellGain.connect(ctx.destination);
  bell.start(t);
  bell.stop(t + 0.5);
}

function formatBytes(bytes: number) {
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

/** Closes the menu when a pointer event lands outside `ref`. */
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 = 13 }: { path: ReactNode; size?: number }) {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.8"
      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="1" />
      <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" />
    </>
  ),
  think: (
    <>
      <path d="M12 3a6 6 0 0 1 6 6c0 2.4-1.2 3.7-2.3 5-.8.9-1.2 1.6-1.2 3h-5c0-1.4-.4-2.1-1.2-3C7.2 12.7 6 11.4 6 9a6 6 0 0 1 6-6z" />
      <path d="M9.5 20.5h5M10.5 23h3" />
    </>
  ),
  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" />,
};

export function PromptComposer({
  models = DEFAULT_MODELS,
  defaultModel,
  placeholder = "Set your question in type…",
  maxLength = 4000,
  disabled = false,
  enableSound = true,
  onSubmit,
  className,
  style,
}: PromptComposerProps) {
  const [text, setText] = useState("");
  const [stamped, setStamped] = useState(false);
  const audioCtxRef = useRef<AudioContext | null>(null);
  const [model, setModel] = useState(defaultModel ?? models[0] ?? "");
  const [webSearch, setWebSearch] = useState(false);
  const [deepThink, setDeepThink] = useState(false);
  const [attachments, setAttachments] = useState<PromptAttachment[]>([]);
  const [attachOpen, setAttachOpen] = useState(false);
  const [modelOpen, setModelOpen] = useState(false);

  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), []));

  const autogrow = useCallback(() => {
    const el = textareaRef.current;
    if (!el) return;
    el.style.height = "auto";
    el.style.height = `${Math.min(el.scrollHeight, 200)}px`;
  }, []);

  useEffect(autogrow, [text, autogrow]);

  const addFiles = (files: FileList | null, kind: "image" | "document") => {
    if (!files) return;
    const next = Array.from(files).map((file) => ({
      id: `att-${++attachmentSeq}`,
      file,
      kind,
    }));
    setAttachments((prev) => [...prev, ...next]);
  };

  const canSend = !disabled && (text.trim().length > 0 || attachments.length > 0);

  const submit = () => {
    if (!canSend) return;
    onSubmit?.({ text: text.trim(), model, attachments, webSearch, deepThink });
    setText("");
    setAttachments([]);

    if (enableSound) {
      try {
        const AC =
          window.AudioContext ||
          (window as unknown as { webkitAudioContext: typeof AudioContext })
            .webkitAudioContext;
        if (!audioCtxRef.current || audioCtxRef.current.state === "closed") {
          audioCtxRef.current = new AC();
        }
        const ctx = audioCtxRef.current;
        const play = () => playCarriageReturn(ctx);
        ctx.state === "suspended" ? void ctx.resume().then(play) : play();
      } catch {
        // audio unavailable — animation alone carries the feedback
      }
    }
    if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      setStamped(true);
    }
  };

  return (
    <div
      className={[
        "relative border border-(--rule) bg-(--background) text-(--ink)",
        disabled ? "pointer-events-none opacity-50" : "",
        className,
      ]
        .filter(Boolean)
        .join(" ")}
      style={{
        ...style,
        animation: stamped ? "mlw-pc-stamp 0.26s cubic-bezier(0.34,1.56,0.64,1)" : undefined,
      }}
      onAnimationEnd={() => setStamped(false)}
    >
      <style>{`@keyframes mlw-pc-stamp{0%{transform:none}30%{transform:translateY(3px)}100%{transform:none}}`}</style>
      {/* registration marks */}
      <span aria-hidden="true" className="pointer-events-none absolute -left-px -top-px h-2.5 w-2.5 border-l border-t border-(--ink)" />
      <span aria-hidden="true" className="pointer-events-none absolute -bottom-px -right-px h-2.5 w-2.5 border-b border-r border-(--ink)" />

      <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 = "";
        }}
      />

      {attachments.length > 0 && (
        <ul className="flex flex-wrap gap-2 border-b border-dashed border-(--rule) p-3">
          {attachments.map((att) => (
            <li
              key={att.id}
              className="flex items-center gap-2 border border-(--rule) bg-[rgba(var(--ink-rgb),0.04)] py-1 pl-2 pr-1"
            >
              <span className="text-[rgba(var(--ink-rgb),0.55)]">
                <Icon path={att.kind === "image" ? PATHS.image : PATHS.document} size={12} />
              </span>
              <span className="max-w-36 truncate font-mono text-[0.68rem] tracking-tight">
                {att.file.name}
              </span>
              <span className="font-mono text-[0.6rem] uppercase text-[rgba(var(--ink-rgb),0.4)]">
                {formatBytes(att.file.size)}
              </span>
              <button
                type="button"
                aria-label={`Remove ${att.file.name}`}
                onClick={() => setAttachments((prev) => prev.filter((a) => a.id !== att.id))}
                className="flex h-5 w-5 cursor-pointer items-center justify-center text-[rgba(var(--ink-rgb),0.5)] transition-colors hover:bg-[rgba(var(--ink-rgb),0.1)] hover:text-(--ink)"
              >
                <Icon path={PATHS.x} size={10} />
              </button>
            </li>
          ))}
        </ul>
      )}

      <textarea
        ref={textareaRef}
        value={text}
        rows={2}
        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 p-4 text-[0.9375rem] leading-6 text-(--ink) outline-hidden! placeholder:font-serif placeholder:italic placeholder:text-[rgba(var(--ink-rgb),0.35)]"
      />

      <div className="flex items-center justify-between gap-2 border-t border-(--rule) px-2 py-2">
        <div className="flex items-center gap-1">
          {/* attach dropdown */}
          <div ref={attachMenuRef} className="relative">
            <button
              type="button"
              aria-label="Add attachment"
              aria-haspopup="menu"
              aria-expanded={attachOpen}
              onClick={() => setAttachOpen((o) => !o)}
              className={[
                "flex h-8 w-8 cursor-pointer items-center justify-center border transition-colors",
                attachOpen
                  ? "border-(--ink) bg-(--ink) text-(--background)"
                  : "border-(--rule) text-[rgba(var(--ink-rgb),0.6)] hover:border-[rgba(var(--ink-rgb),0.4)] hover:text-(--ink)",
              ].join(" ")}
            >
              <Icon path={PATHS.plus} />
            </button>

            {attachOpen && (
              <div
                role="menu"
                aria-label="Attachment type"
                className="absolute bottom-full left-0 z-10 mb-2 w-44 border border-(--rule) bg-(--background)"
              >
                <p className="border-b border-(--rule) px-3 py-1.5 font-mono text-[0.58rem] uppercase tracking-[0.2em] text-[rgba(var(--ink-rgb),0.45)]">
                  Attach
                </p>
                {(
                  [
                    { label: "Image", icon: PATHS.image, ref: imageInputRef },
                    { label: "Document", icon: PATHS.document, ref: docInputRef },
                  ] as const
                ).map((item) => (
                  <button
                    key={item.label}
                    type="button"
                    role="menuitem"
                    onClick={() => {
                      setAttachOpen(false);
                      item.ref.current?.click();
                    }}
                    className="flex w-full cursor-pointer items-center gap-2.5 px-3 py-2 text-left text-[0.8125rem] text-(--ink) transition-colors hover:bg-[rgba(var(--ink-rgb),0.07)]"
                  >
                    <span className="text-[rgba(var(--ink-rgb),0.55)]">
                      <Icon path={item.icon} />
                    </span>
                    {item.label}
                  </button>
                ))}
              </div>
            )}
          </div>

          <TogglePill
            label="Search web"
            icon={PATHS.globe}
            pressed={webSearch}
            onToggle={() => setWebSearch((v) => !v)}
          />
          <TogglePill
            label="Deep think"
            icon={PATHS.think}
            pressed={deepThink}
            onToggle={() => setDeepThink((v) => !v)}
          />
        </div>

        <div className="flex items-center gap-2">
          <span
            aria-hidden="true"
            className="hidden font-mono text-[0.6rem] tracking-[0.08em] text-[rgba(var(--ink-rgb),0.35)] sm:block"
          >
            {String(text.length).padStart(4, "0")}/{maxLength}
          </span>

          {/* model dropdown */}
          <div ref={modelMenuRef} className="relative">
            <button
              type="button"
              aria-haspopup="menu"
              aria-expanded={modelOpen}
              aria-label={`Model: ${model}`}
              onClick={() => setModelOpen((o) => !o)}
              className="flex h-8 cursor-pointer items-center gap-1.5 border border-(--rule) px-2.5 font-mono text-[0.68rem] tracking-tight text-(--ink) transition-colors hover:border-[rgba(var(--ink-rgb),0.4)]"
            >
              {model}
              <span
                className={`text-[rgba(var(--ink-rgb),0.5)] transition-transform duration-200 ${modelOpen ? "rotate-180" : ""}`}
              >
                <Icon path={PATHS.chevron} size={11} />
              </span>
            </button>

            {modelOpen && (
              <div
                role="menu"
                aria-label="Model"
                className="absolute bottom-full right-0 z-10 mb-2 w-48 border border-(--rule) bg-(--background)"
              >
                <p className="border-b border-(--rule) px-3 py-1.5 font-mono text-[0.58rem] uppercase tracking-[0.2em] text-[rgba(var(--ink-rgb),0.45)]">
                  Model
                </p>
                {models.map((m, i) => {
                  const active = m === model;
                  return (
                    <button
                      key={m}
                      type="button"
                      role="menuitemradio"
                      aria-checked={active}
                      onClick={() => {
                        setModel(m);
                        setModelOpen(false);
                      }}
                      className={[
                        "flex w-full cursor-pointer items-baseline gap-2.5 px-3 py-2 text-left font-mono text-[0.72rem] transition-colors",
                        active
                          ? "bg-(--ink) text-(--background)"
                          : "text-(--ink) hover:bg-[rgba(var(--ink-rgb),0.07)]",
                      ].join(" ")}
                    >
                      <span className={active ? "opacity-70" : "text-[rgba(var(--ink-rgb),0.35)]"}>
                        {String(i + 1).padStart(2, "0")}
                      </span>
                      {m}
                    </button>
                  );
                })}
              </div>
            )}
          </div>

          <button
            type="button"
            aria-label="Send message"
            disabled={!canSend}
            onClick={submit}
            className={[
              "flex h-8 w-8 items-center justify-center border transition-all duration-150",
              canSend
                ? "cursor-pointer border-(--ink) bg-(--ink) text-(--background) hover:-translate-y-px hover:shadow-[2px_2px_0_rgba(var(--ink-rgb),0.25)] motion-reduce:hover:translate-y-0"
                : "border-(--rule) text-[rgba(var(--ink-rgb),0.25)]",
            ].join(" ")}
          >
            <Icon path={PATHS.arrowUp} size={14} />
          </button>
        </div>
      </div>
    </div>
  );
}

function TogglePill({
  label,
  icon,
  pressed,
  onToggle,
}: {
  label: string;
  icon: ReactNode;
  pressed: boolean;
  onToggle: () => void;
}) {
  return (
    <button
      type="button"
      aria-pressed={pressed}
      onClick={onToggle}
      className={[
        "flex h-8 cursor-pointer items-center gap-1.5 border px-2.5 font-mono text-[0.62rem] uppercase tracking-[0.14em] transition-colors",
        pressed
          ? "border-(--ink) bg-(--ink) text-(--background)"
          : "border-(--rule) text-[rgba(var(--ink-rgb),0.55)] hover:border-[rgba(var(--ink-rgb),0.4)] hover:text-(--ink)",
      ].join(" ")}
    >
      <Icon path={icon} size={12} />
      <span className="hidden sm:inline">{label}</span>
    </button>
  );
}

export default PromptComposer;



Usage notes:
Add a PromptComposer component from the mellow library — an AI chat input with an attachment dropdown (image / document file pickers), 'Search web' and 'Deep think' toggle pills, a model selector dropdown, and a send button. 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 — upload them yourself in the handler.

Discovery vocabulary

Related by governed terms

Continue comparing

More from Mellow UI