diff --git a/docs/examples/widgets/collapsible.py b/docs/examples/widgets/collapsible.py
new file mode 100644
index 0000000000..9dd1bee51e
--- /dev/null
+++ b/docs/examples/widgets/collapsible.py
@@ -0,0 +1,47 @@
+from textual.app import App, ComposeResult
+from textual.widgets import Collapsible, Footer, Label, Markdown
+
+LETO = """
+# Duke Leto I Atreides
+
+Head of House Atreides.
+"""
+
+JESSICA = """
+# Lady Jessica
+
+Bene Gesserit and concubine of Leto, and mother of Paul and Alia.
+"""
+
+PAUL = """
+# Paul Atreides
+
+Son of Leto and Jessica.
+"""
+
+
+class CollapsibleApp(App[None]):
+ """An example of colllapsible container."""
+
+ BINDINGS = [
+ ("c", "collapse_or_expand(True)", "Collapse All"),
+ ("e", "collapse_or_expand(False)", "Expand All"),
+ ]
+
+ def compose(self) -> ComposeResult:
+ """Compose app with collapsible containers."""
+ yield Footer()
+ with Collapsible(collapsed=False, title="Leto"):
+ yield Label(LETO)
+ yield Collapsible(Markdown(JESSICA), collapsed=False, title="Jessica")
+ with Collapsible(collapsed=True, title="Paul"):
+ yield Markdown(PAUL)
+
+ def action_collapse_or_expand(self, collapse: bool) -> None:
+ for child in self.walk_children(Collapsible):
+ child.collapsed = collapse
+
+
+if __name__ == "__main__":
+ app = CollapsibleApp()
+ app.run()
diff --git a/docs/examples/widgets/collapsible_custom_symbol.py b/docs/examples/widgets/collapsible_custom_symbol.py
new file mode 100644
index 0000000000..d2fa266aa6
--- /dev/null
+++ b/docs/examples/widgets/collapsible_custom_symbol.py
@@ -0,0 +1,25 @@
+from textual.app import App, ComposeResult
+from textual.containers import Horizontal
+from textual.widgets import Collapsible, Label
+
+
+class CollapsibleApp(App[None]):
+ def compose(self) -> ComposeResult:
+ with Horizontal():
+ with Collapsible(
+ collapsed_symbol=">>>",
+ expanded_symbol="v",
+ ):
+ yield Label("Hello, world.")
+
+ with Collapsible(
+ collapsed_symbol=">>>",
+ expanded_symbol="v",
+ collapsed=False,
+ ):
+ yield Label("Hello, world.")
+
+
+if __name__ == "__main__":
+ app = CollapsibleApp()
+ app.run()
diff --git a/docs/examples/widgets/collapsible_nested.py b/docs/examples/widgets/collapsible_nested.py
new file mode 100644
index 0000000000..d4b65835f7
--- /dev/null
+++ b/docs/examples/widgets/collapsible_nested.py
@@ -0,0 +1,14 @@
+from textual.app import App, ComposeResult
+from textual.widgets import Collapsible, Label
+
+
+class CollapsibleApp(App[None]):
+ def compose(self) -> ComposeResult:
+ with Collapsible(collapsed=False):
+ with Collapsible():
+ yield Label("Hello, world.")
+
+
+if __name__ == "__main__":
+ app = CollapsibleApp()
+ app.run()
diff --git a/docs/widgets/collapsible.md b/docs/widgets/collapsible.md
new file mode 100644
index 0000000000..d98ed7b398
--- /dev/null
+++ b/docs/widgets/collapsible.md
@@ -0,0 +1,153 @@
+# Collapsible
+
+!!! tip "Added in version 0.36"
+
+Widget that wraps its contents in a collapsible container.
+
+- [ ] Focusable
+- [x] Container
+
+
+## Composing
+
+There are two ways to wrap other widgets.
+You can pass them as positional arguments to the [Collapsible][textual.widgets.Collapsible] constructor:
+
+```python
+def compose(self) -> ComposeResult:
+ yield Collapsible(Label("Hello, world."))
+```
+
+Alternatively, you can compose other widgets under the context manager:
+
+```python
+def compose(self) -> ComposeResult:
+ with Collapsible():
+ yield Label("Hello, world.")
+```
+
+## Title
+
+The default title "Toggle" of the `Collapsible` widget can be customized by specifying the parameter `title` of the constructor:
+
+```python
+def compose(self) -> ComposeResult:
+ with Collapsible(title="An interesting story."):
+ yield Label("Interesting but verbose story.")
+```
+
+## Initial State
+
+The initial state of the `Collapsible` widget can be customized via the parameter `collapsed` of the constructor:
+
+```python
+def compose(self) -> ComposeResult:
+ with Collapsible(title="Contents 1", collapsed=False):
+ yield Label("Hello, world.")
+
+ with Collapsible(title="Contents 2", collapsed=True): # Default.
+ yield Label("Hello, world.")
+```
+
+## Collapse/Expand Symbols
+
+The symbols `►` and `▼` of the `Collapsible` widget can be customized by specifying the parameters `collapsed_symbol` and `expanded_symbol`, respectively, of the `Collapsible` constructor:
+
+```python
+def compose(self) -> ComposeResult:
+ with Collapsible(collapsed_symbol=">>>", expanded_symbol="v"):
+ yield Label("Hello, world.")
+```
+
+=== "Output"
+
+ ```{.textual path="tests/snapshot_tests/snapshot_apps/collapsible_custom_symbol.py"}
+ ```
+
+=== "collapsible_custom_symbol.py"
+
+ ```python
+ --8<-- "tests/snapshot_tests/snapshot_apps/collapsible_custom_symbol.py"
+ ```
+
+## Examples
+
+### Basic example
+
+The following example contains three `Collapsible`s in different states.
+
+=== "All expanded"
+
+ ```{.textual path="docs/examples/widgets/collapsible.py press="e"}
+ ```
+
+=== "All collapsed"
+
+ ```{.textual path="docs/examples/widgets/collapsible.py press="c"}
+ ```
+
+=== "Mixed"
+
+ ```{.textual path="docs/examples/widgets/collapsible.py"}
+ ```
+
+=== "collapsible.py"
+
+ ```python
+ --8<-- "docs/examples/widgets/collapsible.py"
+ ```
+
+### Setting Initial State
+
+The example below shows nested `Collapsible` widgets and how to set their initial state.
+
+
+=== "Output"
+
+ ```{.textual path="tests/snapshot_tests/snapshot_apps/collapsible_nested.py"}
+ ```
+
+=== "collapsible_nested.py"
+
+ ```python hl_lines="7"
+ --8<-- "tests/snapshot_tests/snapshot_apps/collapsible_nested.py"
+ ```
+
+### Custom Symbols
+
+The app below shows `Collapsible` widgets with custom expand/collapse symbols.
+
+
+=== "Output"
+
+ ```{.textual path="tests/snapshot_tests/snapshot_apps/collapsible_custom_symbol.py"}
+ ```
+
+=== "collapsible_custom_symbol.py"
+
+ ```python
+ --8<-- "tests/snapshot_tests/snapshot_apps/collapsible_custom_symbol.py"
+ ```
+
+## Reactive attributes
+
+| Name | Type | Default | Description |
+| ----------- | ------ | ------- | -------------------------------------------------------------- |
+| `collapsed` | `bool` | `True` | Controls the collapsed/expanded state of the widget. |
+
+## Messages
+
+- [Collapsible.Title.Toggle][textual.widgets.Collapsible.Title.Toggle]
+
+
+
+---
+
+
+::: textual.widgets.Collapsible
+ options:
+ heading_level: 2
diff --git a/src/textual/widgets/__init__.py b/src/textual/widgets/__init__.py
index 8c71dfa7fd..0e8b3d262d 100644
--- a/src/textual/widgets/__init__.py
+++ b/src/textual/widgets/__init__.py
@@ -12,6 +12,7 @@
from ..widget import Widget
from ._button import Button
from ._checkbox import Checkbox
+ from ._collapsible import Collapsible
from ._content_switcher import ContentSwitcher
from ._data_table import DataTable
from ._digits import Digits
@@ -43,10 +44,10 @@
from ._tree import Tree
from ._welcome import Welcome
-
__all__ = [
"Button",
"Checkbox",
+ "Collapsible",
"ContentSwitcher",
"DataTable",
"Digits",
diff --git a/src/textual/widgets/__init__.pyi b/src/textual/widgets/__init__.pyi
index 86f17d13cb..87c2f75f5e 100644
--- a/src/textual/widgets/__init__.pyi
+++ b/src/textual/widgets/__init__.pyi
@@ -1,6 +1,7 @@
# This stub file must re-export every classes exposed in the __init__.py's `__all__` list:
from ._button import Button as Button
from ._checkbox import Checkbox as Checkbox
+from ._collapsible import Collapsible as Collapsible
from ._content_switcher import ContentSwitcher as ContentSwitcher
from ._data_table import DataTable as DataTable
from ._digits import Digits as Digits
diff --git a/src/textual/widgets/_collapsible.py b/src/textual/widgets/_collapsible.py
new file mode 100644
index 0000000000..b29216bb4d
--- /dev/null
+++ b/src/textual/widgets/_collapsible.py
@@ -0,0 +1,156 @@
+from __future__ import annotations
+
+from textual.widget import Widget
+
+from .. import events
+from ..app import ComposeResult
+from ..containers import Container, Horizontal
+from ..message import Message
+from ..reactive import reactive
+from ..widget import Widget
+from ..widgets import Label
+
+__all__ = ["Collapsible"]
+
+
+class Collapsible(Widget):
+ """A collapsible container."""
+
+ collapsed = reactive(True)
+
+ DEFAULT_CSS = """
+ Collapsible {
+ width: 1fr;
+ height: auto;
+ }
+ """
+
+ class Title(Horizontal):
+ DEFAULT_CSS = """
+ Title {
+ width: 100%;
+ height: auto;
+ }
+
+ Title:hover {
+ background: grey;
+ }
+
+ Title .label {
+ padding: 0 0 0 1;
+ }
+
+ Title #collapsed-symbol {
+ display:none;
+ }
+
+ Title.-collapsed #expanded-symbol {
+ display:none;
+ }
+
+ Title.-collapsed #collapsed-symbol {
+ display:block;
+ }
+ """
+
+ def __init__(
+ self,
+ *,
+ label: str,
+ collapsed_symbol: str,
+ expanded_symbol: str,
+ name: str | None = None,
+ id: str | None = None,
+ classes: str | None = None,
+ disabled: bool = False,
+ ) -> None:
+ super().__init__(name=name, id=id, classes=classes, disabled=disabled)
+ self.collapsed_symbol = collapsed_symbol
+ self.expanded_symbol = expanded_symbol
+ self.label = label
+
+ class Toggle(Message):
+ """Request toggle."""
+
+ async def _on_click(self, event: events.Click) -> None:
+ """Inform ancestor we want to toggle."""
+ event.stop()
+ self.post_message(self.Toggle())
+
+ def compose(self) -> ComposeResult:
+ """Compose right/down arrow and label."""
+ yield Label(self.expanded_symbol, classes="label", id="expanded-symbol")
+ yield Label(self.collapsed_symbol, classes="label", id="collapsed-symbol")
+ yield Label(self.label, classes="label")
+
+ class Contents(Container):
+ DEFAULT_CSS = """
+ Contents {
+ width: 100%;
+ height: auto;
+ padding: 0 0 0 3;
+ }
+
+ Contents.-collapsed {
+ display: none;
+ }
+ """
+
+ def __init__(
+ self,
+ *children: Widget,
+ title: str = "Toggle",
+ collapsed: bool = True,
+ collapsed_symbol: str = "►",
+ expanded_symbol: str = "▼",
+ name: str | None = None,
+ id: str | None = None,
+ classes: str | None = None,
+ disabled: bool = False,
+ ) -> None:
+ """Initialize a Collapsible widget.
+
+ Args:
+ *children: Contents that will be collapsed/expanded.
+ title: Title of the collapsed/expanded contents.
+ collapsed: Default status of the contents.
+ collapsed_symbol: Collapsed symbol before the title.
+ expanded_symbol: Expanded symbol before the title.
+ name: The name of the collapsible.
+ id: The ID of the collapsible in the DOM.
+ classes: The CSS classes of the collapsible.
+ disabled: Whether the collapsible is disabled or not.
+ """
+ self._title = self.Title(
+ label=title,
+ collapsed_symbol=collapsed_symbol,
+ expanded_symbol=expanded_symbol,
+ )
+ self._contents_list: list[Widget] = list(children)
+ super().__init__(name=name, id=id, classes=classes, disabled=disabled)
+ self.collapsed = collapsed
+
+ def _on_title_toggle(self, event: Title.Toggle) -> None:
+ event.stop()
+ self.collapsed = not self.collapsed
+
+ def watch_collapsed(self) -> None:
+ for child in self._nodes:
+ child.set_class(self.collapsed, "-collapsed")
+
+ def compose(self) -> ComposeResult:
+ yield from (
+ child.set_class(self.collapsed, "-collapsed")
+ for child in (
+ self._title,
+ self.Contents(*self._contents_list),
+ )
+ )
+
+ def compose_add_child(self, widget: Widget) -> None:
+ """When using the context manager compose syntax, we want to attach nodes to the contents.
+
+ Args:
+ widget: A Widget to add.
+ """
+ self._contents_list.append(widget)
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
index bd2ffa863b..0cf24556d6 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
@@ -1698,6 +1698,792 @@
'''
# ---
+# name: test_collapsible_collapsed
+ '''
+
+
+ '''
+# ---
+# name: test_collapsible_custom_symbol
+ '''
+
+
+ '''
+# ---
+# name: test_collapsible_expanded
+ '''
+
+
+ '''
+# ---
+# name: test_collapsible_nested
+ '''
+
+
+ '''
+# ---
+# name: test_collapsible_render
+ '''
+
+
+ '''
+# ---
# name: test_columns_height
'''