# Prompt Composer

> A prompt input with attachments, a model picker, and a stop state.

A shadcn/ui block from the [@spectrumui](https://ui.spectrumhq.in) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/spectrumui/prompt-composer).

- Page, with a live preview: https://shadcn.exchange/r/spectrumui/prompt-composer
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/spectrumui/prompt-composer-light-33f0d0580fe14421.png
- Registry item: https://ui.spectrumhq.in/r/prompt-composer.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://ui.spectrumhq.in/r/prompt-composer.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`

## Files

### `components/spectrumui/blocks/ai-assistants/prompt-composer.tsx`

```tsx
'use client';

import { useRef, useState } from 'react';
import { ArrowUp, Check, ChevronDown, Paperclip, Square, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { Attachment, ModelOption } from './types';

export type PromptComposerVariant = 'Default' | 'Minimal';

export interface PromptComposerProps {
  placeholder?: string;
  models?: ModelOption[];
  defaultModelId?: string;
  attachments?: Attachment[];
  onRemoveAttachment?: (id: string) => void;
  onAttach?: () => void;
  onSend?: (text: string, modelId?: string) => void;
  onStop?: () => void;
  isGenerating?: boolean;
  maxLength?: number;
  variant?: PromptComposerVariant;
  className?: string;
}

function formatSize(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`;
}

export function PromptComposer({
  placeholder = 'Ask anything…',
  models = [],
  defaultModelId,
  attachments = [],
  onRemoveAttachment,
  onAttach,
  onSend,
  onStop,
  isGenerating = false,
  maxLength = 2000,
  variant = 'Default',
  className,
}: PromptComposerProps) {
  const [value, setValue] = useState('');
  const [modelId, setModelId] = useState(defaultModelId ?? models[0]?.id);
  const [pickerOpen, setPickerOpen] = useState(false);
  const textarea = useRef<HTMLTextAreaElement>(null);

  const activeModel = models.find((model) => model.id === modelId);
  const canSend = value.trim().length > 0 && !isGenerating;
  const minimal = variant === 'Minimal';

  function submit() {
    if (!canSend) return;
    onSend?.(value.trim(), modelId);
    setValue('');
    if (textarea.current) textarea.current.style.height = 'auto';
  }

  return (
    <form
      onSubmit={(event) => {
        event.preventDefault();
        submit();
      }}
      className={cn(
        'w-full max-w-[560px] rounded-2xl border border-black/[0.08] bg-white shadow-xs transition-[border-color,box-shadow] duration-150 focus-within:border-black/[0.18] focus-within:shadow-[0_0_0_3px_rgba(0,0,0,0.03)] dark:border-white/[0.09] dark:bg-[#0B0B0D] dark:focus-within:border-white/[0.24] dark:focus-within:shadow-[0_0_0_3px_rgba(255,255,255,0.04)]',
        className,
      )}
    >
      {attachments.length > 0 && (
        <ul className="flex flex-wrap gap-1.5 px-3 pt-3">
          {attachments.map((attachment) => (
            <li
              key={attachment.id}
              className="flex items-center gap-1.5 rounded-lg border border-black/[0.07] bg-black/[0.02] py-1 pl-2 pr-1 dark:border-white/[0.08] dark:bg-white/[0.04]"
            >
              <Paperclip className="size-3 text-neutral-400 dark:text-neutral-500" />
              <span className="max-w-[140px] truncate text-[11.5px] font-medium text-neutral-700 dark:text-neutral-300">
                {attachment.name}
              </span>
              <span className="font-mono text-[10px] tabular-nums text-neutral-400 dark:text-neutral-600">
                {formatSize(attachment.size)}
              </span>
              <button
                type="button"
                aria-label={`Remove ${attachment.name}`}
                onClick={() => onRemoveAttachment?.(attachment.id)}
                className="grid size-4 place-items-center rounded text-neutral-400 transition-[color,transform] duration-150 hover:text-neutral-700 active:scale-[0.9] dark:hover:text-neutral-200"
              >
                <X className="size-3" />
              </button>
            </li>
          ))}
        </ul>
      )}

      <div className="px-3.5 pt-3">
        <textarea
          ref={textarea}
          value={value}
          rows={minimal ? 1 : 2}
          maxLength={maxLength}
          placeholder={placeholder}
          aria-label="Prompt"
          onChange={(event) => {
            setValue(event.target.value);
            event.target.style.height = 'auto';
            event.target.style.height = `${Math.min(event.target.scrollHeight, 132)}px`;
          }}
          onKeyDown={(event) => {
            if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
              event.preventDefault();
              submit();
            }
          }}
          className="max-h-[132px] w-full resize-none bg-transparent text-[13.5px] leading-[1.6] text-neutral-900 outline-hidden placeholder:text-neutral-400 dark:text-neutral-100 dark:placeholder:text-neutral-500"
        />
      </div>

      <div className="flex items-center justify-between gap-2 px-2.5 pb-2.5 pt-1.5">
        <div className="flex min-w-0 items-center gap-1">
          {!minimal && (
            <button
              type="button"
              aria-label="Attach a file"
              onClick={onAttach}
              className="grid size-7 shrink-0 place-items-center rounded-lg text-neutral-400 transition-[color,background-color,transform] duration-150 hover:bg-black/[0.04] hover:text-neutral-700 active:scale-[0.94] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-neutral-400 dark:text-neutral-500 dark:hover:bg-white/[0.06] dark:hover:text-neutral-200"
            >
              <Paperclip className="size-3.5" />
            </button>
          )}

          {!minimal && models.length > 0 && (
            <div className="relative min-w-0">
              <button
                type="button"
                aria-haspopup="listbox"
                aria-expanded={pickerOpen}
                onClick={() => setPickerOpen((open) => !open)}
                className="flex min-w-0 items-center gap-1 rounded-lg px-2 py-1 font-mono text-[11px] text-neutral-500 transition-[color,background-color,transform] duration-150 hover:bg-black/[0.04] hover:text-neutral-800 active:scale-[0.97] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-neutral-400 dark:text-neutral-400 dark:hover:bg-white/[0.06] dark:hover:text-neutral-200"
              >
                <span className="truncate">{activeModel?.name ?? 'Model'}</span>
                <ChevronDown
                  className={cn(
                    'size-3 shrink-0 transition-transform duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]',
                    pickerOpen && 'rotate-180',
                  )}
                />
              </button>

              {pickerOpen && (
                <ul
                  role="listbox"
                  aria-label="Model"
                  className="absolute bottom-full left-0 z-10 mb-1.5 w-56 rounded-xl border border-black/[0.08] bg-white p-1 shadow-lg dark:border-white/[0.1] dark:bg-neutral-900"
                >
                  {models.map((model) => {
                    const selected = model.id === modelId;
                    return (
                      <li key={model.id}>
                        <button
                          type="button"
                          role="option"
                          aria-selected={selected}
                          disabled={model.disabled}
                          onClick={() => {
                            setModelId(model.id);
                            setPickerOpen(false);
                          }}
                          className={cn(
                            'flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-colors duration-150',
                            'focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-neutral-400',
                            model.disabled
                              ? 'cursor-not-allowed opacity-40'
                              : 'hover:bg-black/[0.04] dark:hover:bg-white/[0.06]',
                          )}
                        >
                          <span className="min-w-0 flex-1">
                            <span className="block truncate text-[12.5px] font-medium text-neutral-800 dark:text-neutral-200">
                              {model.name}
                            </span>
                            {model.description && (
                              <span className="block truncate text-[11px] text-neutral-400 dark:text-neutral-500">
                                {model.description}
                              </span>
                            )}
                          </span>
                          {model.badge && !selected && (
                            <span className="shrink-0 rounded bg-black/[0.05] px-1 py-0.5 font-mono text-[9px] uppercase tracking-wide text-neutral-500 dark:bg-white/[0.08] dark:text-neutral-400">
                              {model.badge}
                            </span>
                          )}
                          {selected && <Check className="size-3.5 shrink-0 text-neutral-600 dark:text-neutral-300" />}
                        </button>
                      </li>
                    );
                  })}
                </ul>
              )}
            </div>
          )}
        </div>

        <div className="flex shrink-0 items-center gap-2">
          {!minimal && (
            <span
              className={cn(
                'font-mono text-[10.5px] tabular-nums transition-colors duration-150',
                value.length > maxLength * 0.9
                  ? 'text-amber-600 dark:text-amber-400'
                  : 'text-neutral-300 dark:text-neutral-600',
              )}
            >
              {value.length}/{maxLength}
            </span>
          )}

          <button
            type={isGenerating ? 'button' : 'submit'}
            onClick={isGenerating ? onStop : undefined}
            aria-label={isGenerating ? 'Stop generating' : 'Send prompt'}
            disabled={!isGenerating && !canSend}
            className="grid size-7 place-items-center rounded-full bg-neutral-900 text-white transition-[transform,opacity] duration-[160ms] ease-[cubic-bezier(0.23,1,0.32,1)] active:scale-[0.92] disabled:opacity-30 dark:bg-neutral-100 dark:text-neutral-900"
          >
            <span className="relative grid size-3.5 place-items-center">
              <ArrowUp
                className={cn(
                  'absolute size-3.5 transition-[opacity,filter] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]',
                  isGenerating ? 'opacity-0 blur-[2px]' : 'opacity-100 blur-0',
                )}
              />
              <Square
                className={cn(
                  'absolute size-2.5 fill-current transition-[opacity,filter] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]',
                  isGenerating ? 'opacity-100 blur-0' : 'opacity-0 blur-[2px]',
                )}
              />
            </span>
          </button>
        </div>
      </div>
    </form>
  );
}

export default PromptComposer;
```

### `components/spectrumui/blocks/ai-assistants/types.ts`

```ts
export type MessageRole = 'user' | 'assistant' | 'system';

export type MessageState = 'streaming' | 'complete' | 'error';

export interface Attachment {
  id: string;
  name: string;
  size: number;
  type: string;
  url?: string;
  previewUrl?: string;
}

export interface Citation {
  id: string;
  index: number;
  url: string;
  title: string;
  snippet?: string;
  favicon?: string;
}

export type ToolCallStatus = 'pending' | 'running' | 'success' | 'error' | 'cancelled';

export interface ToolCall {
  id: string;
  name: string;
  args?: Record<string, unknown>;
  result?: string;
  status: ToolCallStatus;
  startedAt?: number;
  completedAt?: number;
  children?: ToolCall[];
  parallel?: boolean;
}

export interface ReasoningStep {
  id: string;
  content: string;
}

export interface Reasoning {
  steps: ReasoningStep[];
  status: 'thinking' | 'complete';
  durationMs?: number;
}

export interface Message {
  id: string;
  role: MessageRole;
  content: string;
  createdAt?: number;
  state?: MessageState;
  attachments?: Attachment[];
  citations?: Citation[];
  toolCalls?: ToolCall[];
  reasoning?: Reasoning;
}

export interface Conversation {
  id: string;
  label: string;
}

export interface ModelOption {
  id: string;
  name: string;
  description?: string;
  badge?: string;
  disabled?: boolean;
}

export interface SuggestedPrompt {
  id: string;
  label: string;
  prompt?: string;
}

export type MessageFeedback = 'positive' | 'negative';

export interface UsageState {
  promptTokens: number;
  completionTokens: number;
  contextWindow: number;
  estimatedCostUsd?: number;
}
```
