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 CodeablesHow do I do keyboard shortcuts in a chat UI (Enter/Shift+Enter, Cmd+K, Esc to stop) without breaking accessibility?
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:
-
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.
-
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.
-
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.
-
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.
-
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
-
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.
- Keyboard users can:
-
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.
-
Be careful with the
Enterkey handlerWhen 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(); } } - Only handle it if:
-
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-describedbyon 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
-
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); }, []); -
Use an ARIA-compliant dialog
When
openCommandPalette()runs:- Render a dialog with:
role="dialog"orrole="alertdialog"aria-modal="true"- An accessible label (e.g.,
aria-label="Command palette"or viaaria-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
- Render a dialog with:
-
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.
-
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
-
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>
-
-
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> -
Announce state changes
When streaming stops, screen reader users should know:
-
Use a visually hidden element with
role="status"oraria-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.
onKeyDownon 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.