AI Prompt Input

The composer: auto-growing textarea, send-on-Enter, and a send/stop control.

Installation

Terminal
pnpm dlx @dowel-ui/cli add ai-prompt-input

No npm packages are needed beyond what Dowel already requires.

Accessibility

Enter sends and Shift+Enter inserts a newline, but never while an IME composition is active — for Japanese, Chinese and Korean input Enter confirms a candidate, and sending there truncates the sentence mid-word. The submit control renames itself to "Stop generating" while busy, so its accessible name always matches what it does. The character counter stays silent until it nears the limit; announcing every keystroke would be unusable.

Props

PromptInput

PropTypeDefault
busy

A response is in flight: submission is blocked and Stop is shown instead.

booleanfalse
disabled

Blocks submission and dims the composer.

booleanfalse

Plus every attribute of <form> except onSubmit.

PromptInputTextarea

PropTypeDefault
maxRowsnumberDEFAULT_MAX_ROWS
submitOnEnter

Send on Enter, newline on Shift+Enter. Turn it off for a composer where Enter should always insert a newline and sending is only ever an explicit button press.

booleantrue

Plus every attribute of <textarea> except rows.

PromptInputToolbar

Plus every attribute of <div>.

PromptInputSubmit

PropTypeDefault
label

Label while idle.

string"Send message"
onStop

Called instead of submitting when busy.

() => void
stopLabel

Label while a response is streaming.

string"Stop generating"

Plus every attribute of <button>.

PromptInputCounter

PropTypeDefault
max (required)number
value (required)number
warnAt

Fraction of the limit past which the counter starts warning.

number0.9

Plus every attribute of <div>.

Quality

10/10 checks, measured from the source and its tests

  • Testedpasses
  • axe assertionpasses
  • Keyboard testedpasses
  • Storybook examplespasses
  • Accessibility documentedpasses
  • Semantic tokens onlypasses
  • Motion from tokenspasses
  • className mergedpasses
  • Visible focuspasses
  • No fixed widthspasses

Used in

Whole screens assembled from this component. Installing one brings this and everything else it needs with it.

Source

This is exactly what dowel add ai-prompt-input writes into your project, with imports rewritten to your own path alias.

ui/ai-prompt-input.tsx
"use client";

// Motion from SmoothUI AI Prompt Input (MIT, © 2024 Eduardo Calvo). See THIRD_PARTY_NOTICES.md.
import {
  createContext,
  useContext,
  useId,
  useLayoutEffect,
  useMemo,
  useRef,
  type ComponentPropsWithRef,
  type FormEvent,
  type KeyboardEvent as ReactKeyboardEvent,
} from "react";

import { focusRing, focusRingInset } from "@/lib/styles";
import { cn } from "@/lib/utils";

/**
 * The composer.
 *
 * Handles the parts of a chat input that are easy to get subtly wrong:
 * auto-growing to fit the text up to a ceiling, sending on Enter without
 * breaking multi-line input, and — the one almost everybody misses —
 * not sending mid-composition when someone is typing with an IME.
 */

interface PromptInputContextValue {
  textareaId: string;
  submit: () => void;
  disabled: boolean;
  busy: boolean;
}

const PromptInputContext = createContext<PromptInputContextValue | null>(null);

function usePromptInput(component: string): PromptInputContextValue {
  const context = useContext(PromptInputContext);
  if (!context) {
    throw new Error(`${component} must be rendered inside <PromptInput>.`);
  }
  return context;
}

export interface PromptInputProps extends Omit<ComponentPropsWithRef<"form">, "onSubmit"> {
  onSubmit?: (event: FormEvent<HTMLFormElement>) => void;
  /** Blocks submission and dims the composer. */
  disabled?: boolean;
  /** A response is in flight: submission is blocked and Stop is shown instead. */
  busy?: boolean;
}

export function PromptInput({
  className,
  onSubmit,
  disabled = false,
  busy = false,
  children,
  ...props
}: PromptInputProps) {
  const uid = useId();
  const formRef = useRef<HTMLFormElement | null>(null);

  const context = useMemo<PromptInputContextValue>(
    () => ({
      textareaId: `${uid}-textarea`,
      disabled,
      busy,
      submit: () => {
        formRef.current?.requestSubmit();
      },
    }),
    [uid, disabled, busy],
  );

  return (
    <PromptInputContext.Provider value={context}>
      <form
        ref={formRef}
        data-slot="prompt-input"
        data-busy={busy || undefined}
        onSubmit={(event) => {
          if (disabled || busy) {
            event.preventDefault();
            return;
          }
          onSubmit?.(event);
        }}
        className={cn(
          "rounded-xl border border-input bg-background shadow-xs",
          "transition-[border-color,box-shadow] duration-[var(--duration-fast)]",
          "focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/55",
          disabled && "opacity-60",
          className,
        )}
        {...props}
      >
        {children}
      </form>
    </PromptInputContext.Provider>
  );
}

/** Rows the textarea will grow to before it starts scrolling. */
const DEFAULT_MAX_ROWS = 8;

export interface PromptInputTextareaProps extends Omit<
  ComponentPropsWithRef<"textarea">,
  "rows"
> {
  maxRows?: number;
  /**
   * Send on Enter, newline on Shift+Enter.
   *
   * Turn it off for a composer where Enter should always insert a newline and
   * sending is only ever an explicit button press.
   */
  submitOnEnter?: boolean;
}

export function PromptInputTextarea({
  className,
  maxRows = DEFAULT_MAX_ROWS,
  submitOnEnter = true,
  onKeyDown,
  onChange,
  ...props
}: PromptInputTextareaProps) {
  const { textareaId, submit, disabled, busy } = usePromptInput("PromptInputTextarea");
  const ref = useRef<HTMLTextAreaElement | null>(null);

  // Grows with the content up to maxRows, then scrolls. Measured after layout
  // so the height is right on the first paint rather than one frame late.
  useLayoutEffect(() => {
    const textarea = ref.current;
    if (!textarea) return;

    textarea.style.height = "auto";
    const lineHeight = Number.parseFloat(getComputedStyle(textarea).lineHeight) || 20;
    const maxHeight = lineHeight * maxRows;
    textarea.style.height = `${String(Math.min(textarea.scrollHeight, maxHeight))}px`;
    textarea.style.overflowY = textarea.scrollHeight > maxHeight ? "auto" : "hidden";
  });

  function handleKeyDown(event: ReactKeyboardEvent<HTMLTextAreaElement>) {
    onKeyDown?.(event);
    if (event.defaultPrevented || !submitOnEnter) return;
    if (event.key !== "Enter" || event.shiftKey) return;

    // Mid-composition Enter confirms the candidate an IME is offering — for
    // Japanese, Chinese, Korean and others it is part of typing a word, not a
    // request to send. Sending here truncates the sentence and is the single
    // most common way a chat composer breaks for those users.
    if (event.nativeEvent.isComposing) return;

    event.preventDefault();
    submit();
  }

  return (
    <textarea
      ref={ref}
      id={textareaId}
      data-slot="prompt-input-textarea"
      rows={1}
      disabled={disabled}
      aria-disabled={busy || undefined}
      onKeyDown={handleKeyDown}
      onChange={onChange}
      className={cn(
        "block w-full resize-none bg-transparent px-4 py-3 text-sm outline-none",
        "placeholder:text-muted-foreground disabled:cursor-not-allowed",
        focusRingInset,
        "focus-visible:ring-0",
        className,
      )}
      {...props}
    />
  );
}

/** The row beneath the textarea: attachments, model picker, send. */
export function PromptInputToolbar({ className, ...props }: ComponentPropsWithRef<"div">) {
  return (
    <div
      data-slot="prompt-input-toolbar"
      className={cn("flex items-center gap-2 px-2 pb-2", className)}
      {...props}
    />
  );
}

export interface PromptInputSubmitProps extends ComponentPropsWithRef<"button"> {
  /** Label while idle. */
  label?: string;
  /** Label while a response is streaming. */
  stopLabel?: string;
  /** Called instead of submitting when busy. */
  onStop?: () => void;
}

/**
 * Send, or stop.
 *
 * One control with two jobs, because that is what the space affords and what
 * people expect. The accessible name changes with the state, so it is never
 * "Send" while it actually stops.
 */
export function PromptInputSubmit({
  className,
  label = "Send message",
  stopLabel = "Stop generating",
  onStop,
  ...props
}: PromptInputSubmitProps) {
  const { busy, disabled } = usePromptInput("PromptInputSubmit");

  // Both glyphs stay mounted in one grid cell and cross-fade, so the swap is a
  // scale-and-fade rather than a jump. The accessible name carries the state;
  // the glyphs are decoration.
  const glyph =
    "col-start-1 row-start-1 transition-[opacity,scale] duration-[var(--duration-normal)] ease-[var(--ease-out-quint)]";

  return (
    <button
      type={busy ? "button" : "submit"}
      data-slot="prompt-input-submit"
      data-state={busy ? "stop" : "send"}
      aria-label={busy ? stopLabel : label}
      disabled={disabled}
      onClick={busy ? onStop : undefined}
      className={cn(
        "ms-auto grid size-8 shrink-0 place-items-center rounded-lg",
        "bg-primary text-primary-foreground",
        "transition-[color,background-color,scale] duration-[var(--duration-fast)] ease-[var(--ease-out-quint)]",
        "hover:scale-105 hover:bg-primary-hover active:scale-95",
        "disabled:pointer-events-none disabled:scale-100 disabled:opacity-55",
        focusRing,
        className,
      )}
      {...props}
    >
      <svg
        viewBox="0 0 24 24"
        fill="none"
        aria-hidden="true"
        data-glyph="send"
        className={cn(glyph, "size-4", busy ? "scale-60 opacity-0" : "scale-100 opacity-100")}
      >
        <path
          d="M12 19V5m0 0-6 6m6-6 6 6"
          stroke="currentColor"
          strokeWidth="2"
          strokeLinecap="round"
          strokeLinejoin="round"
        />
      </svg>
      <svg
        viewBox="0 0 24 24"
        fill="none"
        aria-hidden="true"
        data-glyph="stop"
        className={cn(glyph, "size-3.5", busy ? "scale-100 opacity-100" : "scale-60 opacity-0")}
      >
        <rect x="6" y="6" width="12" height="12" rx="2" fill="currentColor" />
      </svg>
    </button>
  );
}

const PREFIX = "dowel-ai-prompt-input";

/* The "value / max" reading rises in once, when the limit first matters. */
const STYLES = `
@keyframes ${PREFIX}-counter-in{from{opacity:0;translate:0 4px}}
[data-slot=prompt-input-counter-value][data-warning]{display:inline-block;animation:${PREFIX}-counter-in calc(200ms * var(--motion-scale,1)) var(--ease-out-quint) both}
`;

export interface PromptInputCounterProps extends ComponentPropsWithRef<"div"> {
  value: number;
  max: number;
  /** Fraction of the limit past which the counter starts warning. */
  warnAt?: number;
}

/**
 * Characters used against a limit.
 *
 * The visible count updates on every keystroke; the live region does not.
 *
 * The naive version toggles `aria-live` on once the count matters, but a region
 * only announces changes that happen *while* it is live — switching it on at
 * the same moment the content changes means the one update worth hearing is the
 * one that is missed. So the region is live from the start and simply has
 * nothing in it until the limit is close, which is also what keeps ordinary
 * typing silent.
 */
export function PromptInputCounter({
  className,
  value,
  max,
  warnAt = 0.9,
  ...props
}: PromptInputCounterProps) {
  const warning = value >= max * warnAt;
  const over = value > max;
  const remaining = max - value;

  return (
    <div
      data-slot="prompt-input-counter"
      data-warning={warning || undefined}
      data-over={over || undefined}
      className={cn(
        "text-xs tabular-nums",
        over ? "text-destructive" : warning ? "text-warning" : "text-muted-foreground",
        className,
      )}
      {...props}
    >
      <style href={PREFIX} precedence="dowel">
        {STYLES}
      </style>
      {/* Keyed so crossing the threshold remounts the visible reading, which is
          what plays its entrance. Never key the status span below: it has to
          stay the same live region from first paint (ADR 0009). */}
      <span
        key={warning ? "limit" : "count"}
        aria-hidden="true"
        data-slot="prompt-input-counter-value"
        data-warning={warning || undefined}
      >
        {warning ? `${String(value)} / ${String(max)}` : String(value)}
      </span>
      <span role="status" aria-live="polite" className="sr-only">
        {over
          ? `Over the limit by ${String(-remaining)} characters`
          : warning
            ? `${String(remaining)} characters remaining`
            : ""}
      </span>
    </div>
  );
}