---
title: ChatInput
description: A controlled text input form for the chatbot, with a send button and optional audio/attachment trigger.
---

```tsx
'use client';

import { useState } from 'react';
import { ChatInput } from '@/ui/ChatInput';

export function ChatInputDemo() {
  const [value, setValue] = useState('');

  return (
    <div className="w-[380px]">
      <ChatInput
        aria-label="Chat message input"
        value={value}
        onValueChange={setValue}
        onSubmit={() => setValue('')}
        onAudioClick={() => {}}
      />
    </div>
  );
}
```

## Installation

Using the CLI:

```bash
npx shadcn@latest add https://develop.trident-ui.pro.clubmed/r/chat-input.json
```

Or manually, copy the following code into `/ui/ChatInput.tsx`:

```tsx
/// <reference types="@types/dom-speech-recognition" />
'use client';

import type { FormHTMLAttributes, KeyboardEvent, MouseEvent, SubmitEvent } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Button } from '../buttons/Button';
import { twMerge } from '../helpers/twMerge';

/**
 * Keyboard shortcut that submits the message:
 * - `mod+enter`: Ctrl/Cmd+Enter submits, Enter adds a line break
 * - `enter`: Enter submits, Shift+Enter adds a line break (Ctrl/Cmd+Enter submits too)
 */
export type ChatInputSubmitKey = 'mod+enter' | 'enter';

export interface ChatInputProps extends FormHTMLAttributes<HTMLFormElement> {
  value: string;
  placeholder?: string;
  onValueChange: (value: string) => void;
  onSubmit: () => void;
  /**
   * Keyboard shortcut that submits the message.
   * @default 'mod+enter'
   */
  submitKey?: ChatInputSubmitKey;
  /** While `true`, submitting is disabled and the send button becomes a stop button */
  isStreaming?: boolean;
  /**
   * Enables the microphone button. The browser speech recognition is used when available, this
   * callback is the fallback for when the browser does not support the Web Speech API.
   */
  onAudioClick?: () => void;
  /** Called when the stop button, shown in place of the send button while `isStreaming`, is clicked */
  onStop?: () => void;
}

function isSubmitKey(e: KeyboardEvent<HTMLTextAreaElement>, submitKey: ChatInputSubmitKey) {
  // Enter validates the text being composed with an IME: it must not submit
  if (e.key !== 'Enter' || e.nativeEvent.isComposing) return false;
  if (e.metaKey || e.ctrlKey) return true;

  return submitKey === 'enter' && !e.shiftKey;
}

const FORM_PAD = 12; // matches p-12
const BTN_SIZE = 48; // matches size-48
const BTN_GAP = 8; // matches gap-8

// Keep onValueChange stable inside the recognition handler without re-creating the SR instance
function useCallbackRef<T extends (...args: never[]) => unknown>(fn: T) {
  const ref = useRef(fn);
  useEffect(() => {
    ref.current = fn;
  });
  return ref;
}

const SILENCE_TIMEOUT_MS = 3_000;

function useSpeechRecognition({
  enabled,
  onValueChange,
}: {
  enabled: boolean;
  onValueChange: (v: string) => void;
}) {
  const [isSupported, setIsSupported] = useState(false);
  const [isListening, setIsListening] = useState(false);
  const recognitionRef = useRef<SpeechRecognition | null>(null);
  const silenceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const isListeningRef = useRef(false);
  const onValueChangeRef = useCallbackRef(onValueChange);

  function clearSilenceTimer() {
    if (silenceTimerRef.current) clearTimeout(silenceTimerRef.current);
  }

  function resetSilenceTimer() {
    clearSilenceTimer();
    silenceTimerRef.current = setTimeout(() => {
      isListeningRef.current = false;
      setIsListening(false);
      recognitionRef.current?.stop();
    }, SILENCE_TIMEOUT_MS);
  }

  useEffect(() => {
    const SR: typeof SpeechRecognition | undefined =
      (window as Window & { webkitSpeechRecognition?: typeof SpeechRecognition })
        .webkitSpeechRecognition ?? window.SpeechRecognition;
    if (!enabled || !SR) {
      return;
    }

    setIsSupported(true);

    const recognition = new SR();
    recognition.continuous = true;
    recognition.interimResults = true;
    recognition.lang = navigator.language;

    recognition.onresult = (event: SpeechRecognitionEvent) => {
      const transcript = Array.from(event.results)
        .map((r: SpeechRecognitionResult) => r[0].transcript)
        .join('');
      onValueChangeRef.current(transcript);
      resetSilenceTimer();
    };

    recognition.onend = () => {
      clearSilenceTimer();
      // Restart automatically if the user hasn't manually stopped (browser cut us off)
      if (isListeningRef.current) {
        try {
          recognition.start();
        } catch {
          /* already starting */
        }
      } else {
        setIsListening(false);
      }
    };

    recognition.onerror = (e: SpeechRecognitionErrorEvent) => {
      if (e.error === 'not-allowed' || e.error === 'aborted') {
        isListeningRef.current = false;
        setIsListening(false);
        clearSilenceTimer();
      }
    };

    recognitionRef.current = recognition;
    return () => {
      recognition.abort();
      clearSilenceTimer();
    };
    // SR detection and instance creation only needs to run when the capability changes
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [enabled]);

  function toggle() {
    const recognition = recognitionRef.current;
    if (!recognition) return;
    if (isListeningRef.current) {
      isListeningRef.current = false;
      recognition.stop();
    } else {
      isListeningRef.current = true;
      setIsListening(true);
      recognition.start();
      resetSilenceTimer();
    }
  }

  return { isSupported, isListening, toggle };
}

/**
 * Drives the audio-level indicator by writing directly to the DOM element's
 * style — avoids triggering a React re-render on every animation frame
 * (rule: rerender-use-ref-transient-values).
 *
 * Returns a ref to attach to the indicator <span>.
 */
function useAudioLevel(isListening: boolean) {
  const indicatorRef = useRef<HTMLSpanElement>(null);

  useEffect(() => {
    const el = indicatorRef.current;

    if (!isListening) {
      if (el) el.style.height = '0px';
      return;
    }

    let animId: number;
    let stream: MediaStream | null = null;
    let ctx: AudioContext | null = null;

    navigator.mediaDevices.getUserMedia({ audio: true, video: false }).then((s) => {
      stream = s;
      ctx = new AudioContext();
      const source = ctx.createMediaStreamSource(stream);
      const analyser = ctx.createAnalyser();
      analyser.fftSize = 256;
      source.connect(analyser);
      const data = new Uint8Array(analyser.frequencyBinCount);

      function tick() {
        analyser.getByteFrequencyData(data);
        const avg = data.reduce((sum, v) => sum + v, 0) / data.length;
        // Square-root curve so quiet sounds register more visibly
        const node = indicatorRef.current;
        if (node) node.style.height = `${Math.sqrt(avg / 255) * 10}px`;
        animId = requestAnimationFrame(tick);
      }

      animId = requestAnimationFrame(tick);
    });

    return () => {
      cancelAnimationFrame(animId);
      stream?.getTracks().forEach((t) => t.stop());
      ctx?.close();
    };
  }, [isListening]);

  return indicatorRef;
}

export function ChatInput({
  value,
  placeholder = 'Ask a question?',
  onValueChange,
  onSubmit,
  onAudioClick,
  onStop,
  isStreaming,
  submitKey = 'mod+enter',
  className,
  ...props
}: ChatInputProps) {
  const { isSupported, isListening, toggle } = useSpeechRecognition({
    enabled: !!onAudioClick,
    onValueChange: onValueChange,
  });
  const audioIndicatorRef = useAudioLevel(isListening);
  const [isFocused, setIsFocused] = useState(false);
  const trimmed = value.trim();
  const isOpen = isFocused || trimmed.length > 0;
  const btnW = onAudioClick ? BTN_SIZE + BTN_GAP + BTN_SIZE : BTN_SIZE;

  function handleAudioClick() {
    if (isSupported) {
      toggle();
    } else {
      onAudioClick?.();
    }
  }

  function handleSubmit(e: SubmitEvent) {
    e.preventDefault();
    if (trimmed && !isStreaming) onSubmit();
  }

  function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
    if (!isSubmitKey(e, submitKey)) return;

    e.preventDefault();
    if (trimmed && !isStreaming) onSubmit();
  }

  function onMouseDown(e: MouseEvent) {
    if ((e.target as HTMLElement).tagName !== 'TEXTAREA') {
      e.preventDefault();
    }
  }

  return (
    <form
      onSubmit={handleSubmit}
      onMouseDown={onMouseDown}
      {...props}
      style={{
        paddingBottom: isOpen ? FORM_PAD + BTN_GAP + BTN_SIZE : FORM_PAD,
      }}
      className={twMerge(
        'relative flex items-start overflow-hidden bg-white border border-lightGrey p-12 w-full',
        'transition-[padding-bottom,border-radius] duration-250 ease-out rounded-b-[24px]',
        isOpen ? 'rounded-32' : 'rounded-[40px]',
        className,
      )}
    >
      <textarea
        rows={isOpen ? 3 : 1}
        value={value}
        onChange={(e) => onValueChange(e.target.value)}
        onKeyDown={onKeyDown}
        onFocus={() => setIsFocused(true)}
        onBlur={() => setIsFocused(false)}
        placeholder={placeholder}
        aria-label={placeholder}
        style={{
          marginInlineEnd: isOpen ? 0 : btnW + BTN_GAP,
        }}
        className={twMerge(
          'scrollbar-hidden bg-transparent text-b3 py-12 outline-none focus-visible:ring-4 focus-visible:ring-black focus-visible:ring-offset-2 resize-none w-full px-20 border border-middleGrey',
          'transition-[margin-inline-end,border-radius] duration-250 ease-out',
          isOpen
            ? 'text-black rounded-[20px]'
            : 'text-darkGrey placeholder:text-darkGrey rounded-[40px]',
        )}
      />

      <div
        style={{
          insetInlineStart: isOpen
            ? `calc(50% - ${btnW / 2}px)`
            : `calc(100% - ${FORM_PAD + btnW}px)`,
          bottom: FORM_PAD,
        }}
        className="absolute flex gap-8 items-center transition-[inset-inline-start] duration-250 ease-out"
      >
        {isStreaming ? (
          <Button
            type="button"
            aria-label="Stop generating"
            onClick={onStop}
            variant="circle"
            color="black"
            icon="CrossDefault"
            iconWidth="24px"
          />
        ) : (
          <Button
            type="submit"
            aria-label="Send message"
            disabled={!trimmed}
            variant="circle"
            color="saffron"
            icon="ArrowTailRight"
            iconWidth="24px"
          />
        )}

        {!!onAudioClick && (
          <Button
            type="button"
            aria-label={isListening ? 'Stop recording' : 'Use microphone'}
            aria-pressed={isListening}
            onClick={handleAudioClick}
            variant="circle"
            theme={isListening ? 'solid' : 'outline'}
            color={isListening ? 'sienna' : 'black'}
            icon="Audio"
            iconWidth="24px"
            className={twMerge(
              'relative',
              isListening && 'animate-pulse',
              !isListening && 'border-lightGrey',
            )}
          >
            <span
              ref={audioIndicatorRef}
              aria-hidden="true"
              className="bg-current absolute w-5 bottom-21"
              style={{ height: '0px' }}
            />
          </Button>
        )}
      </div>
    </form>
  );
}
```

## Usage

```tsx showLineNumbers
import { ChatInput } from '@/ui/ChatInput';
import { useState } from 'react';

export function Component() {
  const [value, setValue] = useState('');

  return (
    <ChatInput
      value={value}
      onValueChange={setValue}
      onSubmit={() => {
        console.log('Submitted:', value);
        setValue('');
      }}
      onAudioClick={() => console.log('Audio clicked')}
    />
  );
}
```

## Stop while an answer is generated

While `isStreaming` is `true`, submitting is disabled and the send button becomes a **stop button**
that calls `onStop`. Set it when you start generating the answer, and reset it when the answer is
complete or stopped.

```tsx
'use client';

import { useRef, useState } from 'react';
import { ChatInput } from '@/ui/ChatInput';

export function ChatInputStreamingDemo() {
  const [value, setValue] = useState('');
  const [isStreaming, setIsStreaming] = useState(false);
  const timer = useRef<ReturnType<typeof setTimeout>>(undefined);

  function send() {
    setValue('');
    setIsStreaming(true);
    // Simulates an answer being generated for 4 seconds
    timer.current = setTimeout(() => setIsStreaming(false), 4000);
  }

  function stop() {
    clearTimeout(timer.current);
    setIsStreaming(false);
  }

  return (
    <div className="w-[380px]">
      <ChatInput
        aria-label="Chat message input"
        value={value}
        onValueChange={setValue}
        onSubmit={send}
        isStreaming={isStreaming}
        onStop={stop}
      />
    </div>
  );
}
```

## Submit with Enter

By default <kbd>Ctrl</kbd>/<kbd>Cmd</kbd> + <kbd>Enter</kbd> submits and <kbd>Enter</kbd> adds a
line break. Set `submitKey="enter"` for the usual chat behavior: <kbd>Enter</kbd> submits and
<kbd>Shift</kbd> + <kbd>Enter</kbd> adds a line break (<kbd>Ctrl</kbd>/<kbd>Cmd</kbd> + <kbd>Enter</kbd>
still submits).

```tsx
'use client';

import { useState } from 'react';
import { ChatInput } from '@/ui/ChatInput';

export function ChatInputSubmitOnEnterDemo() {
  const [value, setValue] = useState('');

  return (
    <div className="w-[380px]">
      <ChatInput
        aria-label="Chat message input"
        value={value}
        onValueChange={setValue}
        onSubmit={() => setValue('')}
        submitKey="enter"
      />
    </div>
  );
}
```

| `submitKey`             | <kbd>Enter</kbd> | <kbd>Shift</kbd> + <kbd>Enter</kbd> | <kbd>Ctrl</kbd>/<kbd>Cmd</kbd> + <kbd>Enter</kbd> |
| ----------------------- | ---------------- | ------------------------------------ | -------------------------------------------------- |
| `"mod+enter"` (default) | line break       | line break                           | submits                                            |
| `"enter"`               | submits          | line break                           | submits                                            |

The <kbd>Enter</kbd> that validates a text being composed with an IME (Japanese, Chinese…) never
submits.

## API Reference

### ChatInput

| Prop            | Type                        | Default             | Description                                                |
| --------------- | --------------------------- | ------------------- | ---------------------------------------------------------- |
| `value`         | `string`                    | —                   | **Required.** Controlled input value                       |
| `onValueChange` | `(value: string) => void`   | —                   | **Required.** Called on every keystroke                    |
| `onSubmit`      | `() => void`                | —                   | **Required.** Called when the form is submitted            |
| `placeholder`   | `string`                    | `"Ask a question?"` | Input placeholder text (also used as `aria-label`)         |
| `submitKey`     | `"mod+enter" \| "enter"`    | `"mod+enter"`       | Keyboard shortcut that submits the message, see [Submit with Enter](#submit-with-enter) |
| `isStreaming`   | `boolean`                   | `false`             | While `true`, submitting is disabled and the send button becomes a stop button |
| `onStop`        | `() => void`                | `undefined`         | Called when the stop button, shown while `isStreaming`, is clicked |
| `onAudioClick`  | `() => void`                | `undefined`         | Enables the microphone button. The browser speech recognition is used when available, this is the fallback otherwise |
| `className`     | `string`                    | `undefined`         | Additional CSS classes on the `<form>` element             |

Inherits all `HTMLFormElement` attributes.

## Notes

- The send button is disabled when the input is empty or whitespace only.
- The microphone button, and the browser speech recognition behind it, are **only enabled when `onAudioClick` is provided**.
- While `isStreaming` is `true`, neither the send button nor the keyboard shortcut submits.
- The component is fully controlled — manage state externally.
- Used as the bottom bar inside `ChatWindow`.
