Answers you can trust, from Codeables

Every page on Codeables is structured and verified — built so people and the AI agents they rely on can trust it. Explore more from the source behind this answer.

Explore Codeables
Verified Source
AI Chat UI Toolkits

How do I do keyboard shortcuts in a chat UI (Enter/Shift+Enter, Cmd+K, Esc to stop) without breaking accessibility?

Assistant-UI10 min read

Most chat UIs need familiar keyboard shortcuts—Enter to send, Shift+Enter for a new line, Cmd+K to open a command palette, Esc to stop streaming or close—and they need to do it without locking out assistive tech users or surprising keyboard-only users. The good news: you can implement all of these in a chat UI in an accessible way if you follow a few predictable patterns.

This guide walks through:

  • Enter vs Shift+Enter in a chat input
  • Cmd+K (or Ctrl+K) command palettes
  • Esc for stopping messages / closing panels
  • How to wire these up without breaking screen readers or keyboard navigation
  • Example implementation patterns you can drop into a React chat UI (including ones powered by assistant-ui or similar libraries)

Core accessibility principles for chat keyboard shortcuts

Before diving into specific shortcuts, keep these principles in mind:

  1. Do not steal keys from native controls
    Let textareas, inputs, and contenteditable elements behave as users expect. Override only where:

    • The pattern is well-known (e.g., Enter to send in a chat)
    • You provide alternative input paths for users who can’t or don’t want to use the shortcut.
  2. Respect focus and context
    A shortcut should only work when it makes sense:

    • Esc should stop streaming only when a message is actively streaming and the chat area has focus.
    • Cmd+K should not fire inside a regular text field where users expect browser or OS behavior.
  3. Stay reachable by keyboard alone
    Anything you can do with a shortcut (send message, stop streaming, open palette) must also be:

    • Reachable via tabs and buttons.
    • Operable with Space/Enter on those buttons.
    • Announced meaningfully to screen readers.
  4. Announce non-trivial state changes
    Use ARIA to notify assistive tech when:

    • A response starts or stops streaming.
    • A command palette opens or closes.
    • An error or validation message appears.
  5. Make shortcuts discoverable and optional

    • Show keyboard hints in the UI (e.g., “Press Enter to send, Shift+Enter for new line”).
    • Provide settings to toggle or customize shortcuts if your app is advanced enough.

Enter vs Shift+Enter in a chat UI

This is the most fundamental chat shortcut pattern.

Recommended behavior

  • Enter
    • Default: submit/send the message.
    • Only when focus is in the chat input.
  • Shift+Enter
    • Insert a newline in the message.
    • Preserve it visually and in the underlying value.

This mirrors most modern chat apps and is widely understood, but it still must be implemented carefully.

Accessibility considerations

  1. Always provide a visible “Send” button

    Not everyone can (or wants to) rely on Enter to send.

    • Keyboard users can:
      • Tab to the Send button and press Space/Enter.
    • Screen reader users:
      • Discover it via Tab or quick navigation by “button.”
    • Touch users:
      • Tap it.

    The button should be:

    • A native <button type="submit"> when wrapped in a <form>
      or
    • A <button> that calls your send handler explicitly.
  2. Use a <textarea> for the chat input

    <textarea> supports multi-line input natively and behaves well with:

    • Screen readers
    • Keyboard navigation
    • IME (Input Method Editors) for non-Latin languages

    Styling can make it look like a single-line field until it grows, but the underlying element should be a textarea.

  3. Be careful with the Enter key handler

    When you intercept Enter, you must:

    • Only handle it if:
      • The focus is inside your message input.
      • Modifier keys match your intended behavior.
    • Prevent the default behavior only when you’re actually sending.

    Minimal logic:

    function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
      if (e.key === "Enter" && !e.shiftKey && !e.altKey && !e.metaKey && !e.ctrlKey) {
        e.preventDefault(); // avoid adding a newline
        sendMessage();
      }
    }
    
  4. Communicate the behavior in the UI

    Add helper text below the input, such as:

    • “Press Enter to send, Shift+Enter for a new line.”
    • For power users: “Press Enter to send, Shift+Enter for a new line. You can also use Cmd+Enter to send.”

    For screen readers, you can also:

    • Reference this helper text using aria-describedby on the textarea.

Cmd+K (or Ctrl+K) command palette in a chat UI

Command palettes (search, jump to thread, run a tool, etc.) are now common in chat UIs. Cmd+K (macOS) or Ctrl+K (Windows) is a familiar way to open them.

Recommended behavior

  • Global-ish, but not truly global
    Cmd+K / Ctrl+K should:
    • Work when the application (or chat area) is focused.
    • Not override behavior inside text fields where users expect to type k.
  • Open a focus-trapped dialog
    Use an accessible dialog for the command palette:
    • Focus moves into the palette input when it opens.
    • Focus returns to the originating element (e.g., chat input) when it closes.
    • Esc closes the palette.

Accessible implementation pattern

  1. Scope your keyboard listener

    You can listen at the document level, but you must ignore events from editable fields:

    function isEditableElement(target: EventTarget | null) {
      if (!(target instanceof HTMLElement)) return false;
      const tag = target.tagName.toLowerCase();
      const editable = target.getAttribute("contenteditable");
      return (
        tag === "input" ||
        tag === "textarea" ||
        editable === "" ||
        editable === "true"
      );
    }
    
    function onGlobalKeyDown(e: KeyboardEvent) {
      const isMac = navigator.platform.toUpperCase().includes("MAC");
      const cmdOrCtrl = isMac ? e.metaKey : e.ctrlKey;
    
      if (!cmdOrCtrl || e.key.toLowerCase() !== "k") return;
      if (isEditableElement(e.target)) return; // don't steal from text inputs
    
      e.preventDefault();
      openCommandPalette();
    }
    
    useEffect(() => {
      window.addEventListener("keydown", onGlobalKeyDown);
      return () => window.removeEventListener("keydown", onGlobalKeyDown);
    }, []);
    
  2. Use an ARIA-compliant dialog

    When openCommandPalette() runs:

    • Render a dialog with:
      • role="dialog" or role="alertdialog"
      • aria-modal="true"
      • An accessible label (e.g., aria-label="Command palette" or via aria-labelledby).
    • Auto-focus the input inside the palette.
    • Trap focus inside the dialog while open.
    • Close on:
      • Esc
      • Click outside (optional, but common)
      • Explicit close button
  3. Announce open/close to screen readers

    The dialog semantics handle much of this, but ensure:

    • The label is meaningful (e.g., “Command palette” instead of “Search” if it does more than search).
    • Focus returns to a logical place (usually the chat input) when the dialog closes.
  4. Provide an alternate way to open the palette

    Because some users can’t or don’t want to press Cmd+K:

    • Add a “Command palette” button in the UI (e.g., in the header).
    • Show the shortcut next to the label: Command palette (Cmd+K).

    This ensures full operability via mouse or keyboard without shortcuts.


Esc to stop streaming or close UI elements

The Esc key is commonly used to:

  • Stop a streaming response
  • Cancel an in-progress operation
  • Close modals/panels (command palette, settings, thread picker, etc.)

You can use Esc for both functions as long as context is clear and consistent.

Pattern 1: Esc to stop a streaming AI response

  1. Provide a visible “Stop” button

    Just like with Enter-to-send, don’t rely solely on Esc:

    • Show a Stop button (e.g., “Stop”, “Cancel”, or an icon).

    • Keep it in the same place where the “Send” button appears when idle.

    • Make it a <button> with a clear accessible label, like:

      <button
        type="button"
        onClick={stopStreaming}
        aria-label="Stop AI response"
      >
        Stop
      </button>
      
  2. Scope Esc to the chat area and streaming state

    Only listen for Esc when:

    • A message is currently streaming.
    • Focus is inside the chat UI (or the chat container has a known focus state).

    Example:

    function onChatKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
      if (e.key === "Escape" && isStreaming) {
        e.preventDefault();
        stopStreaming();
      }
    }
    
    // Attach to a focusable wrapper around the chat
    <div
      tabIndex={-1}
      onKeyDown={onChatKeyDown}
      aria-label="Chat interface"
    >
      {/* Chat content */}
    </div>
    
  3. Announce state changes

    When streaming stops, screen reader users should know:

    • Use a visually hidden element with role="status" or aria-live="polite" that updates its content:

      <div aria-live="polite" className="sr-only">
        {isStreaming ? "AI is responding…" : "AI response stopped."}
      </div>
      

Pattern 2: Esc to close modals/panels

For elements like:

  • Command palette
  • Settings dialog
  • Thread history side panel

Use the standard dialog pattern:

  • Esc closes the dialog.
  • onKeyDown on the dialog captures Esc.
  • Focus returns to the originating element (e.g., chat input).

Example:

function onDialogKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
  if (e.key === "Escape") {
    e.stopPropagation();
    closeDialog();
  }
}

Attach this to the dialog container and ensure that:

  • The dialog is rendered last in the DOM (or in a portal).
  • Background content is inert or not focusable while the dialog is open.

Putting it all together in a React chat UI

Here’s an example structure that combines these patterns into a typical chat UI with:

  • Enter/Shift+Enter handling
  • Cmd+K for a command palette
  • Esc to stop streaming
  • All backed by accessible DOM semantics
function ChatUI() {
  const [message, setMessage] = useState("");
  const [isStreaming, setIsStreaming] = useState(false);
  const [isPaletteOpen, setIsPaletteOpen] = useState(false);
  const chatRef = useRef<HTMLDivElement | null>(null);

  // Cmd+K / Ctrl+K global handler (outside inputs)
  useEffect(() => {
    function onGlobalKeyDown(e: KeyboardEvent) {
      const isMac = navigator.platform.toUpperCase().includes("MAC");
      const cmdOrCtrl = isMac ? e.metaKey : e.ctrlKey;

      if (!cmdOrCtrl || e.key.toLowerCase() !== "k") return;
      if (isEditableElement(e.target)) return;
      e.preventDefault();
      setIsPaletteOpen(true);
    }

    window.addEventListener("keydown", onGlobalKeyDown);
    return () => window.removeEventListener("keydown", onGlobalKeyDown);
  }, []);

  function sendMessage() {
    if (!message.trim()) return;
    // trigger your LLM call here (e.g., via assistant-ui or your own backend)
    setIsStreaming(true);
    // ...
  }

  function stopStreaming() {
    setIsStreaming(false);
    // also cancel the underlying request / stream if applicable
  }

  function onChatKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
    if (e.key === "Escape" && isStreaming) {
      e.preventDefault();
      stopStreaming();
    }
  }

  function onInputKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
    if (
      e.key === "Enter" &&
      !e.shiftKey &&
      !e.altKey &&
      !e.metaKey &&
      !e.ctrlKey
    ) {
      e.preventDefault();
      sendMessage();
    }
  }

  return (
    <div
      ref={chatRef}
      tabIndex={-1}
      onKeyDown={onChatKeyDown}
      aria-label="Chat interface"
    >
      {/* Messages list here */}

      {/* Live-region for streaming status */}
      <div aria-live="polite" className="sr-only">
        {isStreaming ? "AI is responding…" : "AI response stopped."}
      </div>

      {/* Input + actions */}
      <form
        onSubmit={(e) => {
          e.preventDefault();
          sendMessage();
        }}
      >
        <label htmlFor="chat-input" className="sr-only">
          Message
        </label>
        <textarea
          id="chat-input"
          value={message}
          onChange={(e) => setMessage(e.target.value)}
          onKeyDown={onInputKeyDown}
          rows={1}
          placeholder="Send a message…"
          aria-describedby="chat-input-help"
        />

        <div id="chat-input-help" className="help-text">
          Press Enter to send, Shift+Enter for a new line. Press Esc to stop the
          AI response.
        </div>

        {isStreaming ? (
          <button
            type="button"
            onClick={stopStreaming}
            aria-label="Stop AI response"
          >
            Stop
          </button>
        ) : (
          <button type="submit">Send</button>
        )}
      </form>

      {/* Command palette button for discoverability */}
      <button type="button" onClick={() => setIsPaletteOpen(true)}>
        Command palette <span aria-hidden="true">(Cmd+K)</span>
      </button>

      {isPaletteOpen && (
        <CommandPalette onClose={() => setIsPaletteOpen(false)} />
      )}
    </div>
  );
}

The CommandPalette component should be a proper dialog with:

  • role="dialog", aria-modal="true"
  • Focus trap
  • Esc handling
  • Return focus on close

This pattern aligns well with React-based chat libraries like assistant-ui, where you can tap into existing state management (streaming, interruptions, retries, multi-turn conversations) and layer these keyboard shortcuts on top of the production-ready components.


Checklist: keyboard shortcuts in a chat UI without breaking accessibility

Use this as a quick reference when implementing Enter/Shift+Enter, Cmd+K, and Esc:

Enter / Shift+Enter

  • Chat input is a <textarea> (not a div with contenteditable) unless you have a strong reason otherwise.
  • Enter sends the message only when the chat input is focused.
  • Shift+Enter inserts a newline.
  • A visible Send button exists and is reachable via Tab + Space/Enter.
  • Helper text explains the behavior and is linked via aria-describedby.

Cmd+K / Ctrl+K

  • Cmd+K / Ctrl+K only opens the command palette when the app is focused.
  • Events from inputs/textareas/contenteditable elements are ignored.
  • The command palette is an ARIA-compliant dialog with focus trapping.
  • A button or menu item exists to open the palette without the shortcut.
  • Esc closes the palette and returns focus to a logical element (e.g., chat input).

Esc to stop / close

  • A visible Stop button exists when streaming, with aria-label.
  • Esc stops streaming only when the chat is focused and streaming is active.
  • State changes (start/stop streaming) are announced via aria-live.
  • Esc also closes dialogs and panels using standard dialog patterns.

By following these patterns, you get a chat UI whose keyboard shortcuts feel as smooth as popular chat apps, while still working correctly for screen readers, keyboard-only users, and users on assistive technologies.

How do I do keyboard shortcuts in a chat UI (Enter/Shift+Enter, Cmd+K, Esc to stop) without breaking accessibility? | AI Chat UI Toolkits | Codeables | Codeables