diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5200430ceb..5e40a7bc8c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
+## Unreleased
+
+### Added
+
+- Added scrollbar-visibility rule https://github.com/Textualize/textual/pull/6156
+
## [6.2.1] - 2025-10-01
- Fix inability to copy text outside of an input/textarea when it was focused https://github.com/Textualize/textual/pull/6148
diff --git a/docs/examples/styles/scrollbar_visibility.py b/docs/examples/styles/scrollbar_visibility.py
new file mode 100644
index 0000000000..3f08a8bf4a
--- /dev/null
+++ b/docs/examples/styles/scrollbar_visibility.py
@@ -0,0 +1,27 @@
+from textual.app import App
+from textual.containers import Horizontal, VerticalScroll
+from textual.widgets import Label
+
+TEXT = """I must not fear.
+Fear is the mind-killer.
+Fear is the little-death that brings total obliteration.
+I will face my fear.
+I will permit it to pass over me and through me.
+And when it has gone past, I will turn the inner eye to see its path.
+Where the fear has gone there will be nothing. Only I will remain.
+"""
+
+
+class ScrollbarApp(App):
+ CSS_PATH = "scrollbar_visibility.tcss"
+
+ def compose(self):
+ yield Horizontal(
+ VerticalScroll(Label(TEXT * 10), classes="left"),
+ VerticalScroll(Label(TEXT * 10), classes="right"),
+ )
+
+
+if __name__ == "__main__":
+ app = ScrollbarApp()
+ app.run()
diff --git a/docs/examples/styles/scrollbar_visibility.tcss b/docs/examples/styles/scrollbar_visibility.tcss
new file mode 100644
index 0000000000..cab96d982e
--- /dev/null
+++ b/docs/examples/styles/scrollbar_visibility.tcss
@@ -0,0 +1,11 @@
+VerticalScroll {
+ width: 1fr;
+}
+
+.left {
+ scrollbar-visibility: visible; # The default
+}
+
+.right {
+ scrollbar-visibility: hidden;
+}
diff --git a/docs/styles/scrollbar_visibility.md b/docs/styles/scrollbar_visibility.md
new file mode 100644
index 0000000000..c493597a7c
--- /dev/null
+++ b/docs/styles/scrollbar_visibility.md
@@ -0,0 +1,60 @@
+# Scrollbar-visibility
+
+The `scrollbar-visibility` is used to show or hide scrollbars.
+
+If scrollbars are hidden, the user may still scroll the container using the mouse wheel / keys / and gestures, but
+there will be no scrollbars shown.
+
+## Syntax
+
+--8<-- "docs/snippets/syntax_block_start.md"
+scrollbar-visibility: hidden | visible;
+--8<-- "docs/snippets/syntax_block_end.md"
+
+
+### Values
+
+| Value | Description |
+| ------------------- | ---------------------------------------------------- |
+| `hidden` | The widget's scrollbars will be hidden. |
+| `visible` (default) | The widget's scrollbars will be displayed as normal. |
+
+
+## Examples
+
+The following example contains two containers with the same text.
+The container on the right has its scrollbar hidden.
+
+=== "Output"
+
+ ```{.textual path="docs/examples/styles/scrollbar_visibility.py"}
+ ```
+
+=== "scrollbar_visibility.py"
+
+ ```py
+ --8<-- "docs/examples/styles/scrollbar_visibility.py"
+ ```
+
+=== "scrollbar_visibility.tcss"
+
+ ```css
+ --8<-- "docs/examples/styles/scrollbar_visibility.tcss"
+ ```
+
+
+## CSS
+
+```css
+scrollbar-visibility: visible;
+scrollbar-visibility: hidden;
+```
+
+
+
+## Python
+
+```py
+widget.styles.scrollbar_visibility = "visible";
+widget.styles.scrollbar_visibility = "hidden";
+```
diff --git a/mkdocs-nav.yml b/mkdocs-nav.yml
index a587d54c82..e6b7b66725 100644
--- a/mkdocs-nav.yml
+++ b/mkdocs-nav.yml
@@ -136,6 +136,7 @@ nav:
- "styles/scrollbar_colors/scrollbar_corner_color.md"
- "styles/scrollbar_gutter.md"
- "styles/scrollbar_size.md"
+ - "styles/scrollbar_visibility.md"
- "styles/text_align.md"
- "styles/text_opacity.md"
- "styles/text_overflow.md"
diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py
index a473c2e3e4..f60d4c9b83 100644
--- a/src/textual/_compositor.py
+++ b/src/textual/_compositor.py
@@ -694,7 +694,7 @@ def add_widget(
if (
widget.show_vertical_scrollbar
or widget.show_horizontal_scrollbar
- ):
+ ) and styles.scrollbar_visibility == "visible":
for chrome_widget, chrome_region in widget._arrange_scrollbars(
container_region
):
diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py
index c2234ecaae..95c157e858 100644
--- a/src/textual/css/_styles_builder.py
+++ b/src/textual/css/_styles_builder.py
@@ -52,6 +52,7 @@
VALID_OVERLAY,
VALID_POSITION,
VALID_SCROLLBAR_GUTTER,
+ VALID_SCROLLBAR_VISIBILITY,
VALID_STYLE_FLAGS,
VALID_TEXT_ALIGN,
VALID_TEXT_OVERFLOW,
@@ -76,6 +77,7 @@
Display,
EdgeType,
Overflow,
+ ScrollbarVisibility,
TextOverflow,
TextWrap,
Visibility,
@@ -768,6 +770,13 @@ def process_color(self, name: str, tokens: list[Token]) -> None:
process_scrollbar_background_hover = process_color
process_scrollbar_background_active = process_color
+ def process_scrollbar_visibility(self, name: str, tokens: list[Token]) -> None:
+ """Process scrollbar visibility rules."""
+ self.styles._rules["scrollbar_visibility"] = cast(
+ ScrollbarVisibility,
+ self._process_enum(name, tokens, VALID_SCROLLBAR_VISIBILITY),
+ )
+
process_link_color = process_color
process_link_background = process_color
process_link_color_hover = process_color
diff --git a/src/textual/css/constants.py b/src/textual/css/constants.py
index 2145302306..46606d6709 100644
--- a/src/textual/css/constants.py
+++ b/src/textual/css/constants.py
@@ -90,6 +90,7 @@
VALID_TEXT_WRAP: Final = {"wrap", "nowrap"}
VALID_TEXT_OVERFLOW: Final = {"clip", "fold", "ellipsis"}
VALID_EXPAND: Final = {"greedy", "optimal"}
+VALID_SCROLLBAR_VISIBILITY: Final = {"visible", "hidden"}
HATCHES: Final = {
"left": "╲",
diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py
index 0c2bed837f..e165d3590c 100644
--- a/src/textual/css/styles.py
+++ b/src/textual/css/styles.py
@@ -48,6 +48,7 @@
VALID_OVERLAY,
VALID_POSITION,
VALID_SCROLLBAR_GUTTER,
+ VALID_SCROLLBAR_VISIBILITY,
VALID_TEXT_ALIGN,
VALID_TEXT_OVERFLOW,
VALID_TEXT_WRAP,
@@ -153,11 +154,10 @@ class RulesMap(TypedDict, total=False):
scrollbar_background: Color
scrollbar_background_hover: Color
scrollbar_background_active: Color
-
scrollbar_gutter: ScrollbarGutter
-
scrollbar_size_vertical: int
scrollbar_size_horizontal: int
+ scrollbar_visibility: ScrollbarVisibility
align_horizontal: AlignHorizontal
align_vertical: AlignVertical
@@ -242,6 +242,7 @@ class StylesBase:
"scrollbar_background",
"scrollbar_background_hover",
"scrollbar_background_active",
+ "scrollbar_visibility",
"link_color",
"link_background",
"link_color_hover",
@@ -424,6 +425,10 @@ class StylesBase:
"""Set the width of the vertical scrollbar (measured in cells)."""
scrollbar_size_horizontal = IntegerProperty(default=1, layout=True)
"""Set the height of the horizontal scrollbar (measured in cells)."""
+ scrollbar_visibility = StringEnumProperty(
+ VALID_SCROLLBAR_VISIBILITY, "visible", layout=True
+ )
+ """Sets the visibility of the scrollbar."""
align_horizontal = StringEnumProperty(
VALID_ALIGN_HORIZONTAL, "left", layout=True, refresh_children=True
@@ -1153,6 +1158,8 @@ def append_declaration(name: str, value: str) -> None:
append_declaration(
"scrollbar-size-vertical", str(self.scrollbar_size_vertical)
)
+ if "scrollbar_visibility" in rules:
+ append_declaration("scrollbar-visibility", self.scrollbar_visibility)
if "box_sizing" in rules:
append_declaration("box-sizing", self.box_sizing)
diff --git a/src/textual/css/types.py b/src/textual/css/types.py
index 4bf9e6671e..d2b2434808 100644
--- a/src/textual/css/types.py
+++ b/src/textual/css/types.py
@@ -44,6 +44,7 @@
TextWrap = Literal["wrap", "nowrap"]
TextOverflow = Literal["clip", "fold", "ellipsis"]
Expand = Literal["greedy", "expand"]
+ScrollbarVisibility = Literal["visible", "hidden"]
Specificity3 = Tuple[int, int, int]
Specificity6 = Tuple[int, int, int, int, int, int]
diff --git a/src/textual/dom.py b/src/textual/dom.py
index 69ee6c8ba4..c0d80ee3b7 100644
--- a/src/textual/dom.py
+++ b/src/textual/dom.py
@@ -1723,8 +1723,6 @@ def _update_styles(self) -> None:
Should be called whenever CSS classes / pseudo classes change.
"""
- if not self.is_attached or not self.screen.is_mounted:
- return
try:
self.app.update_styles(self)
except NoActiveAppError:
diff --git a/src/textual/widget.py b/src/textual/widget.py
index 79a3a94d2e..cf56e9536d 100644
--- a/src/textual/widget.py
+++ b/src/textual/widget.py
@@ -3430,7 +3430,11 @@ def scroll_to_widget(
return False
while isinstance(widget.parent, Widget) and widget is not self:
+ if not region:
+ break
+
container = widget.parent
+
if widget.styles.dock != "none":
scroll_offset = Offset(0, 0)
else:
@@ -3454,13 +3458,11 @@ def scroll_to_widget(
# Adjust the region by the amount we just scrolled it, and convert to
# its parent's virtual coordinate system.
-
region = (
(
region.translate(-scroll_offset)
.translate(container.styles.margin.top_left)
.translate(container.styles.border.spacing.top_left)
- .translate(-widget.scroll_offset)
.translate(container.virtual_region_with_margin.offset)
)
.grow(container.styles.margin)
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_css_property[scrollbar_visibility.py].svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_css_property[scrollbar_visibility.py].svg
new file mode 100644
index 0000000000..5cd29a1d07
--- /dev/null
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_css_property[scrollbar_visibility.py].svg
@@ -0,0 +1,152 @@
+
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_scrollbar_visibility.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_scrollbar_visibility.svg
new file mode 100644
index 0000000000..295e0aa7d1
--- /dev/null
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_scrollbar_visibility.svg
@@ -0,0 +1,150 @@
+
diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py
index ea8a7434f2..86cf17914a 100644
--- a/tests/snapshot_tests/test_snapshots.py
+++ b/tests/snapshot_tests/test_snapshots.py
@@ -4690,3 +4690,24 @@ def compose(self) -> ComposeResult:
yield Button("Hello")
assert snap_compare(CollapseApp(), press=["enter", "enter", "tab"])
+
+
+def test_scrollbar_visibility(snap_compare) -> None:
+ """Test scrollbar-visibility rule
+
+ You should see a screen of text that overflows, but there should be *no* scrollbar.
+ """
+
+ class ScrollbarApp(App):
+
+ CSS = """
+ Screen {
+ overflow: auto;
+ scrollbar-visibility: hidden;
+ }
+ """
+
+ def compose(self) -> ComposeResult:
+ yield Static("Hello, World! 293487 " * 200)
+
+ assert snap_compare(ScrollbarApp())