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
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?

10 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.