Repository: streamlit/streamlit
Pull request: Add chat component
## Describe your changes

This PR adds two new commands optimized for a chat UI:
- `st.chat_message`: Displays a container styled like a chat message with a configurable avatar.
- `st.chat_input`: An input widget optimized for chat apps. E.g. it autogrows, sends the message on `enter`, clears the message after it is submitted and has a submit button.

## GitHub Issue Link

Closes: https://github.com/streamlit/streamlit/issues/6320

## Testing Plan

Implemented various e2e, Python & Typescript unit tests.

---

**Contribution License Agreement**

By submitting this pull request you agree that all contributions to this project are made under the Apache 2.0 license.


Problem statement:
Chat component
Since ChatGPT or some other LLM modules are so popular recently, lot of users want to make a web app with conversational interface. Now if I want to make one with streamlit, I can use streamlit component st-chat and st.text_input. But the user experience is not as good as streamlit official components, so I'm wondering will you make some official component for conversational interface?

---

Community voting on feature requests enables the Streamlit team to understand which features are most important to our users.

**If you'd like the Streamlit team to prioritize this feature request, please use the 👍 (thumbs up emoji) reaction in response to the initial post.**

Hints from issue discussion:
Yup we are discussing this at the moment! No concrete plan yet but stay tuned. We're all just as excited about GPT as you are 😉

Commit message: Fix reStructuredText formatting issues

Patch to review:
diff --git a/e2e/scripts/st_chat_input.py b/e2e/scripts/st_chat_input.py
new file mode 100644
index 000000000000..b2411d648295
--- /dev/null
+++ b/e2e/scripts/st_chat_input.py
@@ -0,0 +1,17 @@
+# Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import streamlit as st
+
+st.chat_input(placeholder="Please enter your message here...", max_chars=200)
diff --git a/e2e/scripts/st_chat_message.py b/e2e/scripts/st_chat_message.py
new file mode 100644
index 000000000000..6deb542168a2
--- /dev/null
+++ b/e2e/scripts/st_chat_message.py
@@ -0,0 +1,69 @@
+# Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import numpy as np
+import pandas as pd
+
+import streamlit as st
+
+np.random.seed(0)
+
+
+# Generate a random dataframe
+df = pd.DataFrame(
+    np.random.randn(5, 5),
+    columns=("col_%d" % i for i in range(5)),
+)
+
+
+with st.chat_message("user"):
+    st.write("Hello…")
+
+with st.chat_message("assistant"):
+    st.write(
+        """
+Hello, here is a code snippet:
+
+```python
+import streamlit as st
+with st.chat_message("assistant"):
+     st.write("Hello, here is a code snippet...")
+```
+"""
+    )
+
+with st.chat_message("user", avatar="🧑"):
+    st.write(
+        """
+Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris tristique est
+at tincidunt pul vinar. Nam pulvinar neque sapien, eu pellentesque metus pellentesque
+at. Ut et dui molestie, iaculis magna sed.
+"""
+    )
+
+with st.chat_message("dog", avatar="https://static.streamlit.io/examples/dog.jpg"):
+    st.write("Woof woof! I'm a dog and I like charts:")
+    st.line_chart(df, use_container_width=True)
+
+cat = st.chat_message("cat", avatar="https://static.streamlit.io/examples/cat.jpg")
+cat.write("I'm a cat and I like this dataset:")
+cat.dataframe(df, use_container_width=True)
+cat.text_input("What's your name?")
+
+
+with st.chat_message("Bot"):
+    with st.expander("See more", expanded=True):
+        st.write("Lorem ipsum dolor sit amet")
+
+st.chat_message("user")
diff --git a/e2e/specs/st_chat_input.spec.js b/e2e/specs/st_chat_input.spec.js
new file mode 100644
index 000000000000..a30f74b81c9c
--- /dev/null
+++ b/e2e/specs/st_chat_input.spec.js
@@ -0,0 +1,76 @@
+/**
+ * Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+describe("st.chat_input", () => {
+  before(() => {
+    cy.loadApp("http://localhost:3000/");
+    cy.prepForElementSnapshots();
+    cy.get(".stChatInputContainer").should("have.length", 1);
+  });
+
+  it("renders the default state correctly", () => {
+    cy.get(".stChatInputContainer").matchThemedSnapshots("chatInput");
+  });
+
+  it("renders the focus state correctly", () => {
+    // Light Theme:
+    cy.get(".stChatInputContainer textarea").click();
+    cy.get(".stChatInputContainer").matchImageSnapshot("chatInput-focused-light");
+    // Dark Theme:
+    cy.changeTheme("Dark")
+    // refocus
+    cy.get(".stChatInputContainer textarea").click();
+    cy.get(".stChatInputContainer").matchImageSnapshot("chatInput-focused-dark");
+  });
+
+  it("Shift+Enter creates a new line", () => {
+    // Clear the text input & Shift+Enter
+    cy.get(".stChatInputContainer textarea").clear().type(`{shift+enter}New Line`);
+    cy.get(".stChatInputContainer").matchThemedSnapshots("chatInput-shiftEnter");
+  });
+
+  it("Enter submits/clears input", () => {
+    // Types a message & then Enter
+    cy.get(".stChatInputContainer textarea").clear().type(`Corgi{enter}`);
+    cy.get('.stChatInputContainer textarea').invoke('val').should('eq', '');
+  });
+
+  it("can click button to submit & clear input", () => {
+    // Types a message
+    cy.get(".stChatInputContainer textarea").clear().type(`Corgi`);
+    // Clicks the submit button
+    cy.get(".stChatInputContainer button").click();
+    cy.get('.stChatInputContainer textarea').invoke('val').should('eq', '');
+  });
+
+  it("grows when input text is long & shrinks when deleted", () => {
+    // Type a long message (but < 200 chars)
+    cy.get(".stChatInputContainer textarea").type(`Lorem ipsum dolor amet, consectetur adipiscing elit. Mauris tristique est at tincidunt pul vinar. Nam pulvinar neque sapien, eu pellentesque metus pellentesque at. Ut et dui molestie, iaculis magna.`);
+    cy.get(".stChatInputContainer").matchThemedSnapshots("chatInput-grows");
+
+    // Remove characters on third line
+    cy.get(".stChatInputContainer textarea").type('{backspace}{backspace}{backspace}{backspace}{backspace}{backspace}{backspace}');
+    cy.get(".stChatInputContainer").matchThemedSnapshots("chatInput-shrinks");
+  });
+
+  it("max characters enforced", () => {
+    // Try to type a message over the 200 char limit
+    cy.get(".stChatInputContainer textarea").clear().type(`Lorem ipsum dolor amet, consectetur adipiscing elit. Mauris tristique est at tincidunt pul vinar. Nam pulvinar neque sapien, eu pellentesque metus pellentesque at. Ut et dui molestie, iaculis magna sed. This text shouldn't appear in the input.`);
+    // Check that the message is truncated
+    cy.get('.stChatInputContainer textarea').invoke('val').should('eq', 'Lorem ipsum dolor amet, consectetur adipiscing elit. Mauris tristique est at tincidunt pul vinar. Nam pulvinar neque sapien, eu pellentesque metus pellentesque at. Ut et dui molestie, iaculis magna se')
+    cy.get(".stChatInputContainer").matchThemedSnapshots("chatInput-maxChars");
+  });
+});
diff --git a/e2e/specs/st_chat_message.spec.js b/e2e/specs/st_chat_message.spec.js
new file mode 100644
index 000000000000..b414b9d41c7b
--- /dev/null
+++ b/e2e/specs/st_chat_message.spec.js
@@ -0,0 +1,32 @@
+/**
+ * Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+describe("st.chat_message", () => {
+  before(() => {
+    cy.loadApp("http://localhost:3000/");
+    cy.prepForElementSnapshots();
+    // Make the toolbar disappear to not interfere with snapshots (in wide mode)
+    cy.get("[data-testid='stToolbar']").invoke("css", "opacity", 0);
+  });
+
+  it("renders chat messages correctly", () => {
+    cy.get(".stChatMessage").should("have.length", 7);
+
+    cy.get(".stChatMessage").each((el, idx) => {
+      return cy.wrap(el).matchThemedSnapshots("chat_message-" + idx);
+    });
+  });
+});
diff --git a/frontend/cypress/snapshots/linux/2x/st_chat_input.spec.js/chatInput-dark.snap.png b/frontend/cypress/snapshots/linux/2x/st_chat_input.spec.js/chatInput-dark.snap.png
new file mode 100644
index 000000000000..4a61ca6ae14d
Binary files /dev/null and b/frontend/cypress/snapshots/linux/2x/st_chat_input.spec.js/chatInput-dark.snap.png differ
diff --git a/frontend/cypress/snapshots/linux/2x/st_chat_input.spec.js/chatInput-focused-dark.snap.png b/frontend/cypress/snapshots/linux/2x/st_chat_input.spec.js/chatInput-focused-dark.snap.png
new file mode 100644
index 000000000000..c105f308c869
Binary files /dev/null and b/frontend/cypress/snapshots/linux/2x/st_chat_input.spec.js/chatInput-focused-dark.snap.png differ
diff --git a/frontend/cypress/snapshots/linux/2x/st_chat_input.spec.js/chatInput-focused-light.snap.png b/frontend/cypress/snapshots/linux/2x/st_chat_input.spec.js/chatInput-focused-light.snap.png
new file mode 100644
index 000000000000..26c52ddaa4ad
Binary files /dev/null and b/frontend/cypress/snapshots/linux/2x/st_chat_input.spec.js/chatInput-focused-light.snap.png differ
diff --git a/frontend/cypress/snapshots/linux/2x/st_chat_input.spec.js/chatInput-grows-dark.snap.png b/frontend/cypress/snapshots/linux/2x/st_chat_input.spec.js/chatInput-grows-dark.snap.png
new file mode 100644
index 000000000000..790fe325f745
Binary files /dev/null and b/frontend/cypress/snapshots/linux/2x/st_chat_input.spec.js/chatInput-grows-dark.snap.png differ
diff --git a/frontend/cypress/snapshots/linux/2x/st_chat_input.spec.js/chatInput-grows.snap.png b/frontend/cypress/snapshots/linux/2x/st_chat_input.spec.js/chatInput-grows.snap.png
new file mode 100644
index 000000000000..59e0a1c07bc3
Binary files /dev/null and b/frontend/cypress/snapshots/linux/2x/st_chat_input.spec.js/chatInput-grows.snap.png differ
diff --git a/frontend/cypress/snapshots/linux/2x/st_chat_input.spec.js/chatInput-maxChars-dark.snap.png b/frontend/cypress/snapshots/linux/2x/st_chat_input.spec.js/chatInput-maxChars-dark.snap.png
new file mode 100644
index 000000000000..7c6d877c77e2
Binary files /dev/null and b/frontend/cypress/snapshots/linux/2x/st_chat_input.spec.js/chatInput-maxChars-dark.snap.png differ
diff --git a/frontend/cypress/snapshots/linux/2x/st_chat_input.spec.js/chatInput-maxChars.snap.png b/frontend/cypress/snapshots/linux/2x/st_chat_input.spec.js/chatInput-maxChars.snap.png
new file mode 100644
index 000000000000..dff5b9c438bf
Binary files /dev/null and b/frontend/cypress/snapshots/linux/2x/st_chat_input.spec.js/chatInput-maxChars.snap.png differ
diff --git a/frontend/cypress/snapshots/linux/2x/st_chat_input.spec.js/chatInput-shiftEnter-dark.snap.png b/frontend/cypress/snapshots/linux/2x/st_chat_input.spec.js/chatInput-shiftEnter-dark.snap.png
new file mode 100644
index 000000000000..7f9d296c1383
Binary files /dev/null and b/frontend/cypress/snapshots/linux/2x/st_chat_input.spec.js/chatInput-shiftEnter-dark.snap.png differ
diff --git a/frontend/cypress/snapshots/linux/2x/st_chat_input.spec.js/chatInput-shiftEnter.snap.png b/frontend/cypress/snapshots/linux/2x/st_chat_input.spec.js/chatInput-shiftEnter.snap.png
new file mode 100644
index 000000000000..71bed4b8e916
Binary files /dev/null and b/frontend/cypress/snapshots/linux/2x/st_chat_input.spec.js/chatInput-shiftEnter.snap.png differ
diff --git a/frontend/cypress/snapshots/linux/2x/st_chat_input.spec.js/chatInput-shrinks-dark.snap.png b/frontend/cypress/snapshots/linux/2x/st_chat_input.spec.js/chatInput-shrinks-dark.snap.png
new file mode 100644
index 000000000000..afa4f4391be0
Binary files /dev/null and b/frontend/cypress/snapshots/linux/2x/st_chat_input.spec.js/chatInput-shrinks-dark.snap.png differ
diff --git a/frontend/cypress/snapshots/linux/2x/st_chat_input.spec.js/chatInput-shrinks.snap.png b/frontend/cypress/snapshots/linux/2x/st_chat_input.spec.js/chatInput-shrinks.snap.png
new file mode 100644
index 000000000000..4d2e4e616c55
Binary files /dev/null and b/frontend/cypress/snapshots/linux/2x/st_chat_input.spec.js/chatInput-shrinks.snap.png differ
diff --git a/frontend/cypress/snapshots/linux/2x/st_chat_input.spec.js/chatInput.snap.png b/frontend/cypress/snapshots/linux/2x/st_chat_input.spec.js/chatInput.snap.png
new file mode 100644
index 000000000000..418aad3cfd42
Binary files /dev/null and b/frontend/cypress/snapshots/linux/2x/st_chat_input.spec.js/chatInput.snap.png differ
diff --git a/frontend/cypress/snapshots/linux/2x/st_chat_message.spec.js/chat_message-0-dark.snap.png b/frontend/cypress/snapshots/linux/2x/st_chat_message.spec.js/chat_message-0-dark.snap.png
new file mode 100644
index 000000000000..44405daa281e
Binary files /dev/null and b/frontend/cypress/snapshots/linux/2x/st_chat_message.spec.js/chat_message-0-dark.snap.png differ
diff --git a/frontend/cypress/snapshots/linux/2x/st_chat_message.spec.js/chat_message-0.snap.png b/frontend/cypress/snapshots/linux/2x/st_chat_message.spec.js/chat_message-0.snap.png
new file mode 100644
index 000000000000..85d786c4796f
Binary files /dev/null and b/frontend/cypress/snapshots/linux/2x/st_chat_message.spec.js/chat_message-0.snap.png differ
diff --git a/frontend/cypress/snapshots/linux/2x/st_chat_message.spec.js/chat_message-1-dark.snap.png b/frontend/cypress/snapshots/linux/2x/st_chat_message.spec.js/chat_message-1-dark.snap.png
new file mode 100644
index 000000000000..2cf91a66c53d
Binary files /dev/null and b/frontend/cypress/snapshots/linux/2x/st_chat_message.spec.js/chat_message-1-dark.snap.png differ
diff --git a/frontend/cypress/snapshots/linux/2x/st_chat_message.spec.js/chat_message-1.snap.png b/frontend/cypress/snapshots/linux/2x/st_chat_message.spec.js/chat_message-1.snap.png
new file mode 100644
index 000000000000..c703eea15120
Binary files /dev/null and b/frontend/cypress/snapshots/linux/2x/st_chat_message.spec.js/chat_message-1.snap.png differ
diff --git a/frontend/cypress/snapshots/linux/2x/st_chat_message.spec.js/chat_message-2-dark.snap.png b/frontend/cypress/snapshots/linux/2x/st_chat_message.spec.js/chat_message-2-dark.snap.png
new file mode 100644
index 000000000000..06cb3a2a9609
Binary files /dev/null and b/frontend/cypress/snapshots/linux/2x/st_chat_message.spec.js/chat_message-2-dark.snap.png differ
diff --git a/frontend/cypress/snapshots/linux/2x/st_chat_message.spec.js/chat_message-2.snap.png b/frontend/cypress/snapshots/linux/2x/st_chat_message.spec.js/chat_message-2.snap.png
new file mode 100644
index 000000000000..b3032dd46bde
Binary files /dev/null and b/frontend/cypress/snapshots/linux/2x/st_chat_message.spec.js/chat_message-2.snap.png differ
diff --git a/frontend/cypress/snapshots/linux/2x/st_chat_message.spec.js/chat_message-3-dark.snap.png b/frontend/cypress/snapshots/linux/2x/st_chat_message.spec.js/chat_message-3-dark.snap.png
new file mode 100644
index 000000000000..f547321f5c8b
Binary files /dev/null and b/frontend/cypress/snapshots/linux/2x/st_chat_message.spec.js/chat_message-3-dark.snap.png differ
diff --git a/frontend/cypress/snapshots/linux/2x/st_chat_message.spec.js/chat_message-3.snap.png b/frontend/cypress/snapshots/linux/2x/st_chat_message.spec.js/chat_message-3.snap.png
new file mode 100644
index 000000000000..f9b5917cbe5b
Binary files /dev/null and b/frontend/cypress/snapshots/linux/2x/st_chat_message.spec.js/chat_message-3.snap.png differ
diff --git a/frontend/cypress/snapshots/linux/2x/st_chat_message.spec.js/chat_message-4-dark.snap.png b/frontend/cypress/snapshots/linux/2x/st_chat_message.spec.js/chat_message-4-dark.snap.png
new file mode 100644
index 000000000000..58785f58ea86
Binary files /dev/null and b/frontend/cypress/snapshots/linux/2x/st_chat_message.spec.js/chat_message-4-dark.snap.png differ
diff --git a/frontend/cypress/snapshots/linux/2x/st_chat_message.spec.js/chat_message-4.snap.png b/frontend/cypress/snapshots/linux/2x/st_chat_message.spec.js/chat_message-4.snap.png
new file mode 100644
index 000000000000..d04f1a89b61e
Binary files /dev/null and b/frontend/cypress/snapshots/linux/2x/st_chat_message.spec.js/chat_message-4.snap.png differ
diff --git a/frontend/cypress/snapshots/linux/2x/st_chat_message.spec.js/chat_message-5-dark.snap.png b/frontend/cypress/snapshots/linux/2x/st_chat_message.spec.js/chat_message-5-dark.snap.png
new file mode 100644
index 000000000000..7b8c321cb17f
Binary files /dev/null and b/frontend/cypress/snapshots/linux/2x/st_chat_message.spec.js/chat_message-5-dark.snap.png differ
diff --git a/frontend/cypress/snapshots/linux/2x/st_chat_message.spec.js/chat_message-5.snap.png b/frontend/cypress/snapshots/linux/2x/st_chat_message.spec.js/chat_message-5.snap.png
new file mode 100644
index 000000000000..10430f47bbdf
Binary files /dev/null and b/frontend/cypress/snapshots/linux/2x/st_chat_message.spec.js/chat_message-5.snap.png differ
diff --git a/frontend/cypress/snapshots/linux/2x/st_chat_message.spec.js/chat_message-6-dark.snap.png b/frontend/cypress/snapshots/linux/2x/st_chat_message.spec.js/chat_message-6-dark.snap.png
new file mode 100644
index 000000000000..719f05973f21
Binary files /dev/null and b/frontend/cypress/snapshots/linux/2x/st_chat_message.spec.js/chat_message-6-dark.snap.png differ
diff --git a/frontend/cypress/snapshots/linux/2x/st_chat_message.spec.js/chat_message-6.snap.png b/frontend/cypress/snapshots/linux/2x/st_chat_message.spec.js/chat_message-6.snap.png
new file mode 100644
index 000000000000..6b5b3e3e0449
Binary files /dev/null and b/frontend/cypress/snapshots/linux/2x/st_chat_message.spec.js/chat_message-6.snap.png differ
diff --git a/frontend/src/app/components/AppView/AppView.test.tsx b/frontend/src/app/components/AppView/AppView.test.tsx
index 4e39bbe1ef86..b9ef3cdce246 100644
--- a/frontend/src/app/components/AppView/AppView.test.tsx
+++ b/frontend/src/app/components/AppView/AppView.test.tsx
@@ -15,7 +15,19 @@
  */
 
 import React from "react"
-import { Block as BlockProto, ForwardMsgMetadata } from "src/lib/proto"
+import { screen } from "@testing-library/react"
+import "@testing-library/jest-dom"
+import {
+  AppContext,
+  Props as AppContextProps,
+} from "src/app/components/AppContext"
+import {
+  Block as BlockProto,
+  ForwardMsgMetadata,
+  PageConfig,
+  Element,
+  ChatInput as ChatInputProto,
+} from "src/lib/proto"
 import { ScriptRunState } from "src/lib/ScriptRunState"
 import { BlockNode, ElementNode, AppRoot } from "src/lib/AppNode"
 import { FileUploadClient } from "src/lib/FileUploadClient"
@@ -26,9 +38,25 @@ import {
 import { makeElementWithInfoText } from "src/lib/util/utils"
 import { ComponentRegistry } from "src/lib/components/widgets/CustomComponent"
 import { mockEndpoints, mockSessionInfo } from "src/lib/mocks/mocks"
-import { render, shallow } from "src/lib/test_util"
+import { render } from "src/lib/test_util"
 import AppView, { AppViewProps } from "./AppView"
 
+function getContextOutput(context: Partial<AppContextProps>): AppContextProps {
+  return {
+    wideMode: false,
+    initialSidebarState: PageConfig.SidebarState.AUTO,
+    embedded: false,
+    showPadding: false,
+    disableScrolling: false,
+    showFooter: false,
+    showToolbar: false,
+    showColoredLine: false,
+    pageLinkBaseUrl: "",
+    sidebarChevronDownshift: 0,
+    ...context,
+  }
+}
+
 function getProps(props: Partial<AppViewProps> = {}): AppViewProps {
   const formsData = createFormsData()
 
@@ -68,17 +96,15 @@ describe("AppView element", () => {
   })
 
   it("renders without crashing", () => {
-    const props = getProps()
-    const wrapper = shallow(<AppView {...props} />)
-
-    expect(wrapper).toBeDefined()
+    render(<AppView {...getProps()} />)
   })
 
   it("does not render a sidebar when there are no elements and only one page", () => {
     const props = getProps()
-    const wrapper = shallow(<AppView {...props} />)
+    render(<AppView {...props} />)
 
-    expect(wrapper.find("[data-testid='stSidebar']").exists()).toBe(false)
+    const sidebar = screen.queryByTestId("stSidebar")
+    expect(sidebar).not.toBeInTheDocument()
   })
 
   it("renders a sidebar when there are elements and only one page", () => {
@@ -98,14 +124,10 @@ describe("AppView element", () => {
     const props = getProps({
       elements: new AppRoot(new BlockNode([main, sidebar])),
     })
-    const wrapper = shallow(<AppView {...props} />)
+    render(<AppView {...props} />)
 
-    expect(wrapper.find("ThemedSidebar").exists()).toBe(true)
-    expect(wrapper.find("ThemedSidebar").prop("hasElements")).toBe(true)
-    expect(wrapper.find("ThemedSidebar").prop("appPages")).toHaveLength(1)
-    expect(wrapper.find("ThemedSidebar").prop("currentPageScriptHash")).toBe(
-      "main_page_script_hash"
-    )
+    const sidebarDOMElement = screen.queryByTestId("stSidebar")
+    expect(sidebarDOMElement).toBeInTheDocument()
   })
 
   it("renders a sidebar when there are no elements but multiple pages", () => {
@@ -113,11 +135,10 @@ describe("AppView element", () => {
       { pageName: "streamlit_app", pageScriptHash: "page_hash" },
       { pageName: "streamlit_app2", pageScriptHash: "page_hash2" },
     ]
-    const wrapper = shallow(<AppView {...getProps({ appPages })} />)
+    render(<AppView {...getProps({ appPages })} />)
 
-    expect(wrapper.find("ThemedSidebar").exists()).toBe(true)
-    expect(wrapper.find("ThemedSidebar").prop("hasElements")).toBe(false)
-    expect(wrapper.find("ThemedSidebar").prop("appPages")).toEqual(appPages)
+    const sidebarDOMElement = screen.queryByTestId("stSidebar")
+    expect(sidebarDOMElement).toBeInTheDocument()
   })
 
   it("renders a sidebar when there are elements and multiple pages", () => {
@@ -142,11 +163,10 @@ describe("AppView element", () => {
       elements: new AppRoot(new BlockNode([main, sidebar])),
       appPages,
     })
-    const wrapper = shallow(<AppView {...props} />)
+    render(<AppView {...props} />)
 
-    expect(wrapper.find("ThemedSidebar").exists()).toBe(true)
-    expect(wrapper.find("ThemedSidebar").prop("hasElements")).toBe(true)
-    expect(wrapper.find("ThemedSidebar").prop("appPages")).toEqual(appPages)
+    const sidebarDOMElement = screen.queryByTestId("stSidebar")
+    expect(sidebarDOMElement).toBeInTheDocument()
   })
 
   it("does not render the sidebar if there are no elements, multiple pages but hideSidebarNav is true", () => {
@@ -158,65 +178,86 @@ describe("AppView element", () => {
       appPages,
       hideSidebarNav: true,
     })
-    const wrapper = shallow(<AppView {...props} />)
+    render(<AppView {...props} />)
 
-    expect(wrapper.find("ThemedSidebar").exists()).toBe(false)
+    const sidebar = screen.queryByTestId("stSidebar")
+    expect(sidebar).not.toBeInTheDocument()
   })
 
   it("does not render the wide class", () => {
-    jest
-      .spyOn(React, "useContext")
-      .mockImplementation(() => ({ wideMode: false, embedded: false }))
-    const wrapper = shallow(<AppView {...getProps()} />)
+    const realUseContext = React.useContext
+    jest.spyOn(React, "useContext").mockImplementation(input => {
+      if (input === AppContext) {
+        return getContextOutput({ wideMode: false, embedded: false })
+      }
+
+      return realUseContext(input)
+    })
 
-    expect(
-      wrapper.find("StyledAppViewBlockContainer").prop("isWideMode")
-    ).toBe(false)
+    const main = new BlockNode([], new BlockProto({ allowEmpty: true }))
+    const sidebar = new BlockNode([], new BlockProto({ allowEmpty: true }))
+
+    const props = getProps({
+      elements: new AppRoot(new BlockNode([main, sidebar])),
+    })
+    const { getByTestId } = render(<AppView {...props} />)
 
-    expect(wrapper.find("StyledAppViewFooter").prop("isWideMode")).toBe(false)
+    const style = window.getComputedStyle(getByTestId("block-container"))
+    expect(style.maxWidth).not.toEqual("initial")
   })
 
   it("does render the wide class when specified", () => {
-    jest
-      .spyOn(React, "useContext")
-      .mockImplementation(() => ({ wideMode: true, embedded: false }))
-    const wrapper = shallow(<AppView {...getProps()} />)
+    const realUseContext = React.useContext
+    jest.spyOn(React, "useContext").mockImplementation(input => {
+      if (input === AppContext) {
+        return getContextOutput({ wideMode: true, embedded: false })
+      }
 
-    expect(
-      wrapper.find("StyledAppViewBlockContainer").prop("isWideMode")
-    ).toBe(true)
+      return realUseContext(input)
+    })
+    const { getByTestId } = render(<AppView {...getProps()} />)
+    const style = window.getComputedStyle(getByTestId("block-container"))
 
-    expect(wrapper.find("StyledAppViewFooter").prop("isWideMode")).toBe(true)
+    expect(style.maxWidth).toEqual("initial")
   })
 
   it("opens link to streamlit.io in new tab", () => {
-    const wrapper = shallow(<AppView {...getProps()} />)
-    expect(wrapper.find("StyledAppViewFooterLink").props()).toEqual(
-      expect.objectContaining({
-        href: "//streamlit.io",
-        target: "_blank",
-      })
-    )
+    render(<AppView {...getProps()} />)
+    const link = screen.getByRole("link", { name: "Streamlit" })
+    expect(link).toHaveAttribute("href", "//streamlit.io")
+    expect(link).toHaveAttribute("target", "_blank")
   })
 
   it("renders the Spacer and Footer when not embedded", () => {
-    jest
-      .spyOn(React, "useContext")
-      .mockImplementation(() => ({ wideMode: false, embedded: false }))
-    const wrapper = shallow(<AppView {...getProps()} />)
+    const realUseContext = React.useContext
+    jest.spyOn(React, "useContext").mockImplementation(input => {
+      if (input === AppContext) {
+        return getContextOutput({ wideMode: false, embedded: false })
+      }
+
+      return realUseContext(input)
+    })
+
+    const { getByRole, getByTestId } = render(<AppView {...getProps()} />)
 
-    expect(wrapper.find("StyledAppViewBlockSpacer").exists()).toBe(true)
-    expect(wrapper.find("StyledAppViewFooter").exists()).toBe(true)
+    expect(getByTestId("AppViewBlockSpacer")).toBeInTheDocument()
+    expect(getByRole("contentinfo")).toBeInTheDocument()
   })
 
   it("does not render the Spacer and Footer when embedded", () => {
-    jest
-      .spyOn(React, "useContext")
-      .mockImplementation(() => ({ wideMode: false, embedded: true }))
-    const wrapper = shallow(<AppView {...getProps()} />)
+    const realUseContext = React.useContext
+    jest.spyOn(React, "useContext").mockImplementation(input => {
+      if (input === AppContext) {
+        return getContextOutput({ wideMode: false, embedded: true })
+      }
 
-    expect(wrapper.find("StyledAppViewBlockSpacer").exists()).toBe(false)
-    expect(wrapper.find("StyledAppViewFooter").exists()).toBe(false)
+      return realUseContext(input)
+    })
+
+    const { queryByRole, queryByTestId } = render(<AppView {...getProps()} />)
+
+    expect(queryByTestId("AppViewBlockSpacer")).not.toBeInTheDocument()
+    expect(queryByRole("contentinfo")).not.toBeInTheDocument()
   })
 
   describe("when window.location.hash changes", () => {
@@ -236,4 +277,43 @@ describe("AppView element", () => {
       })
     })
   })
+
+  it("does not render a Scroll To Bottom container when no chat input is present", () => {
+    const props = getProps()
+    render(<AppView {...props} />)
+
+    const stbContainer = screen.queryByTestId("ScrollToBottomContainer")
+    expect(stbContainer).not.toBeInTheDocument()
+  })
+
+  it("renders a Scroll To Bottom container when a chat input is present", () => {
+    const chatInputElement = new ElementNode(
+      new Element({
+        chatInput: {
+          id: "123",
+          placeholder: "Enter Text Here",
+          disabled: false,
+          default: "",
+          position: ChatInputProto.Position.BOTTOM,
+        },
+      }),
+      ForwardMsgMetadata.create({}),
+      "no script run id"
+    )
+
+    const sidebar = new BlockNode([], new BlockProto({ allowEmpty: true }))
+
+    const main = new BlockNode(
+      [chatInputElement],
+      new BlockProto({ allowEmpty: true })
+    )
+    const props = getProps({
+      elements: new AppRoot(new BlockNode([main, sidebar])),
+    })
+
+    render(<AppView {...props} />)
+
+    const stbContainer = screen.queryByTestId("ScrollToBottomContainer")
+    expect(stbContainer).toBeInTheDocument()
+  })
 })
diff --git a/frontend/src/app/components/AppView/AppView.tsx b/frontend/src/app/components/AppView/AppView.tsx
index 451bd5893e23..a444450b7f42 100644
--- a/frontend/src/app/components/AppView/AppView.tsx
+++ b/frontend/src/app/components/AppView/AppView.tsx
@@ -39,6 +39,7 @@ import {
   StyledIFrameResizerAnchor,
   StyledAppViewBlockSpacer,
 } from "./styled-components"
+import ScrollToBottomContainer from "./ScrollToBottomContainer"
 
 export interface AppViewProps {
   elements: AppRoot
@@ -96,6 +97,16 @@ function AppView(props: AppViewProps): ReactElement {
     endpoints,
   } = props
 
+  // TODO: This works for scroll to bottom, but we will need
+  // to revisit this when we support multiple position options
+  const containsChatInput =
+    Array.from(elements.main.getElements()).find(element => {
+      return element.type === "chatInput"
+    }) !== undefined
+  const Component = containsChatInput
+    ? ScrollToBottomContainer
+    : StyledAppViewMain
+
   React.useEffect(() => {
     const listener = (): void => {
       sendMessageToHost({
@@ -120,6 +131,7 @@ function AppView(props: AppViewProps): ReactElement {
   const renderBlock = (node: BlockNode): ReactElement => (
     <StyledAppViewBlockContainer
       className="block-container"
+      data-testid="block-container"
       isWideMode={wideMode}
       showPadding={showPadding}
       addPaddingForHeader={showToolbar || showColoredLine}
@@ -164,7 +176,7 @@ function AppView(props: AppViewProps): ReactElement {
           {renderBlock(elements.sidebar)}
         </ThemedSidebar>
       )}
-      <StyledAppViewMain
+      <Component
         tabIndex={0}
         isEmbedded={embedded}
         disableScrolling={disableScrolling}
@@ -179,8 +191,10 @@ function AppView(props: AppViewProps): ReactElement {
         />
         {/* Spacer fills up dead space to ensure the footer remains at the
         bottom of the page in larger views */}
-        {(!embedded || showFooter) && <StyledAppViewBlockSpacer />}
         {(!embedded || showFooter) && (
+          <StyledAppViewBlockSpacer data-testid="AppViewBlockSpacer" />
+        )}
+        {(!embedded || showFooter) && !containsChatInput && (
           <StyledAppViewFooter isWideMode={wideMode}>
             Made with{" "}
             <StyledAppViewFooterLink href="//streamlit.io" target="_blank">
@@ -188,7 +202,7 @@ function AppView(props: AppViewProps): ReactElement {
             </StyledAppViewFooterLink>
           </StyledAppViewFooter>
         )}
-      </StyledAppViewMain>
+      </Component>
     </StyledAppViewContainer>
   )
 }
diff --git a/frontend/src/app/components/AppView/ScrollToBottomContainer.tsx b/frontend/src/app/components/AppView/ScrollToBottomContainer.tsx
new file mode 100644
index 000000000000..afac2e6d6dda
--- /dev/null
+++ b/frontend/src/app/components/AppView/ScrollToBottomContainer.tsx
@@ -0,0 +1,45 @@
+/**
+ * Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React, { ReactElement, ReactNode } from "react"
+import { StyledAppViewMain } from "./styled-components"
+import useScrollToBottom from "src/lib/hooks/useScrollToBottom"
+
+export interface Props {
+  className: string
+  tabIndex: number
+  isEmbedded: boolean
+  disableScrolling: boolean
+  children: ReactNode
+}
+
+export default function ScrollToBottomContainer(props: Props): ReactElement {
+  const { className, tabIndex, children, isEmbedded, disableScrolling } = props
+  const scrollContainerRef = useScrollToBottom()
+
+  return (
+    <StyledAppViewMain
+      tabIndex={tabIndex}
+      className={className}
+      isEmbedded={isEmbedded}
+      disableScrolling={disableScrolling}
+      ref={scrollContainerRef}
+      data-testid="ScrollToBottomContainer"
+    >
+      {children}
+    </StyledAppViewMain>
+  )
+}
diff --git a/frontend/src/lib/WidgetStateManager.test.ts b/frontend/src/lib/WidgetStateManager.test.ts
index bbbdc5e8f619..d8423613c782 100644
--- a/frontend/src/lib/WidgetStateManager.test.ts
+++ b/frontend/src/lib/WidgetStateManager.test.ts
@@ -152,6 +152,18 @@ describe("Widget State Manager", () => {
     assertCallbacks({ insideForm: false })
   })
 
+  /**
+   * String Triggers can't be used within forms, so this test
+   * is not parameterized on insideForm.
+   */
+  it("sets string trigger value correctly", () => {
+    const widget = getWidget({ insideForm: false })
+    widgetMgr.setStringTriggerValue(widget, "sample string", { fromUi: true })
+    // @ts-expect-error
+    expect(widgetMgr.getWidgetState(widget)).toBe(undefined)
+    assertCallbacks({ insideForm: false })
+  })
+
   it.each([false, true])(
     "sets string array value correctly (insideForm=%p)",
     insideForm => {
diff --git a/frontend/src/lib/WidgetStateManager.ts b/frontend/src/lib/WidgetStateManager.ts
index 081a7359f90c..568e6a051b4f 100644
--- a/frontend/src/lib/WidgetStateManager.ts
+++ b/frontend/src/lib/WidgetStateManager.ts
@@ -23,6 +23,7 @@ import {
   IFileUploaderState,
   SInt64Array,
   StringArray,
+  StringTriggerValue,
   WidgetState,
   WidgetStates,
 } from "src/lib/proto"
@@ -36,7 +37,7 @@ export interface Source {
 /** Common widget protobuf fields that are used by the WidgetStateManager. */
 export interface WidgetInfo {
   id: string
-  formId: string
+  formId?: string
 }
 
 /**
@@ -241,6 +242,22 @@ export class WidgetStateManager {
     }
   }
 
+  /**
+   * Sets the string trigger value for the given widget ID to a string value,
+   * sends a rerunScript message to the server, and then immediately unsets the
+   * string trigger value to None/null.
+   */
+  public setStringTriggerValue(
+    widget: WidgetInfo,
+    value: string,
+    source: Source
+  ): void {
+    this.createWidgetState(widget, source).stringTriggerValue =
+      new StringTriggerValue({ data: value })
+    this.onWidgetValueChanged(widget.formId, source)
+    this.deleteWidgetState(widget.id)
+  }
+
   /**
    * Sets the trigger value for the given widget ID to true, sends a rerunScript message
    * to the server, and then immediately unsets the trigger value.
@@ -527,7 +544,7 @@ export class WidgetStateManager {
   private createWidgetState(widget: WidgetInfo, source: Source): WidgetState {
     const addToForm = isValidFormId(widget.formId) && source.fromUi
     const widgetStateDict = addToForm
-      ? this.getOrCreateFormState(widget.formId).widgetStates
+      ? this.getOrCreateFormState(widget.formId as string).widgetStates
       : this.widgetStates
 
     return widgetStateDict.createState(widget.id)
diff --git a/frontend/src/lib/components/core/Block/Block.tsx b/frontend/src/lib/components/core/Block/Block.tsx
index 513ebea86ad6..feadb8ec9514 100644
--- a/frontend/src/lib/components/core/Block/Block.tsx
+++ b/frontend/src/lib/components/core/Block/Block.tsx
@@ -22,6 +22,7 @@ import { BlockNode, AppNode, ElementNode } from "src/lib/AppNode"
 import { getElementWidgetID } from "src/lib/util/utils"
 import withExpandable from "src/lib/hocs/withExpandable"
 import { Form } from "src/lib/components/widgets/Form"
+import ChatMessage from "src/lib/components/elements/ChatMessage"
 import Tabs, { TabProps } from "src/lib/components/elements/Tabs"
 
 import {
@@ -53,8 +54,13 @@ interface BlockPropsWithWidth extends BaseBlockProps {
 const BlockNodeRenderer = (props: BlockPropsWithWidth): ReactElement => {
   const { node } = props
 
-  // Allow columns to create the specified space regardless of empty state
-  if (node.isEmpty && !node.deltaBlock.column) {
+  // Allow columns and chat messages to create the specified space regardless of empty state
+  // TODO: Maybe we can simplify this to: node.isEmpty && !node.deltaBlock.allowEmpty?
+  if (
+    node.isEmpty &&
+    !node.deltaBlock.column &&
+    !node.deltaBlock.chatMessage
+  ) {
     return <></>
   }
 
@@ -101,6 +107,16 @@ const BlockNodeRenderer = (props: BlockPropsWithWidth): ReactElement => {
     )
   }
 
+  if (node.deltaBlock.chatMessage) {
+    return (
+      <ChatMessage
+        element={node.deltaBlock.chatMessage as BlockProto.ChatMessage}
+      >
+        {child}
+      </ChatMessage>
+    )
+  }
+
   if (node.deltaBlock.column) {
     return (
       <StyledColumn
diff --git a/frontend/src/lib/components/core/Block/ElementNodeRenderer.tsx b/frontend/src/lib/components/core/Block/ElementNodeRenderer.tsx
index 11399baa467f..4dceeac1d09f 100644
--- a/frontend/src/lib/components/core/Block/ElementNodeRenderer.tsx
+++ b/frontend/src/lib/components/core/Block/ElementNodeRenderer.tsx
@@ -22,6 +22,7 @@ import {
   Button as ButtonProto,
   DownloadButton as DownloadButtonProto,
   CameraInput as CameraInputProto,
+  ChatInput as ChatInputProto,
   Checkbox as CheckboxProto,
   Code as CodeProto,
   ColorPicker as ColorPickerProto,
@@ -106,6 +107,7 @@ const ArrowVegaLiteChart = React.lazy(
 const BokehChart = React.lazy(
   () => import("src/lib/components/elements/BokehChart")
 )
+
 const DebouncedBokehChart = debounceRender(BokehChart, 100)
 
 const DataFrame = React.lazy(
@@ -137,6 +139,9 @@ const DownloadButton = React.lazy(
 const CameraInput = React.lazy(
   () => import("src/lib/components/widgets/CameraInput")
 )
+const ChatInput = React.lazy(
+  () => import("src/lib/components/widgets/ChatInput")
+)
 const Checkbox = React.lazy(
   () => import("src/lib/components/widgets/Checkbox")
 )
@@ -481,6 +486,19 @@ const RawElementNodeRenderer = (
       )
     }
 
+    case "chatInput": {
+      const chatInputProto = node.element.chatInput as ChatInputProto
+      widgetProps.disabled = widgetProps.disabled || chatInputProto.disabled
+      return (
+        <ChatInput
+          key={chatInputProto.id}
+          element={chatInputProto}
+          width={width}
+          {...widgetProps}
+        />
+      )
+    }
+
     case "checkbox": {
       const checkboxProto = node.element.checkbox as CheckboxProto
       widgetProps.disabled = widgetProps.disabled || checkboxProto.disabled
diff --git a/frontend/src/lib/components/core/Block/styled-components.ts b/frontend/src/lib/components/core/Block/styled-components.ts
index 434c10acc512..9a17857fadfe 100644
--- a/frontend/src/lib/components/core/Block/styled-components.ts
+++ b/frontend/src/lib/components/core/Block/styled-components.ts
@@ -54,6 +54,7 @@ export interface StyledElementContainerProps {
   elementType: string
 }
 
+const GLOBAL_ELEMENTS = ["balloons", "snow", "chatInput"]
 export const StyledElementContainer = styled.div<StyledElementContainerProps>(
   ({ theme, isStale, width, elementType }) => ({
     width,
@@ -68,7 +69,9 @@ export const StyledElementContainer = styled.div<StyledElementContainerProps>(
       overflow: "visible",
     },
 
-    ...(isStale
+    // We do not want the chat input to be faded out.
+    // TODO: Reconsider this when we implement fixed-sized chat containers
+    ...(isStale && elementType !== "chatInput"
       ? {
           opacity: 0.33,
           transition: "opacity 1s ease-in 0.5s",
@@ -80,10 +83,12 @@ export const StyledElementContainer = styled.div<StyledElementContainerProps>(
           display: "none",
         }
       : {}),
-    ...(elementType === "balloons"
+    ...(GLOBAL_ELEMENTS.includes(elementType)
       ? {
-          // Apply negative bottom margin to remove the flexbox gap.
-          // display: none does not work for balloons, since it needs to be visible.
+          // Global elements are rendered in their delta position, but they
+          // are not part of the flexbox layout. We apply a negative margin
+          // to remove the flexbox gap. display: none does not work for these,
+          // since they needs to be visible.
           marginBottom: `-${theme.spacing.lg}`,
         }
       : {}),
diff --git a/frontend/src/lib/components/elements/ChatMessage/ChatMessage.test.tsx b/frontend/src/lib/components/elements/ChatMessage/ChatMessage.test.tsx
new file mode 100644
index 000000000000..0496e51546b9
--- /dev/null
+++ b/frontend/src/lib/components/elements/ChatMessage/ChatMessage.test.tsx
@@ -0,0 +1,127 @@
+/**
+ * Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from "react"
+import "@testing-library/jest-dom"
+
+import { render } from "src/lib/test_util"
+import { Block as BlockProto } from "src/lib/proto"
+
+import ChatMessage, { ChatMessageProps } from "./ChatMessage"
+
+const getProps = (
+  elementProps: Partial<BlockProto.ChatMessage> = {}
+): ChatMessageProps => ({
+  element: BlockProto.ChatMessage.create({
+    participant: "user",
+    avatarType: BlockProto.ChatMessage.AvatarType.ICON,
+    avatar: "user",
+    ...elementProps,
+  }),
+})
+
+describe("ChatMessage", () => {
+  it("renders without crashing", () => {
+    const props = getProps()
+    const rtlResults = render(<ChatMessage {...props} />)
+    expect(rtlResults).toBeDefined()
+  })
+
+  it("renders message children content", () => {
+    const props = getProps()
+    const { getByLabelText } = render(
+      <ChatMessage {...props}>Hello, world!</ChatMessage>
+    )
+    expect(getByLabelText("Chat message from user").textContent).toBe(
+      "Hello, world!"
+    )
+  })
+
+  it("renders with an emoji avatar", () => {
+    const props = getProps({
+      avatar: "😃",
+      avatarType: BlockProto.ChatMessage.AvatarType.EMOJI,
+    })
+    const rtlResults = render(<ChatMessage {...props} />)
+    expect(rtlResults.getByText("😃")).toBeTruthy()
+  })
+
+  it("renders with an image avatar", () => {
+    const props = getProps({
+      avatar: "http://example.com/avatar.jpg",
+      avatarType: BlockProto.ChatMessage.AvatarType.IMAGE,
+    })
+    const { container } = render(<ChatMessage {...props} />)
+    const images = container.getElementsByTagName("img")
+    expect(images.length).toEqual(1)
+    expect(images[0].src).toBe("http://example.com/avatar.jpg")
+  })
+
+  it("renders with a participant label character as fallback", () => {
+    const props = getProps({
+      avatar: undefined,
+      avatarType: undefined,
+      participant: "test",
+    })
+    const { getByText } = render(<ChatMessage {...props} />)
+    expect(getByText("T")).toBeTruthy()
+  })
+
+  it("renders with a 'user' icon avatar", () => {
+    const props = getProps({
+      avatar: "user",
+      avatarType: BlockProto.ChatMessage.AvatarType.ICON,
+      participant: "foo",
+    })
+    const { container } = render(<ChatMessage {...props} />)
+
+    const svgs = container.getElementsByTagName("svg")
+    expect(svgs.length).toEqual(1)
+  })
+
+  it("renders with a 'assistant' icon avatar", () => {
+    const props = getProps({
+      avatar: "assistant",
+      avatarType: BlockProto.ChatMessage.AvatarType.ICON,
+      participant: "foo",
+    })
+    const { container } = render(<ChatMessage {...props} />)
+
+    const svgs = container.getElementsByTagName("svg")
+    expect(svgs.length).toEqual(1)
+  })
+
+  it("renders with a grey background when participant is 'user'", () => {
+    const props = getProps({
+      participant: "user",
+    })
+    const { container } = render(<ChatMessage {...props} />)
+    const messageContainer = container.firstChild
+    expect(messageContainer).toHaveStyle(
+      "background-color: rgba(240, 242, 246, 0.5)"
+    )
+  })
+
+  it("sets an aria label on the chat message", () => {
+    const props = getProps()
+    const { getByTestId } = render(<ChatMessage {...props} />)
+
+    const chatMessageContent = getByTestId("stChatMessageContent")
+    expect(chatMessageContent.getAttribute("aria-label")).toEqual(
+      "Chat message from user"
+    )
+  })
+})
diff --git a/frontend/src/lib/components/elements/ChatMessage/ChatMessage.tsx b/frontend/src/lib/components/elements/ChatMessage/ChatMessage.tsx
new file mode 100644
index 000000000000..2bbf1b1c018d
--- /dev/null
+++ b/frontend/src/lib/components/elements/ChatMessage/ChatMessage.tsx
@@ -0,0 +1,105 @@
+/**
+ * Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React, { ReactElement } from "react"
+import { useTheme } from "@emotion/react"
+import { Face, SmartToy } from "@emotion-icons/material-outlined"
+
+import { Block as BlockProto } from "src/lib/proto"
+import Icon from "src/lib/components/shared/Icon"
+import { EmotionTheme } from "src/lib/theme"
+
+import {
+  StyledChatMessageContainer,
+  StyledMessageContent,
+  StyledAvatarImage,
+  StyledAvatarIcon,
+  StyledAvatarBackground,
+} from "./styled-components"
+
+interface ChatMessageAvatarProps {
+  participant: string
+  avatar?: string
+  avatarType?: BlockProto.ChatMessage.AvatarType
+}
+
+function ChatMessageAvatar(props: ChatMessageAvatarProps): ReactElement {
+  const { avatar, avatarType, participant } = props
+  const theme: EmotionTheme = useTheme()
+
+  if (avatar) {
+    switch (avatarType) {
+      case BlockProto.ChatMessage.AvatarType.IMAGE:
+        return <StyledAvatarImage src={avatar} alt={`${participant} avatar`} />
+      case BlockProto.ChatMessage.AvatarType.EMOJI:
+        return <StyledAvatarBackground>{avatar}</StyledAvatarBackground>
+      case BlockProto.ChatMessage.AvatarType.ICON:
+        if (avatar === "user") {
+          return (
+            <StyledAvatarIcon background={theme.colors.red60}>
+              <Icon content={Face} size="lg" />
+            </StyledAvatarIcon>
+          )
+        } else if (avatar === "assistant") {
+          return (
+            <StyledAvatarIcon background={theme.colors.orange60}>
+              <Icon content={SmartToy} size="lg" />
+            </StyledAvatarIcon>
+          )
+        }
+    }
+  }
+
+  // Fallback to first character of the participant label if nothing else can be matched:
+  return (
+    <StyledAvatarBackground>
+      {participant ? participant.charAt(0).toUpperCase() : "🧑‍💻"}
+    </StyledAvatarBackground>
+  )
+}
+
+export interface ChatMessageProps {
+  element: BlockProto.ChatMessage
+}
+
+const ChatMessage: React.FC<ChatMessageProps> = ({
+  element,
+  children,
+}): ReactElement => {
+  const { avatar, avatarType, participant } = element
+
+  return (
+    <StyledChatMessageContainer
+      className="stChatMessage"
+      background={participant.toLowerCase() === "user"}
+    >
+      <ChatMessageAvatar
+        participant={participant}
+        avatar={avatar}
+        avatarType={avatarType}
+        data-testid="stChatMessageAvatar"
+      />
+      <StyledMessageContent
+        data-testid="stChatMessageContent"
+        aria-label={`Chat message from ${participant}`}
+      >
+        {children}
+      </StyledMessageContent>
+    </StyledChatMessageContainer>
+  )
+}
+
+export default ChatMessage
diff --git a/frontend/src/lib/components/elements/ChatMessage/index.tsx b/frontend/src/lib/components/elements/ChatMessage/index.tsx
new file mode 100644
index 000000000000..810c6f9ca039
--- /dev/null
+++ b/frontend/src/lib/components/elements/ChatMessage/index.tsx
@@ -0,0 +1,17 @@
+/**
+ * Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export { default } from "./ChatMessage"
diff --git a/frontend/src/lib/components/elements/ChatMessage/styled-components.ts b/frontend/src/lib/components/elements/ChatMessage/styled-components.ts
new file mode 100644
index 000000000000..77daf65b4a3b
--- /dev/null
+++ b/frontend/src/lib/components/elements/ChatMessage/styled-components.ts
@@ -0,0 +1,99 @@
+/**
+ * Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import styled from "@emotion/styled"
+import { transparentize } from "color2k"
+
+import { hasLightBackgroundColor } from "src/lib/theme"
+
+export interface StyledChatMessageContainerProps {
+  background: boolean
+}
+
+export const StyledChatMessageContainer =
+  styled.div<StyledChatMessageContainerProps>(({ theme, background }) => {
+    const lightTheme = hasLightBackgroundColor(theme)
+    return {
+      display: "flex",
+      alignItems: "flex-start",
+      gap: theme.spacing.sm,
+      padding: theme.spacing.lg,
+      paddingRight: background ? theme.spacing.lg : 0,
+      borderRadius: theme.radii.lg,
+      ...(background
+        ? {
+            backgroundColor: lightTheme
+              ? transparentize(theme.colors.gray20, 0.5)
+              : transparentize(theme.colors.gray90, 0.5),
+          }
+        : {}),
+    }
+  })
+
+export const StyledMessageContent = styled.div(({ theme }) => ({
+  color: theme.colors.bodyText,
+  margin: "auto",
+  flexGrow: 1,
+}))
+
+export const StyledAvatarBackground = styled.div(({ theme }) => {
+  const lightTheme = hasLightBackgroundColor(theme)
+  return {
+    display: "flex",
+    border: `1px solid ${
+      lightTheme ? theme.colors.gray40 : theme.colors.gray85
+    }`,
+    backgroundColor: lightTheme ? theme.colors.white : theme.colors.gray100,
+    color: lightTheme ? theme.colors.gray90 : theme.colors.white,
+    lineHeight: "1",
+    fontSize: theme.fontSizes.md,
+    width: "2rem",
+    height: "2rem",
+    borderRadius: theme.radii.lg,
+    alignItems: "center",
+    justifyContent: "center",
+  }
+})
+
+export interface StyledAvatarIconProps {
+  background: string
+}
+
+export const StyledAvatarIcon = styled.div<StyledAvatarIconProps>(
+  ({ theme, background }) => {
+    const lightTheme = hasLightBackgroundColor(theme)
+    return {
+      display: "flex",
+      width: "2rem",
+      height: "2rem",
+      borderRadius: theme.radii.lg,
+      alignItems: "center",
+      justifyContent: "center",
+      backgroundColor: background,
+      color: lightTheme ? theme.colors.white : theme.colors.gray100,
+    }
+  }
+)
+
+export const StyledAvatarImage = styled.img(({ theme }) => {
+  return {
+    width: "2rem",
+    height: "2rem",
+    borderRadius: theme.radii.lg,
+    objectFit: "cover",
+    display: "flex",
+  }
+})
diff --git a/frontend/src/lib/components/shared/InputInstructions/InputInstructions.test.tsx b/frontend/src/lib/components/shared/InputInstructions/InputInstructions.test.tsx
index d8d7be2715d5..a12076809cbe 100644
--- a/frontend/src/lib/components/shared/InputInstructions/InputInstructions.test.tsx
+++ b/frontend/src/lib/components/shared/InputInstructions/InputInstructions.test.tsx
@@ -15,7 +15,7 @@
  */
 
 import React from "react"
-import { shallow } from "src/lib/test_util"
+import { render } from "src/lib/test_util"
 
 import InputInstructions, { Props } from "./InputInstructions"
 
@@ -27,24 +27,31 @@ const getProps = (props: Partial<Props> = {}): Props => ({
 
 describe("InputInstructions", () => {
   const props = getProps()
-  const wrapper = shallow(<InputInstructions {...props} />)
 
   it("renders without crashing", () => {
-    expect(wrapper.text()).toBeDefined()
+    const { getByTestId } = render(<InputInstructions {...props} />)
+
+    expect(getByTestId("InputInstructions").textContent).toBeDefined()
   })
 
   it("should show Enter instructions", () => {
-    expect(wrapper.text()).toBe("Press Enter to apply")
+    const { getByTestId } = render(<InputInstructions {...props} />)
+
+    expect(getByTestId("InputInstructions").textContent).toBe(
+      "Press Enter to apply"
+    )
   })
 
   describe("Multiline type", () => {
     const props = getProps({
       type: "multiline",
     })
-    const wrapper = shallow(<InputInstructions {...props} />)
 
     it("should show Ctrl+Enter instructions", () => {
-      expect(wrapper.text()).toBe("Press Ctrl+Enter to apply")
+      const { getByTestId } = render(<InputInstructions {...props} />)
+      expect(getByTestId("InputInstructions").textContent).toBe(
+        "Press Ctrl+Enter to apply"
+      )
     })
 
     it("show ⌘+Enter instructions", () => {
@@ -56,9 +63,11 @@ describe("InputInstructions", () => {
       const props = getProps({
         type: "multiline",
       })
-      const wrapper = shallow(<InputInstructions {...props} />)
+      const { getByTestId } = render(<InputInstructions {...props} />)
 
-      expect(wrapper.text()).toBe("Press ⌘+Enter to apply")
+      expect(getByTestId("InputInstructions").textContent).toBe(
+        "Press ⌘+Enter to apply"
+      )
     })
 
     it("should show instructions for max length", () => {
@@ -66,9 +75,11 @@ describe("InputInstructions", () => {
         type: "multiline",
         maxLength: 3,
       })
-      const wrapper = shallow(<InputInstructions {...props} />)
+      const { getByTestId } = render(<InputInstructions {...props} />)
 
-      expect(wrapper.text()).toBe("Press ⌘+Enter to apply3/3")
+      expect(getByTestId("InputInstructions").textContent).toBe(
+        "Press ⌘+Enter to apply3/3"
+      )
     })
   })
 
@@ -76,8 +87,31 @@ describe("InputInstructions", () => {
     const props = getProps({
       maxLength: 3,
     })
-    const wrapper = shallow(<InputInstructions {...props} />)
+    const { getByTestId } = render(<InputInstructions {...props} />)
+
+    expect(getByTestId("InputInstructions").textContent).toBe(
+      "Press Enter to apply3/3"
+    )
+  })
+
+  describe("Chat type", () => {
+    const props = getProps({
+      type: "chat",
+    })
 
-    expect(wrapper.text()).toBe("Press Enter to apply3/3")
+    it("should not show instructions", () => {
+      const { getByTestId } = render(<InputInstructions {...props} />)
+      expect(getByTestId("InputInstructions").textContent).toBe("")
+    })
+
+    it("should show instructions for max length", () => {
+      const props = getProps({
+        type: "chat",
+        maxLength: 3,
+      })
+      const { getByTestId } = render(<InputInstructions {...props} />)
+
+      expect(getByTestId("InputInstructions").textContent).toBe("3/3")
+    })
   })
 })
diff --git a/frontend/src/lib/components/shared/InputInstructions/InputInstructions.tsx b/frontend/src/lib/components/shared/InputInstructions/InputInstructions.tsx
index 752b95721e9b..c7ec2478d189 100644
--- a/frontend/src/lib/components/shared/InputInstructions/InputInstructions.tsx
+++ b/frontend/src/lib/components/shared/InputInstructions/InputInstructions.tsx
@@ -24,7 +24,7 @@ export interface Props {
   value: string
   maxLength?: number
   className?: string
-  type?: "multiline" | "single"
+  type?: "multiline" | "single" | "chat"
 }
 
 const InputInstructions = ({
@@ -54,12 +54,12 @@ const InputInstructions = ({
       } else {
         addMessage("Press Ctrl+Enter to apply")
       }
-    } else {
+    } else if (type === "single") {
       addMessage("Press Enter to apply")
     }
   }
 
-  if (maxLength) {
+  if (maxLength && (type !== "chat" || dirty)) {
     addMessage(
       `${value.length}/${maxLength}`,
       dirty && value.length >= maxLength
@@ -67,7 +67,10 @@ const InputInstructions = ({
   }
 
   return (
-    <StyledWidgetInstructions className={className}>
+    <StyledWidgetInstructions
+      data-testid="InputInstructions"
+      className={className}
+    >
       {messages}
     </StyledWidgetInstructions>
   )
diff --git a/frontend/src/lib/components/widgets/ChatInput/ChatInput.test.tsx b/frontend/src/lib/components/widgets/ChatInput/ChatInput.test.tsx
new file mode 100644
index 000000000000..aa6ec9ba133f
--- /dev/null
+++ b/frontend/src/lib/components/widgets/ChatInput/ChatInput.test.tsx
@@ -0,0 +1,249 @@
+/**
+ * Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from "react"
+import "@testing-library/jest-dom"
+import { fireEvent } from "@testing-library/react"
+import { render } from "src/lib/test_util"
+import { ChatInput as ChatInputProto } from "src/lib/proto"
+import { WidgetStateManager } from "src/lib/WidgetStateManager"
+
+import ChatInput, { Props } from "./ChatInput"
+
+const getProps = (elementProps: Partial<ChatInputProto> = {}): Props => ({
+  element: ChatInputProto.create({
+    id: "123",
+    placeholder: "Enter Text Here",
+    disabled: false,
+    default: "",
+    position: ChatInputProto.Position.BOTTOM,
+    ...elementProps,
+  }),
+  width: 0,
+  disabled: false,
+  widgetMgr: new WidgetStateManager({
+    sendRerunBackMsg: jest.fn(),
+    formsDataChanged: jest.fn(),
+  }),
+})
+
+describe("ChatInput widget", () => {
+  afterEach(() => {
+    jest.restoreAllMocks()
+  })
+
+  it("renders without crashing", () => {
+    const props = getProps()
+    const rtlResults = render(<ChatInput {...props} />)
+    expect(rtlResults).toBeDefined()
+  })
+
+  it("shows a placeholder", () => {
+    const props = getProps()
+    const { container } = render(<ChatInput {...props} />)
+    const textareas = container.getElementsByTagName("textarea")
+    expect(textareas.length).toEqual(1)
+    expect(textareas[0].placeholder).toEqual(props.element.placeholder)
+  })
+
+  it("sets the aria label to the placeholder", () => {
+    const props = getProps()
+    const { container } = render(<ChatInput {...props} />)
+    const textareas = container.getElementsByTagName("textarea")
+    expect(textareas.length).toEqual(1)
+    expect(textareas[0].getAttribute("aria-label")).toEqual(
+      props.element.placeholder
+    )
+  })
+
+  it("sets the value intially to the element default", () => {
+    const props = getProps()
+    const { container } = render(<ChatInput {...props} />)
+    const textareas = container.getElementsByTagName("textarea")
+    expect(textareas.length).toEqual(1)
+    expect(textareas[0].value).toEqual(props.element.default)
+  })
+
+  it("sets the value when values are typed in", () => {
+    const props = getProps()
+    const { container } = render(<ChatInput {...props} />)
+    const textareas = container.getElementsByTagName("textarea")
+    expect(textareas.length).toEqual(1)
+    fireEvent.change(textareas[0], { target: { value: "Sample text" } })
+    expect(textareas[0].value).toEqual("Sample text")
+  })
+
+  it("does not increase text value when maxChars is set", () => {
+    const props = getProps({ maxChars: 10 })
+    const { container } = render(<ChatInput {...props} />)
+    const textareas = container.getElementsByTagName("textarea")
+    expect(textareas.length).toEqual(1)
+    fireEvent.change(textareas[0], { target: { value: "1234567890" } })
+    expect(textareas[0].value).toEqual("1234567890")
+    fireEvent.change(textareas[0], { target: { value: "12345678901" } })
+    expect(textareas[0].value).toEqual("1234567890")
+  })
+
+  it("sends and resets the value on enter", () => {
+    const props = getProps()
+    const spy = jest.spyOn(props.widgetMgr, "setStringTriggerValue")
+    const { container } = render(<ChatInput {...props} />)
+    const textareas = container.getElementsByTagName("textarea")
+    expect(textareas.length).toEqual(1)
+    fireEvent.change(textareas[0], { target: { value: "1234567890" } })
+    expect(textareas[0].value).toEqual("1234567890")
+    fireEvent.keyDown(textareas[0], { key: "Enter" })
+    expect(spy).toHaveBeenCalledWith(props.element, "1234567890", {
+      fromUi: true,
+    })
+    expect(textareas[0].value).toEqual("")
+  })
+
+  it("will not send an empty value on enter if empty", () => {
+    const props = getProps()
+    const spy = jest.spyOn(props.widgetMgr, "setStringTriggerValue")
+    const { container } = render(<ChatInput {...props} />)
+    const textareas = container.getElementsByTagName("textarea")
+    expect(textareas.length).toEqual(1)
+    fireEvent.keyDown(textareas[0], { key: "Enter" })
+    expect(spy).not.toHaveBeenCalledWith(props.element, "", {
+      fromUi: true,
+    })
+    expect(textareas[0].value).toEqual("")
+  })
+
+  it("will not show instructions when the text has changed", () => {
+    const props = getProps()
+    const { container, getAllByTestId } = render(<ChatInput {...props} />)
+    const textareas = container.getElementsByTagName("textarea")
+    expect(textareas.length).toEqual(1)
+    const instructions = getAllByTestId("InputInstructions")
+    expect(instructions.length).toEqual(1)
+    expect(instructions[0].textContent).toEqual("")
+    fireEvent.change(textareas[0], { target: { value: "1234567890" } })
+    expect(instructions[0].textContent).toEqual("")
+  })
+
+  it("does not send/clear on shift + enter", () => {
+    const props = getProps()
+    const spy = jest.spyOn(props.widgetMgr, "setStringTriggerValue")
+    const { container } = render(<ChatInput {...props} />)
+    const textareas = container.getElementsByTagName("textarea")
+    expect(textareas.length).toEqual(1)
+    fireEvent.change(textareas[0], { target: { value: "1234567890" } })
+    expect(textareas[0].value).toEqual("1234567890")
+    fireEvent.keyDown(textareas[0], { key: "Enter", shiftKey: true })
+    // We cannot test the value to be changed cause that is essentially a
+    // change event.
+    expect(textareas[0].value).not.toEqual("")
+    expect(spy).not.toHaveBeenCalled()
+  })
+
+  it("does not send/clear on ctrl + enter", () => {
+    const props = getProps()
+    const spy = jest.spyOn(props.widgetMgr, "setStringTriggerValue")
+    const { container } = render(<ChatInput {...props} />)
+    const textareas = container.getElementsByTagName("textarea")
+    expect(textareas.length).toEqual(1)
+    fireEvent.change(textareas[0], { target: { value: "1234567890" } })
+    expect(textareas[0].value).toEqual("1234567890")
+    fireEvent.keyDown(textareas[0], { key: "Enter", ctrlKey: true })
+    // We cannot test the value to be changed cause that is essentially a
+    // change event.
+    expect(textareas[0].value).not.toEqual("")
+    expect(spy).not.toHaveBeenCalled()
+  })
+
+  it("does not send/clear on meta + enter", () => {
+    const props = getProps()
+    const spy = jest.spyOn(props.widgetMgr, "setStringTriggerValue")
+    const { container } = render(<ChatInput {...props} />)
+    const textareas = container.getElementsByTagName("textarea")
+    expect(textareas.length).toEqual(1)
+    fireEvent.change(textareas[0], { target: { value: "1234567890" } })
+    expect(textareas[0].value).toEqual("1234567890")
+    fireEvent.keyDown(textareas[0], { key: "Enter", metaKey: true })
+    // We cannot test the value to be changed cause that is essentially a
+    // change event.
+    expect(textareas[0].value).not.toEqual("")
+    expect(spy).not.toHaveBeenCalled()
+  })
+
+  it("does sets the value if specified from protobuf to set it", () => {
+    const props = getProps({ value: "12345", setValue: true })
+    const { container } = render(<ChatInput {...props} />)
+    const textareas = container.getElementsByTagName("textarea")
+    expect(textareas.length).toEqual(1)
+    expect(textareas[0].value).toEqual("12345")
+  })
+
+  it("does not set the value if protobuf does not specify to set it", () => {
+    const props = getProps({ value: "12345", setValue: false })
+    const { container } = render(<ChatInput {...props} />)
+    const textareas = container.getElementsByTagName("textarea")
+    expect(textareas.length).toEqual(1)
+    expect(textareas[0].value).toEqual("")
+  })
+
+  it("disables the textarea and button", () => {
+    const props = getProps({ disabled: true })
+    const { container } = render(<ChatInput {...props} />)
+    const textareas = container.getElementsByTagName("textarea")
+    expect(textareas.length).toEqual(1)
+    expect(textareas[0]).toBeDisabled()
+
+    const button = container.getElementsByTagName("button")
+    expect(button.length).toEqual(1)
+    expect(button[0]).toBeDisabled()
+  })
+
+  it("not disable the textarea by default", () => {
+    const props = getProps()
+    const { container } = render(<ChatInput {...props} />)
+    const textareas = container.getElementsByTagName("textarea")
+    expect(textareas.length).toEqual(1)
+    expect(textareas[0]).not.toBeDisabled()
+
+    const button = container.getElementsByTagName("button")
+    expect(button.length).toEqual(1)
+    expect(button[0]).toBeDisabled()
+  })
+
+  it("disables the send button by default since there's no text", () => {
+    const props = getProps()
+    const { container } = render(<ChatInput {...props} />)
+
+    const button = container.getElementsByTagName("button")
+    expect(button.length).toEqual(1)
+    expect(button[0]).toBeDisabled()
+  })
+
+  it("enables the send button when text is set, disables it when removed", () => {
+    const props = getProps()
+    const { container } = render(<ChatInput {...props} />)
+    const textareas = container.getElementsByTagName("textarea")
+    expect(textareas.length).toEqual(1)
+    fireEvent.change(textareas[0], { target: { value: "Sample text" } })
+
+    const button = container.getElementsByTagName("button")
+    expect(button.length).toEqual(1)
+    expect(button[0]).not.toBeDisabled()
+
+    fireEvent.change(textareas[0], { target: { value: "" } })
+    expect(button.length).toEqual(1)
+    expect(button[0]).toBeDisabled()
+  })
+})
diff --git a/frontend/src/lib/components/widgets/ChatInput/ChatInput.tsx b/frontend/src/lib/components/widgets/ChatInput/ChatInput.tsx
new file mode 100644
index 000000000000..6596018b196d
--- /dev/null
+++ b/frontend/src/lib/components/widgets/ChatInput/ChatInput.tsx
@@ -0,0 +1,238 @@
+/**
+ * Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React, {
+  useEffect,
+  useRef,
+  useState,
+  ChangeEvent,
+  KeyboardEvent,
+} from "react"
+import { useTheme } from "@emotion/react"
+import { Send } from "@emotion-icons/material-rounded"
+import { Textarea as UITextArea } from "baseui/textarea"
+
+import { ChatInput as ChatInputProto } from "src/lib/proto"
+import { WidgetStateManager } from "src/lib/WidgetStateManager"
+import Icon from "src/lib/components/shared/Icon"
+import InputInstructions from "src/lib/components/shared/InputInstructions/InputInstructions"
+import { hasLightBackgroundColor } from "src/lib/theme"
+
+import {
+  StyledChatInputContainer,
+  StyledChatInput,
+  StyledFloatingChatInputContainer,
+  StyledInputInstructionsContainer,
+  StyledSendIconButton,
+  StyledSendIconButtonContainer,
+} from "./styled-components"
+
+export interface Props {
+  disabled: boolean
+  element: ChatInputProto
+  widgetMgr: WidgetStateManager
+  width: number
+}
+
+// We want to show easily that there's scrolling so we deliberately choose
+// a half size.
+const MAX_VISIBLE_NUM_LINES = 6.5
+// Rounding errors can arbitrarily create scrollbars. We add a rounding offset
+// to manage it better.
+const ROUNDING_OFFSET = 1
+
+const isEnterKeyPressed = (
+  event: KeyboardEvent<HTMLTextAreaElement>
+): boolean => {
+  // Using keyCode as well due to some different behaviors on Windows
+  // https://bugs.chromium.org/p/chromium/issues/detail?id=79407
+
+  const { keyCode, key } = event
+  return key === "Enter" || keyCode === 13 || keyCode === 10
+}
+
+function ChatInput({ width, element, widgetMgr }: Props): React.ReactElement {
+  const theme = useTheme()
+  // True if the user-specified state.value has not yet been synced to the WidgetStateManager.
+  const [dirty, setDirty] = useState(false)
+  // The value specified by the user via the UI. If the user didn't touch this widget's UI, the default value is used.
+  const [value, setValue] = useState(element.default)
+  // The value of the height of the textarea. It depends on a variety of factors including the default height, and autogrowing
+  const [scrollHeight, setScrollHeight] = useState(0)
+  const chatInputRef = useRef<HTMLTextAreaElement>(null)
+  const heightGuidance = useRef({ minHeight: 0, maxHeight: 0 })
+
+  const getScrollHeight = (): number => {
+    let scrollHeight = 0
+    const { current: textarea } = chatInputRef
+    if (textarea) {
+      const placeholder = textarea.placeholder
+      textarea.placeholder = ""
+      textarea.style.height = "auto"
+      scrollHeight = textarea.scrollHeight
+      textarea.placeholder = placeholder
+      textarea.style.height = ""
+    }
+
+    return scrollHeight
+  }
+
+  const handleSubmit = (): void => {
+    if (!value) {
+      return
+    }
+
+    widgetMgr.setStringTriggerValue(element, value, { fromUi: true })
+    setDirty(false)
+    setValue("")
+    setScrollHeight(0)
+  }
+
+  const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
+    const { metaKey, ctrlKey, shiftKey } = e
+    const shouldSubmit =
+      isEnterKeyPressed(e) && !shiftKey && !ctrlKey && !metaKey
+
+    if (shouldSubmit) {
+      e.preventDefault()
+
+      handleSubmit()
+    }
+  }
+
+  const handleChange = (e: ChangeEvent<HTMLTextAreaElement>): void => {
+    const { value } = e.target
+    const { maxChars } = element
+
+    if (maxChars !== 0 && value.length > maxChars) {
+      return
+    }
+
+    setDirty(value !== "")
+    setValue(value)
+    setScrollHeight(getScrollHeight())
+  }
+
+  useEffect(() => {
+    if (element.setValue) {
+      // We are intentionally setting this to avoid regularly calling this effect.
+      element.setValue = false
+      const val = element.value || ""
+      setValue(val)
+      setDirty(val !== "")
+    }
+  }, [element])
+
+  useEffect(() => {
+    if (chatInputRef.current) {
+      const { offsetHeight } = chatInputRef.current
+      heightGuidance.current.minHeight = offsetHeight
+      heightGuidance.current.maxHeight = offsetHeight * MAX_VISIBLE_NUM_LINES
+    }
+  }, [chatInputRef])
+
+  const { disabled, placeholder, maxChars, position } = element
+  const lightTheme = hasLightBackgroundColor(theme)
+  const { minHeight, maxHeight } = heightGuidance.current
+  const placeholderColor = lightTheme
+    ? theme.colors.gray70
+    : theme.colors.gray80
+
+  const isInputExtended =
+    scrollHeight > 0 && chatInputRef.current
+      ? Math.abs(scrollHeight - minHeight) > ROUNDING_OFFSET
+      : false
+
+  return (
+    <StyledFloatingChatInputContainer className="stChatFloatingInputContainer">
+      <StyledChatInputContainer
+        className="stChatInputContainer"
+        width={width}
+        position={position}
+      >
+        <StyledChatInput>
+          <UITextArea
+            data-testid="stChatInput"
+            inputRef={chatInputRef}
+            value={value}
+            placeholder={placeholder}
+            onChange={handleChange}
+            onKeyDown={handleKeyDown}
+            aria-label={placeholder}
+            disabled={disabled}
+            rows={1}
+            overrides={{
+              Root: {
+                style: {
+                  outline: "none",
+                  backgroundColor: theme.colors.transparent,
+                  // Baseweb requires long-hand props, short-hand leads to weird bugs & warnings.
+                  borderLeftWidth: "1px",
+                  borderRightWidth: "1px",
+                  borderTopWidth: "1px",
+                  borderBottomWidth: "1px",
+                  width: `${width}px`,
+                },
+              },
+              InputContainer: {
+                style: {
+                  backgroundColor: theme.colors.transparent,
+                },
+              },
+              Input: {
+                style: {
+                  lineHeight: "1.4",
+                  backgroundColor: theme.colors.transparent,
+                  "::placeholder": {
+                    color: placeholderColor,
+                  },
+                  height: isInputExtended
+                    ? `${scrollHeight + ROUNDING_OFFSET}px`
+                    : "auto",
+                  maxHeight: maxHeight ? `${maxHeight}px` : "none",
+                  // Baseweb requires long-hand props, short-hand leads to weird bugs & warnings.
+                  paddingRight: "3rem",
+                  paddingLeft: theme.spacing.sm,
+                  paddingBottom: theme.spacing.sm,
+                  paddingTop: theme.spacing.sm,
+                },
+              },
+            }}
+          />
+          <StyledInputInstructionsContainer>
+            <InputInstructions
+              dirty={dirty}
+              value={value}
+              maxLength={maxChars}
+              type="chat"
+            />
+          </StyledInputInstructionsContainer>
+          <StyledSendIconButtonContainer>
+            <StyledSendIconButton
+              onClick={handleSubmit}
+              disabled={!dirty || disabled}
+              extended={isInputExtended}
+            >
+              <Icon content={Send} size="xl" color="inherit" />
+            </StyledSendIconButton>
+          </StyledSendIconButtonContainer>
+        </StyledChatInput>
+      </StyledChatInputContainer>
+    </StyledFloatingChatInputContainer>
+  )
+}
+
+export default ChatInput
diff --git a/frontend/src/lib/components/widgets/ChatInput/index.tsx b/frontend/src/lib/components/widgets/ChatInput/index.tsx
new file mode 100644
index 000000000000..65754e81dc92
--- /dev/null
+++ b/frontend/src/lib/components/widgets/ChatInput/index.tsx
@@ -0,0 +1,16 @@
+/**
+ * Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+export { default } from "./ChatInput"
diff --git a/frontend/src/lib/components/widgets/ChatInput/styled-components.ts b/frontend/src/lib/components/widgets/ChatInput/styled-components.ts
new file mode 100644
index 000000000000..65e15fa99d44
--- /dev/null
+++ b/frontend/src/lib/components/widgets/ChatInput/styled-components.ts
@@ -0,0 +1,129 @@
+/**
+ * Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import styled from "@emotion/styled"
+import { ChatInput as ChatInputProto } from "src/lib/proto"
+import { hasLightBackgroundColor } from "src/lib/theme"
+
+export interface StyledChatInputContainerProps {
+  width: number
+  position: ChatInputProto.Position
+}
+
+export const StyledChatInputContainer =
+  styled.div<StyledChatInputContainerProps>(({ theme, width, position }) => {
+    const lightTheme = hasLightBackgroundColor(theme)
+    return {
+      borderRadius: theme.radii.lg,
+      display: "flex",
+      ...(position === ChatInputProto.Position.BOTTOM && {
+        backgroundColor: lightTheme
+          ? theme.colors.gray20
+          : theme.colors.gray90,
+      }),
+      width: `${width}px`,
+    }
+  })
+
+export const StyledChatInput = styled.div(({ theme }) => {
+  return {
+    backgroundColor: theme.colors.transparent,
+    position: "relative",
+    flexGrow: 1,
+    borderRadius: theme.radii.lg,
+    display: "flex",
+    alignItems: "center",
+  }
+})
+
+interface StyledSendIconButtonProps {
+  disabled: boolean
+  extended: boolean
+}
+
+export const StyledSendIconButton = styled.button<StyledSendIconButtonProps>(
+  ({ theme, disabled, extended }) => {
+    const lightTheme = hasLightBackgroundColor(theme)
+    const [cleanIconColor, dirtyIconColor] = lightTheme
+      ? [theme.colors.gray60, theme.colors.gray80]
+      : [theme.colors.gray80, theme.colors.gray40]
+    return {
+      border: "none",
+      backgroundColor: theme.colors.transparent,
+      borderTopRightRadius: extended ? theme.radii.none : theme.radii.lg,
+      borderTopLeftRadius: extended ? theme.radii.lg : theme.radii.none,
+      borderBottomRightRadius: theme.radii.lg,
+      display: "inline-flex",
+      alignItems: "center",
+      justifyContent: "center",
+      lineHeight: 1,
+      margin: 0,
+      padding: theme.spacing.sm,
+      color: disabled ? cleanIconColor : dirtyIconColor,
+      pointerEvents: "auto",
+      "&:focus": {
+        outline: "none",
+      },
+      ":focus": {
+        outline: "none",
+      },
+      "&:focus-visible": {
+        backgroundColor: lightTheme
+          ? theme.colors.gray10
+          : theme.colors.gray90,
+      },
+      "&:hover": {
+        backgroundColor: theme.colors.primary,
+        color: theme.colors.white,
+      },
+      "&:disabled, &:disabled:hover, &:disabled:active": {
+        backgroundColor: theme.colors.transparent,
+        borderColor: theme.colors.transparent,
+        color: theme.colors.gray,
+      },
+    }
+  }
+)
+
+export const StyledFloatingChatInputContainer = styled.div(({ theme }) => ({
+  position: "fixed",
+  bottom: "0px",
+  paddingBottom: "70px",
+  paddingTop: theme.spacing.lg,
+  backgroundColor: theme.colors.bgColor,
+  zIndex: theme.zIndices.chatInput,
+  [`@media (max-width: ${theme.breakpoints.md})`]: {
+    display: "flex",
+    flexDirection: "column",
+    alignItems: "center",
+    left: 0,
+    width: "100vw",
+  },
+}))
+
+export const StyledSendIconButtonContainer = styled.div(() => ({
+  display: "flex",
+  alignItems: "flex-end",
+  height: "100%",
+  position: "absolute",
+  right: "0px",
+  pointerEvents: "none",
+}))
+
+export const StyledInputInstructionsContainer = styled.div({
+  position: "absolute",
+  bottom: "0px",
+  right: "3rem",
+})
diff --git a/frontend/src/lib/hooks/useScrollAnimation.test.ts b/frontend/src/lib/hooks/useScrollAnimation.test.ts
new file mode 100644
index 000000000000..5fbb8d4c6f02
--- /dev/null
+++ b/frontend/src/lib/hooks/useScrollAnimation.test.ts
@@ -0,0 +1,126 @@
+/**
+ * Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { renderHook, act } from "@testing-library/react-hooks"
+import useScrollAnimation from "./useScrollAnimation"
+
+describe("useScrollAnimation", () => {
+  let targetElement: HTMLElement
+  let onEndMock: () => void
+  let scrollHeight: number
+  let offsetHeight: number
+
+  beforeEach(() => {
+    targetElement = document.createElement("div")
+    targetElement.scrollTop = 0
+    scrollHeight = 200
+    offsetHeight = 100
+    Object.defineProperty(targetElement, "scrollHeight", {
+      set: value => (scrollHeight = value),
+      get: () => scrollHeight,
+    })
+    Object.defineProperty(targetElement, "offsetHeight", {
+      set: value => (offsetHeight = value),
+      get: () => offsetHeight,
+    })
+    targetElement.addEventListener = jest.fn()
+    targetElement.removeEventListener = jest.fn()
+    onEndMock = jest.fn()
+  })
+
+  afterEach(() => {
+    jest.clearAllMocks()
+  })
+
+  it("should animate scroll", () => {
+    jest.useFakeTimers()
+
+    renderHook(() => useScrollAnimation(targetElement, onEndMock, true))
+
+    // Simulate scroll animation
+    act(() => {
+      jest.advanceTimersByTime(5)
+      // Trigger the callback of requestAnimationFrame
+      act(() => {
+        jest.runOnlyPendingTimers()
+      })
+    })
+
+    // Assert the updated scrollTop value
+    expect(targetElement.scrollTop).toBeGreaterThan(0)
+
+    // Simulate reaching the end of animation
+    act(() => {
+      jest.advanceTimersByTime(100)
+      // Trigger the callback of requestAnimationFrame
+      act(() => {
+        jest.runOnlyPendingTimers()
+      })
+    })
+
+    // Assert that onEnd callback is called
+    expect(onEndMock).toHaveBeenCalled()
+
+    jest.useRealTimers()
+  })
+
+  it("should register and deregister the correct events", () => {
+    jest.useFakeTimers()
+
+    const { unmount } = renderHook(() =>
+      useScrollAnimation(targetElement, onEndMock, true)
+    )
+
+    expect(targetElement.addEventListener).toHaveBeenCalledTimes(2)
+    expect(targetElement.addEventListener).toHaveBeenCalledWith(
+      "pointerdown",
+      expect.any(Function),
+      { passive: true }
+    )
+    expect(targetElement.addEventListener).toHaveBeenCalledWith(
+      "wheel",
+      expect.any(Function),
+      { passive: true }
+    )
+
+    unmount()
+
+    // Cleanup
+    expect(targetElement.removeEventListener).toHaveBeenCalledTimes(2)
+    expect(targetElement.removeEventListener).toHaveBeenCalledWith(
+      "pointerdown",
+      expect.any(Function)
+    )
+    expect(targetElement.removeEventListener).toHaveBeenCalledWith(
+      "wheel",
+      expect.any(Function)
+    )
+
+    jest.useRealTimers()
+  })
+
+  it("should not animate scroll if target element is null", () => {
+    renderHook(() => useScrollAnimation(null, onEndMock, true))
+
+    expect(targetElement.addEventListener).not.toHaveBeenCalled()
+  })
+
+  it("should not animate scroll if isAnimating is false", () => {
+    renderHook(() => useScrollAnimation(targetElement, onEndMock, false))
+
+    expect(targetElement.addEventListener).not.toHaveBeenCalled()
+  })
+})
diff --git a/frontend/src/lib/hooks/useScrollAnimation.ts b/frontend/src/lib/hooks/useScrollAnimation.ts
new file mode 100644
index 000000000000..05d911934e63
--- /dev/null
+++ b/frontend/src/lib/hooks/useScrollAnimation.ts
@@ -0,0 +1,146 @@
+/**
+ * Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { useCallback, useLayoutEffect, useRef } from "react"
+
+/**
+ * Computes next step in a square-root based animation sequence. Step size is
+ * square root of absolute difference between current and target values. Can be
+ * used as a stepper function for other higher-level stepping functions.
+ *
+ * @param {number} current - Current value in animation sequence.
+ * @param {number} to - Target value of animation sequence.
+ * @returns {number} Next value in animation sequence.
+ */
+function squareStepper(current: number, to: number): number {
+  const sign = Math.sign(to - current)
+  const step = Math.sqrt(Math.abs(to - current))
+  const next = current + step * sign
+
+  if (sign > 0) {
+    return Math.min(to, next)
+  }
+
+  return Math.max(to, next)
+}
+
+/**
+ * Computes sequence of steps in animation by repeatedly applying a stepper
+ * function.
+ *
+ * @param {number} from - Initial value in animation sequence.
+ * @param {number} to - Target value of animation sequence.
+ * @param {function} stepper - Function computing next value given current
+ *                             and target values.
+ * @param {number} index - Number of steps to compute.
+ * @returns {number} Value at given index in animation sequence.
+ */
+function step(
+  from: number,
+  to: number,
+  stepper: (x: number, y: number) => number,
+  index: number
+): number {
+  let next = from
+
+  for (let i = 0; i < index; i++) {
+    next = stepper(next, to)
+  }
+
+  return next
+}
+
+/**
+ * Handles scroll animation for a given target HTMLElement. Uses a square-root
+ * based stepping function to compute scroll animation. Stops animation if
+ * target's scrollTop has reached scrollHeight or if user interacts with target
+ * (mousedown or mousewheel). Can also be cancelled by caller.
+ *
+ * @export
+ * @param {HTMLElement | null} target - HTML element to animate scroll of. If
+ *                                      null, no animation is performed.
+ * @param {() => void} onEnd - Callback when animation ends or is cancelled.
+ * @param {boolean} isAnimating - Boolean to start or stop animation. If false,
+ *                                no animation is performed.
+ * @returns {void}
+ */
+export default function useScrollAnimation(
+  target: HTMLElement | null,
+  onEnd: () => void,
+  isAnimating: boolean
+): void {
+  const animator = useRef(0)
+
+  const animate = useCallback(
+    (from, index, start = Date.now()) => {
+      cancelAnimationFrame(animator.current)
+
+      animator.current = requestAnimationFrame(() => {
+        if (target) {
+          const toNumber = target.scrollHeight - target.offsetHeight
+          let nextValue = step(
+            from,
+            toNumber,
+            squareStepper,
+            (Date.now() - start) / 5
+          )
+
+          if (Math.abs(toNumber - nextValue) < 1.5) {
+            nextValue = toNumber
+          }
+
+          target.scrollTop = nextValue
+
+          if (toNumber === nextValue) {
+            onEnd()
+          } else {
+            animate(from, index + 1, start)
+          }
+        }
+      })
+    },
+    [animator, onEnd, target]
+  )
+
+  const handleCancelAnimation = useCallback(() => {
+    cancelAnimationFrame(animator.current)
+    onEnd()
+  }, [onEnd])
+
+  useLayoutEffect(() => {
+    if (!target || !isAnimating) {
+      return
+    }
+    animate(target.scrollTop, 1)
+
+    if (target) {
+      target.addEventListener("pointerdown", handleCancelAnimation, {
+        passive: true,
+      })
+      target.addEventListener("wheel", handleCancelAnimation, {
+        passive: true,
+      })
+
+      return () => {
+        target.removeEventListener("pointerdown", handleCancelAnimation)
+        target.removeEventListener("wheel", handleCancelAnimation)
+        cancelAnimationFrame(animator.current)
+      }
+    }
+
+    return () => cancelAnimationFrame(animator.current)
+  }, [animate, animator, handleCancelAnimation, target, isAnimating])
+}
diff --git a/frontend/src/lib/hooks/useScrollSpy.test.ts b/frontend/src/lib/hooks/useScrollSpy.test.ts
new file mode 100644
index 000000000000..1beaa2cb9613
--- /dev/null
+++ b/frontend/src/lib/hooks/useScrollSpy.test.ts
@@ -0,0 +1,137 @@
+/**
+ * Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { renderHook, act } from "@testing-library/react-hooks"
+import useScrollSpy, { debounce } from "./useScrollSpy"
+
+describe("debounce function", () => {
+  beforeEach(() => {
+    jest.useFakeTimers()
+  })
+  it("should call the function immediately when no delay is provided", () => {
+    const fn = jest.fn()
+    const debouncedFn = debounce(fn, 0)
+
+    debouncedFn("arg1", "arg2")
+
+    expect(fn).toHaveBeenCalledTimes(1)
+    expect(fn).toHaveBeenCalledWith("arg1", "arg2")
+  })
+
+  it("should delay the function call when a delay is provided", () => {
+    const fn = jest.fn()
+    const debouncedFn = debounce(fn, 100)
+    debouncedFn("arg1", "arg2")
+    expect(fn).toHaveBeenCalledTimes(1)
+    expect(fn).toHaveBeenCalledWith("arg1", "arg2")
+
+    debouncedFn("arg3", "arg4")
+    expect(fn).not.toHaveBeenCalledWith("arg3", "arg4")
+    jest.advanceTimersByTime(99)
+    expect(fn).not.toHaveBeenCalledWith("arg3", "arg4")
+
+    jest.advanceTimersByTime(1)
+    expect(fn).toHaveBeenCalledTimes(2)
+    expect(fn).toHaveBeenCalledWith("arg3", "arg4")
+  })
+
+  it("should cancel the delay when the function is called again", () => {
+    const fn = jest.fn()
+    const debouncedFn = debounce(fn, 100)
+    debouncedFn("arg1", "arg2")
+    expect(fn).toHaveBeenCalledTimes(1)
+    expect(fn).toHaveBeenCalledWith("arg1", "arg2")
+
+    debouncedFn("arg3", "arg4")
+    jest.advanceTimersByTime(99)
+
+    debouncedFn("arg5", "arg6")
+    expect(fn).not.toHaveBeenCalledWith("arg5", "arg6")
+
+    jest.advanceTimersByTime(1)
+    expect(fn).toHaveBeenCalledTimes(2)
+    expect(fn).toHaveBeenCalledWith("arg5", "arg6")
+  })
+})
+
+describe("useScrollSpy hook", () => {
+  let target: HTMLElement
+  let eventHandler: ({ timeStampLow }: any) => void
+
+  beforeEach(() => {
+    jest.useFakeTimers()
+    target = document.createElement("div")
+    eventHandler = jest.fn()
+
+    document.body.appendChild(target)
+    jest.spyOn(target, "addEventListener")
+    jest.spyOn(target, "removeEventListener")
+  })
+
+  afterEach(() => {
+    document.body.removeChild(target)
+    jest.clearAllMocks()
+  })
+
+  it("should set up and clean up event listeners", () => {
+    const { unmount } = renderHook(() => useScrollSpy(target, eventHandler))
+
+    expect(target.addEventListener).toHaveBeenCalledWith(
+      "scroll",
+      expect.any(Function),
+      { passive: true }
+    )
+    expect(eventHandler).toHaveBeenCalledWith({
+      target,
+      timeStampLow: expect.any(Number),
+    })
+
+    unmount()
+
+    expect(target.removeEventListener).toHaveBeenCalledWith(
+      "scroll",
+      expect.any(Function)
+    )
+  })
+
+  it("should not set up event listeners if no target is provided", () => {
+    renderHook(() => useScrollSpy(null, eventHandler))
+
+    expect(target.addEventListener).not.toHaveBeenCalled()
+    expect(eventHandler).not.toHaveBeenCalled()
+  })
+
+  it("should debounce events", () => {
+    renderHook(() => useScrollSpy(target, eventHandler))
+
+    const scrollEvent = new Event("scroll")
+    act(() => {
+      target.dispatchEvent(scrollEvent)
+    })
+    expect(eventHandler).toHaveBeenCalledTimes(1)
+
+    act(() => {
+      target.dispatchEvent(scrollEvent)
+    })
+    expect(eventHandler).toHaveBeenCalledTimes(1)
+
+    jest.advanceTimersByTime(99)
+    expect(eventHandler).toHaveBeenCalledTimes(1)
+
+    jest.advanceTimersByTime(1)
+    expect(eventHandler).toHaveBeenCalledTimes(2)
+  })
+})
diff --git a/frontend/src/lib/hooks/useScrollSpy.ts b/frontend/src/lib/hooks/useScrollSpy.ts
new file mode 100644
index 000000000000..2f369e665f33
--- /dev/null
+++ b/frontend/src/lib/hooks/useScrollSpy.ts
@@ -0,0 +1,130 @@
+/**
+ * Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { useLayoutEffect, useMemo, useRef, useCallback } from "react"
+
+/**
+ * Creates a debounced function that delays invoking `fn` until after `ms`
+ * milliseconds have passed since the last time the debounced function was
+ * invoked.
+ *
+ * @param fn - A function to debounce. `fn` is called after the debounced
+ * function has not been called for `ms` milliseconds.
+ *
+ * @param ms - The delay in milliseconds before `fn` is executed.
+ *
+ * The debounced function behaves as follows:
+ * - It will be invoked immediately if 0 is passed for `ms`.
+ * - It will be invoked immediately for the first call in any case.
+ * - For subsequent calls, if less than `ms` milliseconds have passed since
+ * the last invocation, a new invocation of `fn` is scheduled for `ms`
+ * milliseconds after the last invocation.
+ * - If it is invoked and `ms` milliseconds have passed since the last
+ * invocation, `fn` is executed immediately and the timestamp is updated.
+ * - If a new invocation of the debounced function is scheduled and it
+ * is invoked again before the scheduled invocation, the scheduled
+ * invocation is canceled and a new one is scheduled `ms` milliseconds
+ * after the latest invocation.
+ *
+ * TODO: This has very similar but different behavior than our debounce function
+ * in utils.ts. This behavior ensures that the debounced function is called on
+ * some interval. Our other debounce function ensures that the function is
+ * delayed until the user stops calling it. We should probably unify these
+ *
+ * @returns A debounced version of the `fn` function.
+ */
+export function debounce(
+  fn: (...args: any[]) => void,
+  ms: number
+): (...args: any[]) => void {
+  if (!ms) {
+    return fn
+  }
+
+  let last = 0
+  let timeout: ReturnType<typeof setTimeout> | null = null
+
+  return (...args) => {
+    const now = Date.now()
+
+    if (now - last > ms) {
+      fn(...args)
+      last = now
+    } else {
+      if (timeout) {
+        clearTimeout(timeout)
+      }
+
+      timeout = setTimeout(() => {
+        fn(...args)
+        last = Date.now()
+      }, Math.max(0, ms - now + last))
+    }
+  }
+}
+const DEFAULT_DEBOUNCE_MS = 100
+
+/**
+ * A hook to add a scroll event listener to a target element with debouncing.
+ *
+ * @param target - The target HTMLElement to attach the scroll listener to.
+ * @param eventHandler - The callback function to execute on scroll.
+ *
+ * The hook behaves as follows:
+ * - The eventHandler callback is wrapped in a debounce function, which
+ * ensures the callback is not executed too frequently.
+ * - A scroll event listener is added to the target element on mount.
+ * - A 'timeStampLow' property is added to the event object before it's
+ * passed to the eventHandler.
+ * - The scroll event listener is removed from the target when the component
+ * unmounts.
+ *
+ * @returns void.
+ */
+export default function useScrollSpy(
+  target: HTMLElement | null,
+  eventHandler: ({ timeStampLow }: any) => void
+): void {
+  const onEventRef = useRef(eventHandler)
+
+  const debouncer = useMemo(
+    () =>
+      debounce(event => {
+        onEventRef.current(event)
+      }, DEFAULT_DEBOUNCE_MS),
+    [onEventRef]
+  )
+
+  const handleEvent = useCallback(
+    event => {
+      event.timeStampLow = Date.now()
+
+      debouncer(event)
+    },
+    [debouncer]
+  )
+
+  useLayoutEffect(() => {
+    if (!target) {
+      return () => {}
+    }
+
+    target.addEventListener("scroll", handleEvent, { passive: true })
+    handleEvent({ target })
+
+    return () => target.removeEventListener("scroll", handleEvent)
+  }, [handleEvent, target])
+}
diff --git a/frontend/src/lib/hooks/useScrollToBottom.test.ts b/frontend/src/lib/hooks/useScrollToBottom.test.ts
new file mode 100644
index 000000000000..f61d13bec541
--- /dev/null
+++ b/frontend/src/lib/hooks/useScrollToBottom.test.ts
@@ -0,0 +1,44 @@
+/**
+ * Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { renderHook } from "@testing-library/react-hooks"
+import useScrollToBottom from "./useScrollToBottom"
+import useStateRef from "./useStateRef"
+
+jest.mock("./useScrollSpy")
+jest.mock("./useScrollAnimation")
+jest.mock("./useStateRef")
+
+describe("useScrollToBottom", () => {
+  beforeEach(() => {
+    jest.clearAllMocks()
+  })
+
+  it("should initialize with proper values", () => {
+    const mockedUseStateRef = useStateRef as jest.MockedFunction<
+      typeof useStateRef
+    >
+    mockedUseStateRef.mockImplementation(initialValue => [
+      initialValue,
+      jest.fn(),
+      { current: initialValue },
+    ])
+    const { result } = renderHook(() => useScrollToBottom())
+
+    expect(result.current).not.toBeNull()
+    expect(result.current.current).toBeNull()
+  })
+})
diff --git a/frontend/src/lib/hooks/useScrollToBottom.ts b/frontend/src/lib/hooks/useScrollToBottom.ts
new file mode 100644
index 000000000000..877d0872ee6b
--- /dev/null
+++ b/frontend/src/lib/hooks/useScrollToBottom.ts
@@ -0,0 +1,250 @@
+/**
+ * Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { useEffect, useRef, useCallback, RefObject } from "react"
+import useScrollSpy from "./useScrollSpy"
+import useScrollAnimation from "./useScrollAnimation"
+import useStateRef from "./useStateRef"
+
+export interface ScrollToBottomOptions {
+  bottomThreshold?: number
+  debounceMs?: number
+}
+
+const DEFAULT_BOTTOM_THRESHOLD = 1
+const SCROLL_DECISION_DURATION = 34 // 2 frames
+const MIN_CHECK_INTERVAL = 17 // 1 frame
+
+function setImmediateInterval(fn: () => void, ms: number): NodeJS.Timeout {
+  fn()
+
+  return setInterval(fn, ms)
+}
+
+function isAtBottom({
+  scrollHeight,
+  offsetHeight,
+  scrollTop,
+}: HTMLElement): boolean {
+  return scrollHeight - scrollTop - offsetHeight < DEFAULT_BOTTOM_THRESHOLD
+}
+
+/**
+ * useScrollToBottom is a custom React hook managing automatic
+ * scrolling behavior for an HTML element. It keeps the scroll view
+ * at the bottom unless a user scrolls up, then stops auto-scroll.
+ *
+ * This hook returns a ref object attached to the HTML element.
+ * It maintains several pieces of state:
+ * - isSticky: a boolean for whether the scroll position should
+ *   "stick" to the bottom.
+ * - isAnimating: a boolean for whether the scroll view is animating.
+ *
+ * It has two major functions:
+ * - handleScrollToBottomFinished: resets stickiness if necessary.
+ * - handleScroll: adjusts isSticky based on user scroll behavior.
+ *
+ * The hook includes side effects with the useEffect hook:
+ * - The first effect sets an interval to check the scroll position
+ *   and adjust stickiness and animating state.
+ * - The second effect attaches a focus event listener to update
+ *   the scrollHeight value.
+ */
+function useScrollToBottom<T extends HTMLElement>(): RefObject<T> {
+  const scrollableRef = useRef<T>(null)
+  const [isSticky, setIsSticky, isStickyRef] = useStateRef(false)
+  const [isAnimating, setIsAnimating, isAnimatingRef] = useStateRef(true)
+
+  // Internal context
+  const ignoreScrollEventBeforeRef = useRef(0)
+  const offsetHeightRef = useRef(0)
+  const scrollHeightRef = useRef(0)
+
+  // Once, we have determined we are at the bottom, we can reset
+  // the ignoring of scroll events.
+  const handleScrollToBottomFinished = useCallback(() => {
+    ignoreScrollEventBeforeRef.current = Date.now()
+
+    // handleScrollToBottomFinished may end at a position which should lose stickiness.
+    // In that case, we will need to set sticky to false to stop the interval check.
+    if (!isAnimatingRef.current) {
+      // Essentially we are not suppose to be animating cause a scroll
+      // occurred before we finished animating.
+      setIsSticky(false)
+    }
+
+    setIsAnimating(false)
+  }, [ignoreScrollEventBeforeRef, isAnimatingRef, setIsAnimating, setIsSticky])
+
+  const handleScroll = useCallback(
+    ({ timeStampLow }) => {
+      const { current: target } = scrollableRef
+      const animating = isAnimatingRef.current
+
+      // Currently, there are no reliable way to check if the "scroll" event is trigger due to
+      // user gesture, programmatic scrolling, or Chrome-synthesized "scroll" event to compensate size change.
+      // Thus, we use our best-effort to guess if it is triggered by user gesture, and disable sticky if it is heading towards the start direction.
+
+      if (timeStampLow <= ignoreScrollEventBeforeRef.current || !target) {
+        // Since we debounce "scroll" event, this handler might be called after spineTo.onEnd (a.k.a. artificial scrolling).
+        // We should ignore debounced event fired after scrollEnd, because without skipping them, the userInitiatedScroll calculated below will not be accurate.
+        // Thus, on a fast machine, adding elements super fast will lose the "stickiness".
+
+        return
+      }
+
+      const atBottom = isAtBottom(target)
+
+      // Chrome will emit "synthetic" scroll event if the container is resized or an element is added
+      // We need to ignore these "synthetic" events
+      const {
+        offsetHeight: nextOffsetHeight,
+        scrollHeight: nextScrollHeight,
+      } = target
+      const { current: offsetHeight } = offsetHeightRef
+      const { current: scrollHeight } = scrollHeightRef
+      const offsetHeightChanged = nextOffsetHeight !== offsetHeight
+      const scrollHeightChanged = nextScrollHeight !== scrollHeight
+
+      if (offsetHeightChanged) {
+        offsetHeightRef.current = nextOffsetHeight
+      }
+
+      if (scrollHeightChanged) {
+        scrollHeightRef.current = nextScrollHeight
+      }
+
+      // Sticky means:
+      // - If it is scrolled programatically, we are still in sticky mode
+      // - If it is scrolled by the user, then sticky means if we are at the end
+
+      // Only update stickiness if the scroll event is not due to synthetic scroll done by Chrome
+      if (!offsetHeightChanged && !scrollHeightChanged) {
+        // We are sticky if we are animating to the end, or we are already at the end.
+        // We can be "animating but not sticky" by calling "scrollTo(100)" where the container scrollHeight is 200px.
+        const nextSticky = animating || atBottom
+
+        if (isStickyRef.current !== nextSticky) {
+          setIsSticky(nextSticky)
+        }
+      } else if (isStickyRef.current) {
+        setIsAnimating(true)
+        setIsSticky(true)
+      }
+    },
+    [
+      ignoreScrollEventBeforeRef,
+      offsetHeightRef,
+      scrollHeightRef,
+      isAnimatingRef,
+      isStickyRef,
+      setIsAnimating,
+      setIsSticky,
+    ]
+  )
+
+  useEffect(() => {
+    if (scrollableRef.current) {
+      let stickyButNotAtEndSince = 0
+
+      const timeout = setImmediateInterval(() => {
+        const { current: target } = scrollableRef
+        const animating = isAnimatingRef.current
+
+        if (isStickyRef.current && target) {
+          if (!isAtBottom(target)) {
+            if (!stickyButNotAtEndSince) {
+              stickyButNotAtEndSince = Date.now()
+            } else if (
+              Date.now() - stickyButNotAtEndSince >
+              SCROLL_DECISION_DURATION
+            ) {
+              // Quirks: In Firefox, after user scroll down, Firefox do two things:
+              //         1. Set to a new "scrollTop"
+              //         2. Fire "scroll" event
+              //         For what we observed, #1 is fired about 20ms before #2. There is a chance that this stickyCheckTimeout is being scheduled between 1 and 2.
+              //         That means, if we just look at #1 to decide if we should scroll, we will always scroll, in oppose to the user's intention.
+              // Repro: Open Firefox, set checkInterval to a lower number, and try to scroll by dragging the scroll handler. It will jump back.
+
+              // The "animating" check will make sure stickiness is not lost when elements are adding at a very fast pace.
+              if (!animating) {
+                setIsAnimating(true)
+                setIsSticky(true)
+              }
+
+              stickyButNotAtEndSince = 0
+            }
+          } else {
+            stickyButNotAtEndSince = 0
+          }
+        } else if (
+          target &&
+          target.scrollHeight <= target.offsetHeight &&
+          !isStickyRef.current
+        ) {
+          // When the container is emptied, we will set sticky back to true.
+          setIsSticky(true)
+        }
+      }, MIN_CHECK_INTERVAL)
+
+      return () => clearInterval(timeout)
+    }
+  }, [
+    scrollableRef,
+    isSticky,
+    isAnimating,
+    isAnimatingRef,
+    isStickyRef,
+    setIsSticky,
+    setIsAnimating,
+  ])
+
+  useEffect(() => {
+    // We need to update the "scrollHeight" value to latest when the user do a focus inside the box.
+    //
+    // This is because:
+    // - In our code that mitigate Chrome synthetic scrolling, that code will look at whether "scrollHeight" value is latest or not.
+    // - That code only run on "scroll" event.
+    // - That means, on every "scroll" event, if the "scrollHeight" value is not latest, we will skip modifying the stickiness.
+    // - That means, if the user "focus" to an element that cause the scroll view to scroll to the bottom, the user agent will fire "scroll" event.
+    //   Since the "scrollHeight" is not latest value, this "scroll" event will be ignored and stickiness will not be modified.
+    // - That means, if the user "focus" to a newly added element that is at the end of the scroll view, the "scroll to bottom" button will continue to show.
+    const target = scrollableRef.current
+    if (target) {
+      const handleFocus = (): void => {
+        scrollHeightRef.current = target.scrollHeight
+      }
+
+      target.addEventListener("focus", handleFocus, {
+        capture: true,
+        passive: true,
+      })
+
+      return () => target.removeEventListener("focus", handleFocus)
+    }
+  }, [scrollableRef])
+
+  useScrollSpy(scrollableRef.current, handleScroll)
+  useScrollAnimation(
+    scrollableRef.current,
+    handleScrollToBottomFinished,
+    isAnimating
+  )
+
+  return scrollableRef
+}
+
+export default useScrollToBottom
diff --git a/frontend/src/lib/hooks/useStateRef.test.ts b/frontend/src/lib/hooks/useStateRef.test.ts
new file mode 100644
index 000000000000..673db27f5873
--- /dev/null
+++ b/frontend/src/lib/hooks/useStateRef.test.ts
@@ -0,0 +1,92 @@
+/**
+ * Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { act, renderHook } from "@testing-library/react-hooks"
+import useStateRef from "./useStateRef" // import the hook
+
+describe("useStateRef hook", () => {
+  it("should initialize correctly with initial state", () => {
+    const { result } = renderHook(() => useStateRef(10))
+
+    const [state, , stateRef] = result.current
+
+    // Initial state is set correctly
+    expect(state).toEqual(10)
+    expect(stateRef.current).toEqual(10)
+  })
+
+  it("should set state correctly", () => {
+    const { result } = renderHook(() => useStateRef(10))
+    const [, setState, stateRef] = result.current
+
+    act(() => {
+      setState(20)
+    })
+
+    // State is updated
+    const state = result.current[0]
+    expect(state).toEqual(20)
+    expect(stateRef.current).toEqual(20)
+  })
+
+  it("should handle function update correctly", () => {
+    const { result } = renderHook(() => useStateRef(10))
+    const [, setState, stateRef] = result.current
+
+    act(() => {
+      setState(prev => prev + 10)
+    })
+
+    // State is updated
+    const state = result.current[0]
+    expect(state).toEqual(20)
+    expect(stateRef.current).toEqual(20)
+  })
+
+  it("should maintain reference correctly when state changes", () => {
+    const { result } = renderHook(() => useStateRef(10))
+    const [, setValue, initialRef] = result.current
+
+    act(() => {
+      setValue(20)
+    })
+
+    // Reference has not changed
+    expect(result.current[2]).toBe(initialRef)
+  })
+
+  it("should allow ref to be set independently from state", () => {
+    const { result } = renderHook(() => useStateRef(10))
+    const [, setState, stateRef] = result.current
+
+    act(() => {
+      stateRef.current = 20
+    })
+
+    let state = result.current[0]
+    expect(state).toEqual(10)
+    expect(stateRef.current).toEqual(20)
+
+    act(() => {
+      setState(20)
+    })
+
+    // State is updated
+    state = result.current[0]
+    expect(state).toEqual(20)
+    expect(stateRef.current).toEqual(20)
+  })
+})
diff --git a/frontend/src/lib/hooks/useStateRef.ts b/frontend/src/lib/hooks/useStateRef.ts
new file mode 100644
index 000000000000..7a7d162eb655
--- /dev/null
+++ b/frontend/src/lib/hooks/useStateRef.ts
@@ -0,0 +1,63 @@
+/**
+ * Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+  useRef,
+  useState,
+  MutableRefObject,
+  Dispatch,
+  SetStateAction,
+} from "react"
+
+/**
+ * A custom React Hook that extends useState by providing a mutable ref object
+ * to track the current state value.
+ *
+ * @template T The type of the state value.
+ * @param {T} initialState The initial state value.
+ * @returns {[T, Dispatch<SetStateAction<T>>, MutableRefObject<T>]} A tuple containing the
+ *   current state value, a function to update the state, and a mutable ref object.
+ *
+ * @example
+ * // Usage inside a component:
+ * const [count, setCount, countRef] = useStateRef(0);
+ *
+ * // Accessing the current state value:
+ * console.log(count); // Output: 0
+ * console.log(countRef.current); // Output: 0
+ *
+ * // Modifying the state value and updating the ref:
+ * setCount(10);
+ * console.log(count); // Output: 10
+ * console.log(countRef.current); // Output: 10
+ *
+ * // Comparing previous and current state values:
+ * const previousCount = useRef(countRef.current);
+ * console.log(previousCount.current); // Output: 10
+ * console.log(previousCount.current === countRef.current); // Output: true
+ *
+ * // Sharing the state value with other components:
+ * <ChildComponent countRef={countRef} />
+ */
+export default function useStateRef<T>(
+  initialState: T
+): [T, Dispatch<SetStateAction<T>>, MutableRefObject<T>] {
+  const [state, setState] = useState<T>(initialState)
+  const ref = useRef<T>(initialState)
+  ref.current = state
+
+  return [state, setState, ref]
+}
diff --git a/frontend/src/lib/theme/primitives/zIndices.ts b/frontend/src/lib/theme/primitives/zIndices.ts
index ac104c497d24..a18419d346db 100644
--- a/frontend/src/lib/theme/primitives/zIndices.ts
+++ b/frontend/src/lib/theme/primitives/zIndices.ts
@@ -18,6 +18,7 @@ const sidebar = 100
 const menuButton = sidebar + 10
 const balloons = 1000000
 const header = balloons - 10
+const chatInput = sidebar - 1
 const sidebarMobile = balloons - 5
 const popupMenu = balloons + 40
 const fullscreenWrapper = balloons + 50
@@ -35,4 +36,5 @@ export const zIndices = {
   popupMenu,
   fullscreenWrapper,
   tablePortal,
+  chatInput,
 }
diff --git a/lib/streamlit/__init__.py b/lib/streamlit/__init__.py
index fbd1e21d3645..d914d2f012e6 100644
--- a/lib/streamlit/__init__.py
+++ b/lib/streamlit/__init__.py
@@ -118,6 +118,8 @@ def _update_logger() -> None:
 button = _main.button
 caption = _main.caption
 camera_input = _main.camera_input
+chat_message = _main.chat_message
+chat_input = _main.chat_input
 checkbox = _main.checkbox
 code = _main.code
 columns = _main.columns
diff --git a/lib/streamlit/delta_generator.py b/lib/streamlit/delta_generator.py
index f83005282d52..dff9a8b694ff 100644
--- a/lib/streamlit/delta_generator.py
+++ b/lib/streamlit/delta_generator.py
@@ -46,6 +46,7 @@
 from streamlit.elements.bokeh_chart import BokehMixin
 from streamlit.elements.button import ButtonMixin
 from streamlit.elements.camera_input import CameraInputMixin
+from streamlit.elements.chat import ChatMixin
 from streamlit.elements.checkbox import CheckboxMixin
 from streamlit.elements.code import CodeMixin
 from streamlit.elements.color_picker import ColorPickerMixin
@@ -156,6 +157,7 @@ class DeltaGenerator(
     BokehMixin,
     ButtonMixin,
     CameraInputMixin,
+    ChatMixin,
     CheckboxMixin,
     CodeMixin,
     ColorPickerMixin,
@@ -278,7 +280,7 @@ def __init__(
         # Change the module of all mixin'ed functions to be st.delta_generator,
         # instead of the original module (e.g. st.elements.markdown)
         for mixin in self.__class__.__bases__:
-            for (name, func) in mixin.__dict__.items():
+            for name, func in mixin.__dict__.items():
                 if callable(func):
                     func.__module__ = self.__module__
 
@@ -599,6 +601,10 @@ def _block(
                 raise StreamlitAPIException(
                     "Columns can only be placed inside other columns up to one level of nesting."
                 )
+        if block_type == "chat_message" and block_type in frozenset(parent_block_types):
+            raise StreamlitAPIException(
+                "Chat messages cannot nested inside other chat messages."
+            )
         if block_type == "expandable" and block_type in frozenset(parent_block_types):
             raise StreamlitAPIException(
                 "Expanders may not be nested inside other expanders."
diff --git a/lib/streamlit/elements/__init__.py b/lib/streamlit/elements/__init__.py
index a8de2ef39318..73ab16b4fc1f 100644
--- a/lib/streamlit/elements/__init__.py
+++ b/lib/streamlit/elements/__init__.py
@@ -15,6 +15,7 @@
 WIDGETS = [
     "button",
     "camera_input",
+    "chat_input",
     "checkbox",
     "color_picker",
     "component_instance",
diff --git a/lib/streamlit/elements/chat.py b/lib/streamlit/elements/chat.py
new file mode 100644
index 000000000000..acfb344fa4ff
--- /dev/null
+++ b/lib/streamlit/elements/chat.py
@@ -0,0 +1,326 @@
+# Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+from __future__ import annotations
+
+from dataclasses import dataclass
+from enum import Enum
+from typing import TYPE_CHECKING, Optional, Tuple, cast
+
+from typing_extensions import Literal
+
+from streamlit import runtime
+from streamlit.elements.image import AtomicImage, WidthBehaviour, image_to_url
+from streamlit.elements.utils import check_callback_rules, check_session_state_rules
+from streamlit.errors import StreamlitAPIException
+from streamlit.proto.Block_pb2 import Block as BlockProto
+from streamlit.proto.ChatInput_pb2 import ChatInput as ChatInputProto
+from streamlit.proto.Common_pb2 import StringTriggerValue as StringTriggerValueProto
+from streamlit.proto.RootContainer_pb2 import RootContainer
+from streamlit.runtime.metrics_util import gather_metrics
+from streamlit.runtime.scriptrunner import get_script_run_ctx
+from streamlit.runtime.state import (
+    WidgetArgs,
+    WidgetCallback,
+    WidgetKwargs,
+    register_widget,
+)
+from streamlit.string_util import is_emoji
+from streamlit.type_util import Key, to_key
+
+if TYPE_CHECKING:
+    from streamlit.delta_generator import DeltaGenerator
+
+
+class PresetLabels(str, Enum):
+    USER = "user"
+    ASSISTANT = "assistant"
+
+
+def _process_avatar_input(
+    avatar: str | AtomicImage | None = None,
+) -> Tuple[BlockProto.ChatMessage.AvatarType.ValueType, str]:
+    """Detects the avatar type and prepares the avatar data for the frontend.
+
+    Parameters
+    ----------
+    avatar :
+        The avatar that was provided by the user.
+
+    Returns
+    -------
+    Tuple[AvatarType, str]
+        The detected avatar type and the prepared avatar data.
+    """
+    AvatarType = BlockProto.ChatMessage.AvatarType
+
+    if avatar is None:
+        return AvatarType.ICON, ""
+    elif isinstance(avatar, str) and avatar in [
+        PresetLabels.USER,
+        PresetLabels.ASSISTANT,
+    ]:
+        return AvatarType.ICON, avatar
+    elif isinstance(avatar, str) and is_emoji(avatar):
+        return AvatarType.EMOJI, avatar
+    else:
+        try:
+            # TODO(lukasmasuch): Pure SVGs are not yet supported here.
+            # They have a special handling in `st.image` and might require some refactoring.
+            return AvatarType.IMAGE, image_to_url(
+                avatar,
+                width=WidthBehaviour.ORIGINAL,
+                clamp=False,
+                channels="RGB",
+                output_format="auto",
+                image_id="",
+            )
+        except Exception as ex:
+            raise StreamlitAPIException(
+                "Failed to load the provided avatar value as an image."
+            ) from ex
+
+
+DISALLOWED_CONTAINERS_ERROR_TEXT = "`st.chat_input()` can't be used inside an `st.expander`, `st.form`, `st.tabs`, `st.columns`, or `st.sidebar`."
+
+
+@dataclass
+class ChatInputSerde:
+    def deserialize(
+        self, ui_value: Optional[StringTriggerValueProto], widget_id: str = ""
+    ) -> str | None:
+        if ui_value is None or not ui_value.HasField("data"):
+            return None
+
+        return ui_value.data
+
+    def serialize(self, v: str | None) -> StringTriggerValueProto:
+        return StringTriggerValueProto(data=v)
+
+
+class ChatMixin:
+    @gather_metrics("chat_message")
+    def chat_message(
+        self,
+        participant: Literal["user", "assistant"] | str,
+        *,
+        avatar: Literal["user", "assistant"] | str | AtomicImage | None = None,
+    ) -> "DeltaGenerator":
+        """Insert a chat message container.
+
+        To add elements to the returned container, you can use ``with`` notation
+        (preferred) or just call methods directly on the returned object. See the
+        examples below.
+
+        Parameters
+        ----------
+        participant : "user", "assistant", or str
+            The name of the chat participant. Can be "user" or "assistant" to enable
+            preset styling and avatars. For accessibility reasons, you should not
+            use an empty string.
+
+        avatar : str, numpy.ndarray, or BytesIO
+            The avatar shown next to the message. Can be one of:
+
+            * A single emoji, e.g. "🧑‍💻", "🤖", "🦖". Shortcodes are not supported.
+
+            * An image using one of the formats allowed for ``st.image``: path of a local
+                image file; URL to fetch the image from; array of shape (w,h) or (w,h,1)
+                for a monochrome image, (w,h,3) for a color image, or (w,h,4) for an RGBA image.
+
+            If None (default), uses default icons if ``participant`` is "user" or
+            "assistant", or the first letter of the ``participant`` value.
+
+        Returns
+        -------
+        Container
+            A single container that can hold multiple elements.
+
+        Examples
+        --------
+        You can use ``with`` notation to insert any element into an expander
+
+        >>> import streamlit as st
+        >>> import numpy as np
+        >>>
+        >>> with st.chat_message("user"):
+        ...     st.write("Hello 👋")
+        ...     st.line_chart(np.random.randn(30, 3))
+
+        .. output ::
+            https://doc-chat-message-user.streamlit.app/
+            height: 450px
+
+        Or you can just call methods directly in the returned objects:
+
+        >>> import streamlit as st
+        >>> import numpy as np
+        >>>
+        >>> message = st.chat_message("assistant"):
+        >>> message.write("Hello human")
+        >>> message.bar_chart(np.random.randn(30, 3))
+
+        .. output ::
+            https://doc-chat-message-assistant.streamlit.app/
+            height: 450px
+
+        """
+        if participant is None:
+            raise StreamlitAPIException("A participant is required for a chat message")
+
+        if avatar is None and (
+            participant.lower()
+            in [
+                PresetLabels.USER,
+                PresetLabels.ASSISTANT,
+            ]
+            or is_emoji(participant)
+        ):
+            # For selected labels, we are mapping the label to an avatar
+            avatar = participant.lower()
+        avatar_type, converted_avatar = _process_avatar_input(avatar)
+
+        message_container_proto = BlockProto.ChatMessage()
+        message_container_proto.participant = participant
+        message_container_proto.avatar = converted_avatar
+        message_container_proto.avatar_type = avatar_type
+        block_proto = BlockProto()
+        block_proto.allow_empty = True
+        block_proto.chat_message.CopyFrom(message_container_proto)
+
+        return self.dg._block(block_proto=block_proto)
+
+    @gather_metrics("chat_input")
+    def chat_input(
+        self,
+        placeholder: str = "Your message",
+        *,
+        key: Key | None = None,
+        max_chars: int | None = None,
+        disabled: bool = False,
+        on_submit: WidgetCallback | None = None,
+        args: WidgetArgs | None = None,
+        kwargs: WidgetKwargs | None = None,
+    ) -> str | None:
+        """Display a chat input widget.
+
+        .. warning::
+            Chat input can only be used once per app page and inside the main area of the app.
+            It cannot be used in the sidebar, columns, expanders, forms or tabs.
+            We plan to support this in the future.
+
+        Parameters
+        ----------
+        placeholder : str
+            A placeholder text shown when the chat input is empty. Defaults to
+            "Your message". For accessibility reasons, you should not use an
+            empty string.
+
+        key : str or int
+            An optional string or integer to use as the unique key for the widget.
+            If this is omitted, a key will be generated for the widget based on
+            its content. Multiple widgets of the same type may not share the same key.
+
+        max_chars : int or None
+            The maximum number of characters that can be entered. If None
+            (default), there will be no maximum.
+
+        disabled : bool
+            Whether the chat input should be disabled. Defaults to False.
+
+        on_submit : callable
+            An optional callback invoked when the chat input's value is submitted.
+
+        args : tuple
+            An optional tuple of args to pass to the callback.
+
+        kwargs : dict
+            An optional dict of kwargs to pass to the callback.
+
+        Returns
+        -------
+        str or None
+            The current (non-empty) value of the text input widget on the last
+            run of the app, None otherwise.
+
+        Examples
+        --------
+        >>> import streamlit as st
+        >>>
+        >>> prompt = st.chat_input("Say something")
+        >>> if prompt:
+        ...     st.write(f"User has sent the following prompt: {prompt}")
+
+        .. output ::
+            https://doc-chat-input.streamlit.app/
+            height: 350px
+
+        """
+        # We default to an empty string here and disallow user choice intentionally
+        default = ""
+        key = to_key(key)
+        check_callback_rules(self.dg, on_submit)
+        check_session_state_rules(default_value=default, key=key, writes_allowed=False)
+
+        # We omit this check for scripts running outside streamlit, because
+        # they will have no script_run_ctx.
+        if runtime.exists():
+            if (
+                len(list(self.dg._active_dg._parent_block_types)) > 0
+                or self.dg._active_dg._root_container == RootContainer.SIDEBAR
+            ):
+                # TODO: This allows the user to create a chat_input inside a
+                # container but it still cannot be in other containers. This is
+                # a result of the Vertical field not being set in Blocks by
+                # default and seems like a "bug", but one that is not producing
+                # major issues. We should look into what's the correct behavior
+                # to implement.
+                raise StreamlitAPIException(DISALLOWED_CONTAINERS_ERROR_TEXT)
+
+        chat_input_proto = ChatInputProto()
+        chat_input_proto.placeholder = str(placeholder)
+
+        if max_chars is not None:
+            chat_input_proto.max_chars = max_chars
+
+        chat_input_proto.default = default
+        chat_input_proto.position = ChatInputProto.Position.BOTTOM
+
+        ctx = get_script_run_ctx()
+
+        serde = ChatInputSerde()
+        widget_state = register_widget(
+            "chat_input",
+            chat_input_proto,
+            user_key=key,
+            on_change_handler=on_submit,
+            args=args,
+            kwargs=kwargs,
+            deserializer=serde.deserialize,
+            serializer=serde.serialize,
+            ctx=ctx,
+        )
+
+        chat_input_proto.disabled = disabled
+        if widget_state.value_changed and widget_state.value is not None:
+            chat_input_proto.value = widget_state.value
+            chat_input_proto.set_value = True
+
+        self.dg._enqueue("chat_input", chat_input_proto)
+        return widget_state.value if not widget_state.value_changed else None
+
+    @property
+    def dg(self) -> "DeltaGenerator":
+        """Get our DeltaGenerator."""
+        return cast("DeltaGenerator", self)
+        return cast("DeltaGenerator", self)
diff --git a/lib/streamlit/elements/layouts.py b/lib/streamlit/elements/layouts.py
index 61e84559fa91..1137c0aba57a 100644
--- a/lib/streamlit/elements/layouts.py
+++ b/lib/streamlit/elements/layouts.py
@@ -11,10 +11,8 @@
 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 # See the License for the specific language governing permissions and
 # limitations under the License.
-
 from typing import TYPE_CHECKING, List, Optional, Sequence, Union, cast
 
-from streamlit.deprecation_util import deprecate_func_name
 from streamlit.errors import StreamlitAPIException
 from streamlit.proto.Block_pb2 import Block as BlockProto
 from streamlit.runtime.metrics_util import gather_metrics
diff --git a/lib/streamlit/elements/utils.py b/lib/streamlit/elements/utils.py
index 0b089fc66afe..b1a5f32a2919 100644
--- a/lib/streamlit/elements/utils.py
+++ b/lib/streamlit/elements/utils.py
@@ -50,6 +50,11 @@ def check_callback_rules(
 
 _shown_default_value_warning: bool = False
 
+SESSION_STATE_WRITES_NOT_ALLOWED_ERROR_TEXT = """
+Values for st.button, st.download_button, st.file_uploader, st.data_editor,
+st.chat_input, and st.form cannot be set using st.session_state.
+"""
+
 
 def check_session_state_rules(
     default_value: Any, key: Optional[str], writes_allowed: bool = True
@@ -64,10 +69,7 @@ def check_session_state_rules(
         return
 
     if not writes_allowed:
-        raise StreamlitAPIException(
-            "Values for st.button, st.download_button, st.file_uploader, st.data_editor"
-            " and st.form cannot be set using st.session_state."
-        )
+        raise StreamlitAPIException(SESSION_STATE_WRITES_NOT_ALLOWED_ERROR_TEXT)
 
     if (
         default_value is not None
diff --git a/lib/streamlit/runtime/state/common.py b/lib/streamlit/runtime/state/common.py
index 9ff9e80b2ce9..7f02a2732836 100644
--- a/lib/streamlit/runtime/state/common.py
+++ b/lib/streamlit/runtime/state/common.py
@@ -26,6 +26,7 @@
 from streamlit.proto.Arrow_pb2 import Arrow
 from streamlit.proto.Button_pb2 import Button
 from streamlit.proto.CameraInput_pb2 import CameraInput
+from streamlit.proto.ChatInput_pb2 import ChatInput
 from streamlit.proto.Checkbox_pb2 import Checkbox
 from streamlit.proto.ColorPicker_pb2 import ColorPicker
 from streamlit.proto.Components_pb2 import ComponentInstance
@@ -47,6 +48,7 @@
     Arrow,
     Button,
     CameraInput,
+    ChatInput,
     Checkbox,
     ColorPicker,
     ComponentInstance,
diff --git a/lib/streamlit/runtime/state/session_state.py b/lib/streamlit/runtime/state/session_state.py
index da281b76bc51..74d6bf4f2b20 100644
--- a/lib/streamlit/runtime/state/session_state.py
+++ b/lib/streamlit/runtime/state/session_state.py
@@ -215,6 +215,8 @@ def get_serialized(self, k: str) -> WidgetStateProto | None:
             setattr(widget, field, json.dumps(serialized))
         elif field == "file_uploader_state_value":
             widget.file_uploader_state_value.CopyFrom(serialized)
+        elif field == "string_trigger_value":
+            widget.string_trigger_value.CopyFrom(serialized)
         else:
             setattr(widget, field, serialized)
 
@@ -525,13 +527,19 @@ def _reset_triggers(self) -> None:
         """Set all trigger values in our state dictionary to False."""
         for state_id in self._new_widget_state:
             metadata = self._new_widget_state.widget_metadata.get(state_id)
-            if metadata is not None and metadata.value_type == "trigger_value":
-                self._new_widget_state[state_id] = Value(False)
+            if metadata is not None:
+                if metadata.value_type == "trigger_value":
+                    self._new_widget_state[state_id] = Value(False)
+                elif metadata.value_type == "string_trigger_value":
+                    self._new_widget_state[state_id] = Value(None)
 
         for state_id in self._old_state:
             metadata = self._new_widget_state.widget_metadata.get(state_id)
-            if metadata is not None and metadata.value_type == "trigger_value":
-                self._old_state[state_id] = False
+            if metadata is not None:
+                if metadata.value_type == "trigger_value":
+                    self._old_state[state_id] = False
+                elif metadata.value_type == "string_trigger_value":
+                    self._old_state[state_id] = None
 
     def _remove_stale_widgets(self, active_widget_ids: set[str]) -> None:
         """Remove widget state for widgets whose ids aren't in `active_widget_ids`."""
diff --git a/lib/streamlit/runtime/state/widgets.py b/lib/streamlit/runtime/state/widgets.py
index 2f0b79667505..2b051d6987da 100644
--- a/lib/streamlit/runtime/state/widgets.py
+++ b/lib/streamlit/runtime/state/widgets.py
@@ -53,6 +53,7 @@
         "button": "trigger_value",
         "download_button": "trigger_value",
         "checkbox": "bool_value",
+        "chat_input": "string_trigger_value",
         "camera_input": "file_uploader_state_value",
         "color_picker": "string_value",
         "date_input": "string_array_value",
@@ -232,18 +233,22 @@ def coalesce_widget_states(
         wstate.id: wstate for wstate in new_states.widgets
     }
 
+    trigger_value_types = [("trigger_value", False), ("string_trigger_value", None)]
     for old_state in old_states.widgets:
-        if old_state.WhichOneof("value") == "trigger_value" and old_state.trigger_value:
-
-            # Ensure the corresponding new_state is also a trigger;
-            # otherwise, a widget that was previously a button but no longer is
-            # could get a bad value.
-            new_trigger_val = states_by_id.get(old_state.id)
+        for trigger_value_type, unset_value in trigger_value_types:
             if (
-                new_trigger_val
-                and new_trigger_val.WhichOneof("value") == "trigger_value"
+                old_state.WhichOneof("value") == trigger_value_type
+                and old_state.trigger_value != unset_value
             ):
-                states_by_id[old_state.id] = old_state
+                # Ensure the corresponding new_state is also a trigger;
+                # otherwise, a widget that was previously a button but no longer is
+                # could get a bad value.
+                new_trigger_val = states_by_id.get(old_state.id)
+                if (
+                    new_trigger_val
+                    and new_trigger_val.WhichOneof("value") == trigger_value_type
+                ):
+                    states_by_id[old_state.id] = old_state
 
     coalesced = WidgetStates()
     coalesced.widgets.extend(states_by_id.values())
diff --git a/lib/streamlit/type_util.py b/lib/streamlit/type_util.py
index 4f4aa8126b96..c95543aa0136 100644
--- a/lib/streamlit/type_util.py
+++ b/lib/streamlit/type_util.py
@@ -102,6 +102,7 @@
     "json_value",
     "string_value",
     "trigger_value",
+    "string_trigger_value",
 ]
 
 V_co = TypeVar(
diff --git a/lib/tests/streamlit/delta_generator_test.py b/lib/tests/streamlit/delta_generator_test.py
index ea26f5d02571..0dc0480e334e 100644
--- a/lib/tests/streamlit/delta_generator_test.py
+++ b/lib/tests/streamlit/delta_generator_test.py
@@ -96,6 +96,8 @@ def test_public_api(self):
                 "button",
                 "camera_input",
                 "caption",
+                "chat_input",
+                "chat_message",
                 "checkbox",
                 "code",
                 "color_picker",
diff --git a/lib/tests/streamlit/elements/chat_test.py b/lib/tests/streamlit/elements/chat_test.py
new file mode 100644
index 000000000000..0f300fcd381e
--- /dev/null
+++ b/lib/tests/streamlit/elements/chat_test.py
@@ -0,0 +1,207 @@
+# Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""chat input and message unit tests."""
+
+import pytest
+from parameterized import parameterized
+
+import streamlit as st
+from streamlit.elements.chat import DISALLOWED_CONTAINERS_ERROR_TEXT
+from streamlit.elements.utils import SESSION_STATE_WRITES_NOT_ALLOWED_ERROR_TEXT
+from streamlit.errors import StreamlitAPIException
+from streamlit.proto.Block_pb2 import Block as BlockProto
+from streamlit.proto.ChatInput_pb2 import ChatInput as ChatInputProto
+from tests.delta_generator_test_case import DeltaGeneratorTestCase
+
+
+class ChatTest(DeltaGeneratorTestCase):
+    """Test ability to marshall ChatInput and ChatMessage protos."""
+
+    def test_label_required(self):
+        """Test that label is required"""
+        with self.assertRaises(TypeError):
+            st.chat_message()
+
+    def test_nesting_is_disallowed(self):
+        """Test that it is not allowed to be nested."""
+        with self.assertRaises(StreamlitAPIException):
+            with st.chat_message("user"):
+                with st.chat_message("assistant"):
+                    st.write("hello")
+
+    def test_user_message(self):
+        """Test that the user message is correct."""
+        message = st.chat_message("user")
+
+        with message:
+            pass
+
+        message_block = self.get_delta_from_queue()
+
+        self.assertEqual(message_block.add_block.chat_message.participant, "user")
+        self.assertEqual(message_block.add_block.chat_message.avatar, "user")
+        self.assertEqual(
+            message_block.add_block.chat_message.avatar_type,
+            BlockProto.ChatMessage.AvatarType.ICON,
+        )
+
+    def test_assistant_message(self):
+        """Test that the assistant message is correct."""
+        message = st.chat_message("assistant")
+
+        with message:
+            pass
+
+        message_block = self.get_delta_from_queue()
+
+        self.assertEqual(message_block.add_block.chat_message.participant, "assistant")
+        self.assertEqual(message_block.add_block.chat_message.avatar, "assistant")
+        self.assertEqual(
+            message_block.add_block.chat_message.avatar_type,
+            BlockProto.ChatMessage.AvatarType.ICON,
+        )
+
+    def test_emoji_avatar(self):
+        """Test that it is possible to set an emoji as avatar."""
+
+        message = st.chat_message("user", avatar="👋")
+
+        with message:
+            pass
+
+        message_block = self.get_delta_from_queue()
+
+        self.assertEqual(message_block.add_block.chat_message.participant, "user")
+        self.assertEqual(message_block.add_block.chat_message.avatar, "👋")
+        self.assertEqual(
+            message_block.add_block.chat_message.avatar_type,
+            BlockProto.ChatMessage.AvatarType.EMOJI,
+        )
+
+    def test_image_avatar(self):
+        """Test that it is possible to set an image as avatar."""
+
+        message = st.chat_message(
+            "cat",
+            avatar="https://static.streamlit.io/examples/cat.jpg",
+        )
+
+        with message:
+            pass
+
+        message_block = self.get_delta_from_queue()
+        self.assertEqual(message_block.add_block.chat_message.participant, "cat")
+        self.assertEqual(
+            message_block.add_block.chat_message.avatar,
+            "https://static.streamlit.io/examples/cat.jpg",
+        )
+        self.assertEqual(
+            message_block.add_block.chat_message.avatar_type,
+            BlockProto.ChatMessage.AvatarType.IMAGE,
+        )
+
+    def test_throws_invalid_avatar_exception(self):
+        """Test that chat_message throws an StreamlitAPIException on invalid avatar input."""
+        with pytest.raises(StreamlitAPIException):
+            st.chat_message("user", avatar="FOOO")
+
+    def test_chat_input(self):
+        """Test that it can be called."""
+        st.chat_input("Placeholder")
+
+        c = self.get_delta_from_queue().new_element.chat_input
+        self.assertEqual(c.placeholder, "Placeholder")
+        self.assertEqual(c.default, "")
+        self.assertEqual(c.value, "")
+        self.assertEqual(c.set_value, False)
+        self.assertEqual(c.max_chars, 0)
+        self.assertEqual(c.disabled, False)
+        self.assertEqual(c.position, ChatInputProto.Position.BOTTOM)
+
+    def test_chat_input_disabled(self):
+        """Test that it sets disabled correctly."""
+        st.chat_input("Placeholder", disabled=True)
+
+        c = self.get_delta_from_queue().new_element.chat_input
+        self.assertEqual(c.placeholder, "Placeholder")
+        self.assertEqual(c.default, "")
+        self.assertEqual(c.value, "")
+        self.assertEqual(c.set_value, False)
+        self.assertEqual(c.max_chars, 0)
+        self.assertEqual(c.disabled, True)
+        self.assertEqual(c.position, ChatInputProto.Position.BOTTOM)
+
+    def test_chat_input_max_chars(self):
+        """Test that it sets max chars correctly."""
+        st.chat_input("Placeholder", max_chars=100)
+
+        c = self.get_delta_from_queue().new_element.chat_input
+        self.assertEqual(c.placeholder, "Placeholder")
+        self.assertEqual(c.default, "")
+        self.assertEqual(c.value, "")
+        self.assertEqual(c.set_value, False)
+        self.assertEqual(c.max_chars, 100)
+        self.assertEqual(c.disabled, False)
+        self.assertEqual(c.position, ChatInputProto.Position.BOTTOM)
+
+    @parameterized.expand(
+        [
+            lambda: st.columns(2)[0],
+            lambda: st.tabs(["Tab1", "Tab2"])[0],
+            lambda: st.expander("Expand Me"),
+            lambda: st.form("Form Key"),
+            lambda: st.sidebar,
+        ]
+    )
+    def test_chat_not_allowed_in_containers(self, container_call):
+        """Test that it disallows being called in containers."""
+        with pytest.raises(StreamlitAPIException) as exception_message:
+            container_call().chat_input("Placeholder")
+
+        self.assertEqual(
+            DISALLOWED_CONTAINERS_ERROR_TEXT,
+            str(exception_message.value),
+        )
+
+    @parameterized.expand(
+        [
+            lambda: st.columns(2)[0],
+            lambda: st.tabs(["Tab1", "Tab2"])[0],
+            lambda: st.expander("Expand Me"),
+            lambda: st.form("Form Key"),
+            lambda: st.sidebar,
+        ]
+    )
+    def test_chat_not_allowed_in_with_containers(self, container_call):
+        """Test that it disallows being called in containers (using with syntax)."""
+        with pytest.raises(StreamlitAPIException) as exception_message:
+            with container_call():
+                st.chat_input("Placeholder")
+
+        self.assertEqual(
+            DISALLOWED_CONTAINERS_ERROR_TEXT,
+            str(exception_message.value),
+        )
+
+    def test_session_state_rules(self):
+        """Test that it disallows being called in containers (using with syntax)."""
+        with pytest.raises(StreamlitAPIException) as exception_message:
+            st.session_state.my_key = "Foo"
+            st.chat_input("Placeholder", key="my_key")
+
+        self.assertEqual(
+            SESSION_STATE_WRITES_NOT_ALLOWED_ERROR_TEXT,
+            str(exception_message.value),
+        )
diff --git a/lib/tests/streamlit/runtime/state/widgets_test.py b/lib/tests/streamlit/runtime/state/widgets_test.py
index 9b8becdbb08b..7a9d4a5b0ad4 100644
--- a/lib/tests/streamlit/runtime/state/widgets_test.py
+++ b/lib/tests/streamlit/runtime/state/widgets_test.py
@@ -22,6 +22,7 @@
 import streamlit as st
 from streamlit import errors
 from streamlit.proto.Button_pb2 import Button as ButtonProto
+from streamlit.proto.Common_pb2 import StringTriggerValue as StringTriggerValueProto
 from streamlit.proto.WidgetStates_pb2 import WidgetStates
 from streamlit.runtime.scriptrunner.script_run_context import get_script_run_ctx
 from streamlit.runtime.state import coalesce_widget_states
@@ -181,6 +182,32 @@ def test_reset_triggers(self):
         self.assertFalse(session_state["trigger"])
         self.assertEqual(123, session_state["int"])
 
+    def test_reset_string_triggers(self):
+        states = WidgetStates()
+        session_state = SessionState()
+
+        _create_widget("string_trigger", states).string_trigger_value.CopyFrom(
+            StringTriggerValueProto(data="Some Value")
+        )
+        _create_widget("int", states).int_value = 123
+        session_state.set_widgets_from_proto(states)
+        session_state._set_widget_metadata(
+            WidgetMetadata(
+                "string_trigger", lambda x, s: x, None, "string_trigger_value"
+            )
+        )
+        session_state._set_widget_metadata(
+            WidgetMetadata("int", lambda x, s: x, None, "int_value")
+        )
+
+        self.assertEqual("Some Value", session_state["string_trigger"].data)
+        self.assertEqual(123, session_state["int"])
+
+        session_state._reset_triggers()
+
+        self.assertIsNone(session_state["string_trigger"])
+        self.assertEqual(123, session_state["int"])
+
     def test_coalesce_widget_states(self):
         session_state = SessionState()
 
@@ -188,6 +215,15 @@ def test_coalesce_widget_states(self):
 
         _create_widget("old_set_trigger", old_states).trigger_value = True
         _create_widget("old_unset_trigger", old_states).trigger_value = False
+        _create_widget(
+            "old_set_string_trigger", old_states
+        ).string_trigger_value.CopyFrom(StringTriggerValueProto(data="Some String"))
+        _create_widget(
+            "old_set_empty_string_trigger", old_states
+        ).string_trigger_value.CopyFrom(StringTriggerValueProto(data=""))
+        _create_widget(
+            "old_unset_string_trigger", old_states
+        ).string_trigger_value.CopyFrom(StringTriggerValueProto(data=None))
         _create_widget("missing_in_new", old_states).int_value = 123
         _create_widget("shape_changing_trigger", old_states).trigger_value = True
 
@@ -197,6 +233,15 @@ def test_coalesce_widget_states(self):
         session_state._set_widget_metadata(
             create_metadata("old_unset_trigger", "trigger_value")
         )
+        session_state._set_widget_metadata(
+            create_metadata("old_set_string_trigger", "string_trigger_value")
+        )
+        session_state._set_widget_metadata(
+            create_metadata("old_set_empty_string_trigger", "string_trigger_value")
+        )
+        session_state._set_widget_metadata(
+            create_metadata("old_unset_string_trigger", "string_trigger_value")
+        )
         session_state._set_widget_metadata(
             create_metadata("missing_in_new", "int_value")
         )
@@ -208,11 +253,25 @@ def test_coalesce_widget_states(self):
 
         _create_widget("old_set_trigger", new_states).trigger_value = False
         _create_widget("new_set_trigger", new_states).trigger_value = True
+        _create_widget(
+            "old_set_string_trigger", new_states
+        ).string_trigger_value.CopyFrom(StringTriggerValueProto(data=None))
+        _create_widget(
+            "old_set_empty_string_trigger", new_states
+        ).string_trigger_value.CopyFrom(StringTriggerValueProto(data=None))
+        _create_widget(
+            "new_set_string_trigger", new_states
+        ).string_trigger_value.CopyFrom(
+            StringTriggerValueProto(data="Some other string")
+        )
         _create_widget("added_in_new", new_states).int_value = 456
         _create_widget("shape_changing_trigger", new_states).int_value = 3
         session_state._set_widget_metadata(
             create_metadata("new_set_trigger", "trigger_value")
         )
+        session_state._set_widget_metadata(
+            create_metadata("new_set_string_trigger", "string_trigger_value")
+        )
         session_state._set_widget_metadata(create_metadata("added_in_new", "int_value"))
         session_state._set_widget_metadata(
             create_metadata("shape_changing_trigger", "int_value")
@@ -224,10 +283,16 @@ def test_coalesce_widget_states(self):
 
         self.assertRaises(KeyError, lambda: session_state["old_unset_trigger"])
         self.assertRaises(KeyError, lambda: session_state["missing_in_new"])
+        self.assertRaises(KeyError, lambda: session_state["old_unset_string_trigger"])
 
         self.assertEqual(True, session_state["old_set_trigger"])
         self.assertEqual(True, session_state["new_set_trigger"])
         self.assertEqual(456, session_state["added_in_new"])
+        self.assertEqual("Some String", session_state["old_set_string_trigger"].data)
+        self.assertEqual("", session_state["old_set_empty_string_trigger"].data)
+        self.assertEqual(
+            "Some other string", session_state["new_set_string_trigger"].data
+        )
 
         # Widgets that were triggers before, but no longer are, will *not*
         # be coalesced
diff --git a/lib/tests/streamlit/streamlit_test.py b/lib/tests/streamlit/streamlit_test.py
index 62aa12744511..aab6dbd477c8 100644
--- a/lib/tests/streamlit/streamlit_test.py
+++ b/lib/tests/streamlit/streamlit_test.py
@@ -70,6 +70,8 @@ def test_public_api(self):
                 "button",
                 "caption",
                 "camera_input",
+                "chat_input",
+                "chat_message",
                 "checkbox",
                 "code",
                 "columns",
diff --git a/proto/streamlit/proto/Block.proto b/proto/streamlit/proto/Block.proto
index 635ef6b162ca..8cfdc979cd9b 100644
--- a/proto/streamlit/proto/Block.proto
+++ b/proto/streamlit/proto/Block.proto
@@ -25,6 +25,7 @@ message Block {
     Form form = 5;
     TabContainer tab_container = 6;
     Tab tab = 7;
+    ChatMessage chat_message = 9;
   }
 
   bool allow_empty = 8;
@@ -58,5 +59,17 @@ message Block {
     string label = 1;
   }
 
-  // Next ID: 9
+  message ChatMessage {
+    enum AvatarType {
+      IMAGE = 0;
+      EMOJI = 1;
+      ICON = 2;
+    }
+
+    string participant = 1;
+    string avatar = 2;
+    AvatarType avatar_type = 3;
+  }
+
+  // Next ID: 10
 }
diff --git a/proto/streamlit/proto/ChatInput.proto b/proto/streamlit/proto/ChatInput.proto
new file mode 100644
index 000000000000..fc23d4bb872e
--- /dev/null
+++ b/proto/streamlit/proto/ChatInput.proto
@@ -0,0 +1,32 @@
+/**!
+ * Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+syntax = "proto3";
+
+message ChatInput {
+  enum Position {
+    BOTTOM = 0;
+  }
+
+  string id = 1;
+  string placeholder = 2;
+  uint32 max_chars = 3;
+  bool disabled = 4;
+  string value = 5;
+  bool set_value = 6;
+  string default = 7;
+  Position position = 8;
+}
diff --git a/proto/streamlit/proto/Common.proto b/proto/streamlit/proto/Common.proto
index 68430a2a8407..05b4cd64d7e1 100644
--- a/proto/streamlit/proto/Common.proto
+++ b/proto/streamlit/proto/Common.proto
@@ -42,6 +42,10 @@ message UInt32Array {
   repeated uint32 data = 1;
 }
 
+message StringTriggerValue {
+  optional string data = 1;
+}
+
 // Information on a file uploaded via the file_uploader widget.
 message UploadedFileInfo {
   sint64 id = 1;
diff --git a/proto/streamlit/proto/Element.proto b/proto/streamlit/proto/Element.proto
index 489d5da96a7e..6148cf8fef6d 100644
--- a/proto/streamlit/proto/Element.proto
+++ b/proto/streamlit/proto/Element.proto
@@ -25,6 +25,7 @@ import "streamlit/proto/BokehChart.proto";
 import "streamlit/proto/Button.proto";
 import "streamlit/proto/DownloadButton.proto";
 import "streamlit/proto/CameraInput.proto";
+import "streamlit/proto/ChatInput.proto";
 import "streamlit/proto/Checkbox.proto";
 import "streamlit/proto/Code.proto";
 import "streamlit/proto/ColorPicker.proto";
@@ -75,6 +76,7 @@ message Element {
     Button button = 19;
     DownloadButton download_button = 43;
     CameraInput camera_input = 45;
+    ChatInput chat_input = 49;
     Checkbox checkbox = 20;
     ColorPicker color_picker = 35;
     ComponentInstance component_instance = 37;
@@ -110,7 +112,7 @@ message Element {
     Video video = 14;
     Heading heading = 47;
     Code code = 48;
-    // Next ID: 49
+    // Next ID: 50
   }
 
   reserved 9;
diff --git a/proto/streamlit/proto/WidgetStates.proto b/proto/streamlit/proto/WidgetStates.proto
index 39c44b0fbb28..8ca074cd6c48 100644
--- a/proto/streamlit/proto/WidgetStates.proto
+++ b/proto/streamlit/proto/WidgetStates.proto
@@ -48,5 +48,8 @@ message WidgetState {
     ArrowTable arrow_value = 11;
     bytes bytes_value = 12;
     FileUploaderState file_uploader_state_value = 13;
+    // String value that resets itself to empty after the script has been run.
+    // This is used for the chat_input widget.
+    StringTriggerValue string_trigger_value = 14;
   }
 }


Code review comments:
diff_hunk: @@ -0,0 +1,326 @@
+# Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+from __future__ import annotations
+
+from dataclasses import dataclass
+from enum import Enum
+from typing import TYPE_CHECKING, Optional, Tuple, cast
+
+from typing_extensions import Literal
+
+from streamlit import runtime
+from streamlit.elements.image import AtomicImage, WidthBehaviour, image_to_url
+from streamlit.elements.utils import check_callback_rules, check_session_state_rules
+from streamlit.errors import StreamlitAPIException
+from streamlit.proto.Block_pb2 import Block as BlockProto
+from streamlit.proto.ChatInput_pb2 import ChatInput as ChatInputProto
+from streamlit.proto.Common_pb2 import StringTriggerValue as StringTriggerValueProto
+from streamlit.proto.RootContainer_pb2 import RootContainer
+from streamlit.runtime.metrics_util import gather_metrics
+from streamlit.runtime.scriptrunner import get_script_run_ctx
+from streamlit.runtime.state import (
+    WidgetArgs,
+    WidgetCallback,
+    WidgetKwargs,
+    register_widget,
+)
+from streamlit.string_util import is_emoji
+from streamlit.type_util import Key, to_key
+
+if TYPE_CHECKING:
+    from streamlit.delta_generator import DeltaGenerator
+
+
+class PresetLabels(str, Enum):
+    USER = "user"
+    ASSISTANT = "assistant"
+
+
+def _process_avatar_input(
+    avatar: str | AtomicImage | None = None,
+) -> Tuple[BlockProto.ChatMessage.AvatarType.ValueType, str]:
+    """Detects the avatar type and prepares the avatar data for the frontend.
+
+    Parameters
+    ----------
+    avatar :
+        The avatar that was provided by the user.
+
+    Returns
+    -------
+    Tuple[AvatarType, str]
+        The detected avatar type and the prepared avatar data.
+    """
+    AvatarType = BlockProto.ChatMessage.AvatarType
+
+    if avatar is None:
+        return AvatarType.ICON, ""
+    elif isinstance(avatar, str) and avatar in [
+        PresetLabels.USER,
+        PresetLabels.ASSISTANT,
+    ]:
+        return AvatarType.ICON, avatar
+    elif isinstance(avatar, str) and is_emoji(avatar):
+        return AvatarType.EMOJI, avatar
+    else:
+        try:
+            # TODO(lukasmasuch): Pure SVGs are not yet supported here.
+            # They have a special handling in `st.image` and might require some refactoring.
+            return AvatarType.IMAGE, image_to_url(
+                avatar,
+                width=WidthBehaviour.ORIGINAL,
+                clamp=False,
+                channels="RGB",
+                output_format="auto",
+                image_id="",
+            )
+        except Exception as ex:
+            raise StreamlitAPIException(
+                "Failed to load the provided avatar value as an image."
+            ) from ex
+
+
+DISALLOWED_CONTAINERS_ERROR_TEXT = "`st.chat_input()` can't be used inside an `st.expander`, `st.form`, `st.tabs`, `st.columns`, or `st.sidebar`."
+
+
+@dataclass
+class ChatInputSerde:
+    def deserialize(
+        self, ui_value: Optional[StringTriggerValueProto], widget_id: str = ""
+    ) -> str | None:
+        if ui_value is None or not ui_value.HasField("data"):
+            return None
+
+        return ui_value.data
+
+    def serialize(self, v: str | None) -> StringTriggerValueProto:
+        return StringTriggerValueProto(data=v)
+
+
+class ChatMixin:
+    @gather_metrics("chat_message")
+    def chat_message(
+        self,
+        participant: Literal["user", "assistant"] | str,
+        *,
+        avatar: Literal["user", "assistant"] | str | AtomicImage | None = None,
+    ) -> "DeltaGenerator":
+        """Insert a chat message container.
+
+        To add elements to the returned container, you can use ``with`` notation
+        (preferred) or just call methods directly on the returned object. See the
+        examples below.
+
+        Parameters
+        ----------
+        participant : "user", "assistant", or str
+            The name of the chat participant. Can be "user" or "assistant" to enable
+            preset styling and avatars. For accessibility reasons, you should not
+            use an empty string.
+
+        avatar : str, numpy.ndarray, or BytesIO
+            The avatar shown next to the message. Can be one of:
+
+            * A single emoji, e.g. "🧑‍💻", "🤖", "🦖". Shortcodes are not supported.
+
+            * An image using one of the formats allowed for ``st.image``: path of a local
+                image file; URL to fetch the image from; array of shape (w,h) or (w,h,1)
+                for a monochrome image, (w,h,3) for a color image, or (w,h,4) for an RGBA image.
+
+            If None (default), uses default icons if ``participant`` is "user" or
+            "assistant", or the first letter of the ``participant`` value.
+
+        Returns
+        -------
+        Container
+            A single container that can hold multiple elements.
+
+        Examples
+        --------
+        You can use ``with`` notation to insert any element into an expander
+
+        >>> import streamlit as st
+        >>> import numpy as np
+        >>>
+        >>> with st.chat_message("user"):
+        ...     st.write("Hello 👋")
+        ...     st.line_chart(np.random.randn(30, 3))
+
+        .. output ::
+            https://doc-chat-message-user.streamlit.app/
+            height: 450px
+
+        Or you can just call methods directly in the returned objects:
+
+        >>> import streamlit as st
+        >>> import numpy as np
+        >>>
+        >>> message = st.chat_message("assistant"):
+        >>> message.write("Hello human")
+        >>> message.bar_chart(np.random.randn(30, 3))
+
+        .. output ::
+            https://doc-chat-message-assistant.streamlit.app/
+            height: 450px
+
+        """
+        if participant is None:
+            raise StreamlitAPIException("A participant is required for a chat message")
+
+        if avatar is None and (
+            participant.lower()
+            in [
+                PresetLabels.USER,
+                PresetLabels.ASSISTANT,
+            ]
+            or is_emoji(participant)
+        ):
+            # For selected labels, we are mapping the label to an avatar
+            avatar = participant.lower()
+        avatar_type, converted_avatar = _process_avatar_input(avatar)
+
+        message_container_proto = BlockProto.ChatMessage()
+        message_container_proto.participant = participant
+        message_container_proto.avatar = converted_avatar
+        message_container_proto.avatar_type = avatar_type
+        block_proto = BlockProto()
+        block_proto.allow_empty = True
+        block_proto.chat_message.CopyFrom(message_container_proto)
+
+        return self.dg._block(block_proto=block_proto)
+
+    @gather_metrics("chat_input")
+    def chat_input(
+        self,
+        placeholder: str = "Your message",
+        *,
+        key: Key | None = None,
+        max_chars: int | None = None,
+        disabled: bool = False,
+        on_submit: WidgetCallback | None = None,
+        args: WidgetArgs | None = None,
+        kwargs: WidgetKwargs | None = None,
+    ) -> str | None:
+        """Display a chat input widget.
+
+        .. warning::
+            Chat input can only be used once per app page and inside the main area of the app.
+            It cannot be used in the sidebar, columns, expanders, forms or tabs.
+            We plan to support this in the future.
+
+        Parameters
+        ----------
+        placeholder : str
+            A placeholder text shown when the chat input is empty. Defaults to
+            "Your message". For accessibility reasons, you should not use an
+            empty string.
+
+        key : str or int
+            An optional string or integer to use as the unique key for the widget.
+            If this is omitted, a key will be generated for the widget based on
+            its content. Multiple widgets of the same type may not share the same key.
+
+        max_chars : int or None
+            The maximum number of characters that can be entered. If None
+            (default), there will be no maximum.
+
+        disabled : bool
+            Whether the chat input should be disabled. Defaults to False.
+
+        on_submit : callable
+            An optional callback invoked when the chat input's value is submitted.
+
+        args : tuple
+            An optional tuple of args to pass to the callback.
+
+        kwargs : dict
+            An optional dict of kwargs to pass to the callback.
+
+        Returns
+        -------
+        str or None
+            The current (non-empty) value of the text input widget on the last
+            run of the app, None otherwise.
+
+        Examples
+        --------
+        >>> import streamlit as st
+        >>>
+        >>> prompt = st.chat_input("Say something")
+        >>> if prompt:
+        ...     st.write(f"User has sent the following prompt: {prompt}")
+
+        .. output ::
+            https://doc-chat-input.streamlit.app/
+            height: 350px
+
+        """
+        # We default to an empty string here and disallow user choice intentionally
+        default = ""
+        key = to_key(key)
+        check_callback_rules(self.dg, on_submit)
+        check_session_state_rules(default_value=default, key=key, writes_allowed=False)
+
+        # We omit this check for scripts running outside streamlit, because
+        # they will have no script_run_ctx.
+        if runtime.exists():
+            if (
+                len(list(self.dg._active_dg._parent_block_types)) > 0
+                or self.dg._active_dg._root_container == RootContainer.SIDEBAR
+            ):
+                # TODO: This allows the user to create a chat_input inside a
+                # container but it still cannot be in other containers. This is
+                # a result of the Vertical field not being set in Blocks by
+                # default and seems like a "bug", but one that is not producing
+                # major issues. We should look into what's the correct behavior
+                # to implement.
+                raise StreamlitAPIException(DISALLOWED_CONTAINERS_ERROR_TEXT)
+
+        chat_input_proto = ChatInputProto()
+        chat_input_proto.placeholder = str(placeholder)
+
+        if max_chars is not None:
+            chat_input_proto.max_chars = max_chars
+
+        chat_input_proto.default = default
+        chat_input_proto.position = ChatInputProto.Position.BOTTOM
+
+        ctx = get_script_run_ctx()
+
+        serde = ChatInputSerde()
+        widget_state = register_widget(
+            "chat_input",
+            chat_input_proto,
+            user_key=key,
+            on_change_handler=on_submit,
+            args=args,
+            kwargs=kwargs,
+            deserializer=serde.deserialize,
+            serializer=serde.serialize,
+            ctx=ctx,
+        )
+
+        chat_input_proto.disabled = disabled
+        if widget_state.value_changed and widget_state.value is not None:
+            chat_input_proto.value = widget_state.value
+            chat_input_proto.set_value = True
+
+        self.dg._enqueue("chat_input", chat_input_proto)
+        return widget_state.value if not widget_state.value_changed else None
+
+    @property
+    def dg(self) -> "DeltaGenerator":
+        """Get our DeltaGenerator."""
+        return cast("DeltaGenerator", self)
+        return cast("DeltaGenerator", self)
path: lib/streamlit/elements/chat.py
text: @user1:
## Unreachable code

This statement is unreachable.

[Show more details](https://github.com/streamlit/streamlit/security/code-scanning/7761)

@user2:
Removed on [e389710](https://github.com/streamlit/streamlit/pull/6865/commits/e389710796f154eacc209616f72d5dbb74e2f88d), so dismissing