Repository: Textualize/textual
Pull request: Add support for env variable TEXTUAL_ANIMATIONS
Adds support for the environment variable `TEXTUAL_ANIMATIONS`, which dictates which animations take place in Textual apps.
(Kind of like a log level but for animations.)

Fixes #3992.

Problem statement:
Env var to modify animation
Add an environment variable to set animation behaviour.

`TEXTUAL_ANIMATION` should be one of the following values:

`NONE` for no animation at all.
`BASIC` for scrolling animation and other animation that doesn't delay content appearing (such as the tab underline).
`FULL` for all animations.

Any other values should be equivalent to `FULL`

This should also be replicated in `App` with a property, so it could be set programatically.

Hints from issue discussion:
This looks like it'll take a couple of days because I need to figure out a way for the animations code to determine whether or not an animation is “basic”.
(If it were just `NONE` vs `FULL`, it'd be simpler.)
Looks like we don't want `TEXTUAL_ANIMATIONS` to interfere with the parameter `delay` that is set on some animations.
A delayed animation with `TEXTUAL_ANIMATIONS` set to `NONE` will be delayed for the correct amount of time and then it happens instantaneously.

Commit message: Fix tests for 0.48.0

Patch to review:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7c1d499d41..943e1a7db2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -35,6 +35,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
 - Added `TabbedContent.active_pane` https://github.com/Textualize/textual/pull/4012
 - Added `App.suspend` https://github.com/Textualize/textual/pull/4064
 - Added `App.action_suspend_process` https://github.com/Textualize/textual/pull/4064
+- Added support for environment variable `TEXTUAL_ANIMATIONS` to control what animations Textual displays https://github.com/Textualize/textual/pull/4062
+- Add attribute `App.animation_level` to control whether animations on that app run or not https://github.com/Textualize/textual/pull/4062
 
 ### Fixed
 
diff --git a/docs/api/constants.md b/docs/api/constants.md
new file mode 100644
index 0000000000..f4d97e8dfd
--- /dev/null
+++ b/docs/api/constants.md
@@ -0,0 +1 @@
+::: textuals.constants
diff --git a/mkdocs-nav.yml b/mkdocs-nav.yml
index a5ab15880a..ccb858f352 100644
--- a/mkdocs-nav.yml
+++ b/mkdocs-nav.yml
@@ -178,6 +178,7 @@ nav:
       - "api/cache.md"
       - "api/color.md"
       - "api/command.md"
+      - "api/constants.md"
       - "api/containers.md"
       - "api/content_switcher.md"
       - "api/coordinate.md"
diff --git a/src/textual/_animator.py b/src/textual/_animator.py
index 5ea15154ed..8f863e703c 100644
--- a/src/textual/_animator.py
+++ b/src/textual/_animator.py
@@ -12,6 +12,7 @@
 from ._callback import invoke
 from ._easing import DEFAULT_EASING, EASING
 from ._types import CallbackType
+from .constants import AnimationLevel
 from .timer import Timer
 
 if TYPE_CHECKING:
@@ -53,7 +54,11 @@ class Animation(ABC):
     """Callback to run after animation completes"""
 
     @abstractmethod
-    def __call__(self, time: float) -> bool:  # pragma: no cover
+    def __call__(
+        self,
+        time: float,
+        app_animation_level: AnimationLevel = "full",
+    ) -> bool:  # pragma: no cover
         """Call the animation, return a boolean indicating whether animation is in-progress or complete.
 
         Args:
@@ -93,9 +98,18 @@ class SimpleAnimation(Animation):
     final_value: object
     easing: EasingFunction
     on_complete: CallbackType | None = None
+    level: AnimationLevel = "full"
+    """Minimum level required for the animation to take place (inclusive)."""
 
-    def __call__(self, time: float) -> bool:
-        if self.duration == 0:
+    def __call__(
+        self, time: float, app_animation_level: AnimationLevel = "full"
+    ) -> bool:
+        if (
+            self.duration == 0
+            or app_animation_level == "none"
+            or app_animation_level == "basic"
+            and self.level == "full"
+        ):
             setattr(self.obj, self.attribute, self.final_value)
             return True
 
@@ -170,6 +184,7 @@ def __call__(
         delay: float = 0.0,
         easing: EasingFunction | str = DEFAULT_EASING,
         on_complete: CallbackType | None = None,
+        level: AnimationLevel = "full",
     ) -> None:
         """Animate an attribute.
 
@@ -182,6 +197,7 @@ def __call__(
             delay: A delay (in seconds) before the animation starts.
             easing: An easing method.
             on_complete: A callable to invoke when the animation is finished.
+            level: Minimum level required for the animation to take place (inclusive).
         """
         start_value = getattr(self._obj, attribute)
         if isinstance(value, str) and hasattr(start_value, "parse"):
@@ -200,6 +216,7 @@ def __call__(
             delay=delay,
             easing=easing_function,
             on_complete=on_complete,
+            level=level,
         )
 
 
@@ -284,6 +301,7 @@ def animate(
         easing: EasingFunction | str = DEFAULT_EASING,
         delay: float = 0.0,
         on_complete: CallbackType | None = None,
+        level: AnimationLevel = "full",
     ) -> None:
         """Animate an attribute to a new value.
 
@@ -297,6 +315,7 @@ def animate(
             easing: An easing function.
             delay: Number of seconds to delay the start of the animation by.
             on_complete: Callback to run after the animation completes.
+            level: Minimum level required for the animation to take place (inclusive).
         """
         animate_callback = partial(
             self._animate,
@@ -308,6 +327,7 @@ def animate(
             speed=speed,
             easing=easing,
             on_complete=on_complete,
+            level=level,
         )
         if delay:
             self._complete_event.clear()
@@ -328,7 +348,8 @@ def _animate(
         speed: float | None = None,
         easing: EasingFunction | str = DEFAULT_EASING,
         on_complete: CallbackType | None = None,
-    ):
+        level: AnimationLevel = "full",
+    ) -> None:
         """Animate an attribute to a new value.
 
         Args:
@@ -340,6 +361,7 @@ def _animate(
             speed: The speed of the animation.
             easing: An easing function.
             on_complete: Callback to run after the animation completes.
+            level: Minimum level required for the animation to take place (inclusive).
         """
         if not hasattr(obj, attribute):
             raise AttributeError(
@@ -373,6 +395,7 @@ def _animate(
                 speed=speed,
                 easing=easing_function,
                 on_complete=on_complete,
+                level=level,
             )
 
         if animation is None:
@@ -414,6 +437,7 @@ def _animate(
                     if on_complete is not None
                     else None
                 ),
+                level=level,
             )
         assert animation is not None, "animation expected to be non-None"
 
@@ -521,11 +545,12 @@ def __call__(self) -> None:
             if not self._scheduled:
                 self._complete_event.set()
         else:
+            app_animation_level = self.app.animation_level
             animation_time = self._get_time()
             animation_keys = list(self._animations.keys())
             for animation_key in animation_keys:
                 animation = self._animations[animation_key]
-                animation_complete = animation(animation_time)
+                animation_complete = animation(animation_time, app_animation_level)
                 if animation_complete:
                     del self._animations[animation_key]
                     if animation.on_complete is not None:
diff --git a/src/textual/_context.py b/src/textual/_context.py
index 33d8369d49..b1b20b4d29 100644
--- a/src/textual/_context.py
+++ b/src/textual/_context.py
@@ -14,12 +14,14 @@ class NoActiveAppError(RuntimeError):
     """Runtime error raised if we try to retrieve the active app when there is none."""
 
 
-active_app: ContextVar["App"] = ContextVar("active_app")
+active_app: ContextVar["App[object]"] = ContextVar("active_app")
 active_message_pump: ContextVar["MessagePump"] = ContextVar("active_message_pump")
 prevent_message_types_stack: ContextVar[list[set[type[Message]]]] = ContextVar(
     "prevent_message_types_stack"
 )
-visible_screen_stack: ContextVar[list[Screen]] = ContextVar("visible_screen_stack")
+visible_screen_stack: ContextVar[list[Screen[object]]] = ContextVar(
+    "visible_screen_stack"
+)
 """A stack of visible screens (with background alpha < 1), used in the screen render process."""
 message_hook: ContextVar[Callable[[Message], None]] = ContextVar("message_hook")
 """A callable that accepts a message. Used by App.run_test."""
diff --git a/src/textual/app.py b/src/textual/app.py
index 5c54cb8983..fe61292d40 100644
--- a/src/textual/app.py
+++ b/src/textual/app.py
@@ -84,6 +84,7 @@
 from .await_remove import AwaitRemove
 from .binding import Binding, BindingType, _Bindings
 from .command import CommandPalette, Provider
+from .constants import AnimationLevel
 from .css.errors import StylesheetError
 from .css.query import NoMatches
 from .css.stylesheet import RulesMap, Stylesheet
@@ -616,6 +617,12 @@ def __init__(
         self.set_class(self.dark, "-dark-mode")
         self.set_class(not self.dark, "-light-mode")
 
+        self.animation_level: AnimationLevel = constants.TEXTUAL_ANIMATIONS
+        """Determines what type of animations the app will display.
+
+        See [`textual.constants.TEXTUAL_ANIMATIONS`][textual.constants.TEXTUAL_ANIMATIONS].
+        """
+
     def validate_title(self, title: Any) -> str:
         """Make sure the title is set to a string."""
         return str(title)
@@ -711,6 +718,7 @@ def animate(
         delay: float = 0.0,
         easing: EasingFunction | str = DEFAULT_EASING,
         on_complete: CallbackType | None = None,
+        level: AnimationLevel = "full",
     ) -> None:
         """Animate an attribute.
 
@@ -725,6 +733,7 @@ def animate(
             delay: A delay (in seconds) before the animation starts.
             easing: An easing method.
             on_complete: A callable to invoke when the animation is finished.
+            level: Minimum level required for the animation to take place (inclusive).
         """
         self._animate(
             attribute,
@@ -735,6 +744,7 @@ def animate(
             delay=delay,
             easing=easing,
             on_complete=on_complete,
+            level=level,
         )
 
     async def stop_animation(self, attribute: str, complete: bool = True) -> None:
diff --git a/src/textual/constants.py b/src/textual/constants.py
index d47d0d2c15..43adadf32d 100644
--- a/src/textual/constants.py
+++ b/src/textual/constants.py
@@ -5,13 +5,17 @@
 from __future__ import annotations
 
 import os
+from typing import Literal, get_args
 
-from typing_extensions import Final
+from typing_extensions import Final, TypeGuard
 
 get_environ = os.environ.get
 
 
-def get_environ_bool(name: str) -> bool:
+AnimationLevel = Literal["none", "basic", "full"]
+
+
+def _get_environ_bool(name: str) -> bool:
     """Check an environment variable switch.
 
     Args:
@@ -24,7 +28,7 @@ def get_environ_bool(name: str) -> bool:
     return has_environ
 
 
-def get_environ_int(name: str, default: int) -> int:
+def _get_environ_int(name: str, default: int) -> int:
     """Retrieves an integer environment variable.
 
     Args:
@@ -44,7 +48,34 @@ def get_environ_int(name: str, default: int) -> int:
         return default
 
 
-DEBUG: Final[bool] = get_environ_bool("TEXTUAL_DEBUG")
+def _is_valid_animation_level(value: str) -> TypeGuard[AnimationLevel]:
+    """Checks if a string is a valid animation level.
+
+    Args:
+        value: The string to check.
+
+    Returns:
+        Whether it's a valid level or not.
+    """
+    return value in get_args(AnimationLevel)
+
+
+def _get_textual_animations() -> AnimationLevel:
+    """Get the value of the environment variable that controls textual animations.
+
+    The variable can be in any of the values defined by [`AnimationLevel`][textual.constants.AnimationLevel].
+
+    Returns:
+        The value that the variable was set to. If the environment variable is set to an
+            invalid value, we default to showing all animations.
+    """
+    value: str = get_environ("TEXTUAL_ANIMATIONS", "FULL").lower()
+    if _is_valid_animation_level(value):
+        return value
+    return "full"
+
+
+DEBUG: Final[bool] = _get_environ_bool("TEXTUAL_DEBUG")
 """Enable debug mode."""
 
 DRIVER: Final[str | None] = get_environ("TEXTUAL_DRIVER", None)
@@ -59,20 +90,23 @@ def get_environ_int(name: str, default: int) -> int:
 DEVTOOLS_HOST: Final[str] = get_environ("TEXTUAL_DEVTOOLS_HOST", "127.0.0.1")
 """The host where textual console is running."""
 
-DEVTOOLS_PORT: Final[int] = get_environ_int("TEXTUAL_DEVTOOLS_PORT", 8081)
+DEVTOOLS_PORT: Final[int] = _get_environ_int("TEXTUAL_DEVTOOLS_PORT", 8081)
 """Constant with the port that the devtools will connect to."""
 
-SCREENSHOT_DELAY: Final[int] = get_environ_int("TEXTUAL_SCREENSHOT", -1)
+SCREENSHOT_DELAY: Final[int] = _get_environ_int("TEXTUAL_SCREENSHOT", -1)
 """Seconds delay before taking screenshot."""
 
 PRESS: Final[str] = get_environ("TEXTUAL_PRESS", "")
 """Keys to automatically press."""
 
-SHOW_RETURN: Final[bool] = get_environ_bool("TEXTUAL_SHOW_RETURN")
+SHOW_RETURN: Final[bool] = _get_environ_bool("TEXTUAL_SHOW_RETURN")
 """Write the return value on exit."""
 
-MAX_FPS: Final[int] = get_environ_int("TEXTUAL_FPS", 60)
+MAX_FPS: Final[int] = _get_environ_int("TEXTUAL_FPS", 60)
 """Maximum frames per second for updates."""
 
 COLOR_SYSTEM: Final[str | None] = get_environ("TEXTUAL_COLOR_SYSTEM", "auto")
 """Force color system override"""
+
+TEXTUAL_ANIMATIONS: AnimationLevel = _get_textual_animations()
+"""Determines whether animations run or not."""
diff --git a/src/textual/css/scalar_animation.py b/src/textual/css/scalar_animation.py
index 018d28b191..5ceef5c4d4 100644
--- a/src/textual/css/scalar_animation.py
+++ b/src/textual/css/scalar_animation.py
@@ -4,6 +4,7 @@
 
 from .._animator import Animation, EasingFunction
 from .._types import CallbackType
+from ..constants import AnimationLevel
 from .scalar import Scalar, ScalarOffset
 
 if TYPE_CHECKING:
@@ -23,6 +24,7 @@ def __init__(
         speed: float | None,
         easing: EasingFunction,
         on_complete: CallbackType | None = None,
+        level: AnimationLevel = "full",
     ):
         assert (
             speed is not None or duration is not None
@@ -34,6 +36,7 @@ def __init__(
         self.final_value = value
         self.easing = easing
         self.on_complete = on_complete
+        self.level = level
 
         size = widget.outer_size
         viewport = widget.app.size
@@ -48,11 +51,18 @@ def __init__(
             assert duration is not None, "Duration expected to be non-None"
             self.duration = duration
 
-    def __call__(self, time: float) -> bool:
+    def __call__(
+        self, time: float, app_animation_level: AnimationLevel = "full"
+    ) -> bool:
         factor = min(1.0, (time - self.start_time) / self.duration)
         eased_factor = self.easing(factor)
 
-        if eased_factor >= 1:
+        if (
+            eased_factor >= 1
+            or app_animation_level == "none"
+            or app_animation_level == "basic"
+            and self.level == "full"
+        ):
             setattr(self.styles, self.attribute, self.final_value)
             return True
 
diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py
index 476350648b..e3554aeecb 100644
--- a/src/textual/css/styles.py
+++ b/src/textual/css/styles.py
@@ -13,6 +13,7 @@
 from .._animator import DEFAULT_EASING, Animatable, BoundAnimator, EasingFunction
 from .._types import CallbackType
 from ..color import Color
+from ..constants import AnimationLevel
 from ..geometry import Offset, Spacing
 from ._style_properties import (
     AlignProperty,
@@ -369,6 +370,7 @@ def __textual_animation__(
         speed: float | None,
         easing: EasingFunction,
         on_complete: CallbackType | None = None,
+        level: AnimationLevel = "full",
     ) -> ScalarAnimation | None:
         if self.node is None:
             return None
@@ -400,6 +402,7 @@ def __textual_animation__(
                     if on_complete is not None
                     else None
                 ),
+                level=level,
             )
         return None
 
@@ -1142,6 +1145,7 @@ def animate(
         delay: float = 0.0,
         easing: EasingFunction | str = DEFAULT_EASING,
         on_complete: CallbackType | None = None,
+        level: AnimationLevel = "full",
     ) -> None:
         """Animate an attribute.
 
@@ -1154,6 +1158,7 @@ def animate(
             delay: A delay (in seconds) before the animation starts.
             easing: An easing method.
             on_complete: A callable to invoke when the animation is finished.
+            level: Minimum level required for the animation to take place (inclusive).
         """
         if self._animate is None:
             assert self.node is not None
@@ -1168,6 +1173,7 @@ def animate(
             delay=delay,
             easing=easing,
             on_complete=on_complete,
+            level=level,
         )
 
     def __rich_repr__(self) -> rich.repr.Result:
diff --git a/src/textual/scroll_view.py b/src/textual/scroll_view.py
index a4e3aa03d8..ea9e01452a 100644
--- a/src/textual/scroll_view.py
+++ b/src/textual/scroll_view.py
@@ -8,6 +8,7 @@
 
 from ._animator import EasingFunction
 from ._types import CallbackType
+from .constants import AnimationLevel
 from .containers import ScrollableContainer
 from .geometry import Region, Size
 
@@ -119,6 +120,7 @@ def scroll_to(
         easing: EasingFunction | str | None = None,
         force: bool = False,
         on_complete: CallbackType | None = None,
+        level: AnimationLevel = "basic",
     ) -> None:
         """Scroll to a given (absolute) coordinate, optionally animating.
 
@@ -131,6 +133,7 @@ def scroll_to(
             easing: An easing method for the scrolling animation.
             force: Force scrolling even when prohibited by overflow styling.
             on_complete: A callable to invoke when the animation is finished.
+            level: Minimum level required for the animation to take place (inclusive).
         """
 
         self._scroll_to(
@@ -142,6 +145,7 @@ def scroll_to(
             easing=easing,
             force=force,
             on_complete=on_complete,
+            level=level,
         )
 
     def refresh_lines(self, y_start: int, line_count: int = 1) -> None:
diff --git a/src/textual/widget.py b/src/textual/widget.py
index 568e11ad1a..85c4f36ddc 100644
--- a/src/textual/widget.py
+++ b/src/textual/widget.py
@@ -52,6 +52,7 @@
 from .await_remove import AwaitRemove
 from .box_model import BoxModel
 from .cache import FIFOCache
+from .constants import AnimationLevel
 from .css.query import NoMatches, WrongType
 from .css.scalar import ScalarOffset
 from .dom import DOMNode, NoScreen
@@ -1720,6 +1721,7 @@ def animate(
         delay: float = 0.0,
         easing: EasingFunction | str = DEFAULT_EASING,
         on_complete: CallbackType | None = None,
+        level: AnimationLevel = "full",
     ) -> None:
         """Animate an attribute.
 
@@ -1732,6 +1734,7 @@ def animate(
             delay: A delay (in seconds) before the animation starts.
             easing: An easing method.
             on_complete: A callable to invoke when the animation is finished.
+            level: Minimum level required for the animation to take place (inclusive).
         """
         if self._animate is None:
             self._animate = self.app.animator.bind(self)
@@ -1745,6 +1748,7 @@ def animate(
             delay=delay,
             easing=easing,
             on_complete=on_complete,
+            level=level,
         )
 
     async def stop_animation(self, attribute: str, complete: bool = True) -> None:
@@ -1891,6 +1895,7 @@ def _scroll_to(
         easing: EasingFunction | str | None = None,
         force: bool = False,
         on_complete: CallbackType | None = None,
+        level: AnimationLevel = "basic",
     ) -> bool:
         """Scroll to a given (absolute) coordinate, optionally animating.
 
@@ -1903,6 +1908,7 @@ def _scroll_to(
             easing: An easing method for the scrolling animation.
             force: Force scrolling even when prohibited by overflow styling.
             on_complete: A callable to invoke when the animation is finished.
+            level: Minimum level required for the animation to take place (inclusive).
 
         Returns:
             `True` if the scroll position changed, otherwise `False`.
@@ -1935,6 +1941,7 @@ def _scroll_to(
                         duration=duration,
                         easing=easing,
                         on_complete=on_complete,
+                        level=level,
                     )
                     scrolled_x = True
             if maybe_scroll_y:
@@ -1948,6 +1955,7 @@ def _scroll_to(
                         duration=duration,
                         easing=easing,
                         on_complete=on_complete,
+                        level=level,
                     )
                     scrolled_y = True
 
@@ -1979,6 +1987,7 @@ def scroll_to(
         easing: EasingFunction | str | None = None,
         force: bool = False,
         on_complete: CallbackType | None = None,
+        level: AnimationLevel = "basic",
     ) -> None:
         """Scroll to a given (absolute) coordinate, optionally animating.
 
@@ -1991,6 +2000,7 @@ def scroll_to(
             easing: An easing method for the scrolling animation.
             force: Force scrolling even when prohibited by overflow styling.
             on_complete: A callable to invoke when the animation is finished.
+            level: Minimum level required for the animation to take place (inclusive).
 
         Note:
             The call to scroll is made after the next refresh.
@@ -2005,6 +2015,7 @@ def scroll_to(
             easing=easing,
             force=force,
             on_complete=on_complete,
+            level=level,
         )
 
     def scroll_relative(
@@ -2018,6 +2029,7 @@ def scroll_relative(
         easing: EasingFunction | str | None = None,
         force: bool = False,
         on_complete: CallbackType | None = None,
+        level: AnimationLevel = "basic",
     ) -> None:
         """Scroll relative to current position.
 
@@ -2030,6 +2042,7 @@ def scroll_relative(
             easing: An easing method for the scrolling animation.
             force: Force scrolling even when prohibited by overflow styling.
             on_complete: A callable to invoke when the animation is finished.
+            level: Minimum level required for the animation to take place (inclusive).
         """
         self.scroll_to(
             None if x is None else (self.scroll_x + x),
@@ -2040,6 +2053,7 @@ def scroll_relative(
             easing=easing,
             force=force,
             on_complete=on_complete,
+            level=level,
         )
 
     def scroll_home(
@@ -2051,6 +2065,7 @@ def scroll_home(
         easing: EasingFunction | str | None = None,
         force: bool = False,
         on_complete: CallbackType | None = None,
+        level: AnimationLevel = "basic",
     ) -> None:
         """Scroll to home position.
 
@@ -2061,6 +2076,7 @@ def scroll_home(
             easing: An easing method for the scrolling animation.
             force: Force scrolling even when prohibited by overflow styling.
             on_complete: A callable to invoke when the animation is finished.
+            level: Minimum level required for the animation to take place (inclusive).
         """
         if speed is None and duration is None:
             duration = 1.0
@@ -2073,6 +2089,7 @@ def scroll_home(
             easing=easing,
             force=force,
             on_complete=on_complete,
+            level=level,
         )
 
     def scroll_end(
@@ -2084,6 +2101,7 @@ def scroll_end(
         easing: EasingFunction | str | None = None,
         force: bool = False,
         on_complete: CallbackType | None = None,
+        level: AnimationLevel = "basic",
     ) -> None:
         """Scroll to the end of the container.
 
@@ -2094,6 +2112,7 @@ def scroll_end(
             easing: An easing method for the scrolling animation.
             force: Force scrolling even when prohibited by overflow styling.
             on_complete: A callable to invoke when the animation is finished.
+            level: Minimum level required for the animation to take place (inclusive).
         """
         if speed is None and duration is None:
             duration = 1.0
@@ -2115,6 +2134,7 @@ def _lazily_scroll_end() -> None:
                 easing=easing,
                 force=force,
                 on_complete=on_complete,
+                level=level,
             )
 
         self.call_after_refresh(_lazily_scroll_end)
@@ -2128,6 +2148,7 @@ def scroll_left(
         easing: EasingFunction | str | None = None,
         force: bool = False,
         on_complete: CallbackType | None = None,
+        level: AnimationLevel = "basic",
     ) -> None:
         """Scroll one cell left.
 
@@ -2138,6 +2159,7 @@ def scroll_left(
             easing: An easing method for the scrolling animation.
             force: Force scrolling even when prohibited by overflow styling.
             on_complete: A callable to invoke when the animation is finished.
+            level: Minimum level required for the animation to take place (inclusive).
         """
         self.scroll_to(
             x=self.scroll_target_x - 1,
@@ -2147,6 +2169,7 @@ def scroll_left(
             easing=easing,
             force=force,
             on_complete=on_complete,
+            level=level,
         )
 
     def _scroll_left_for_pointer(
@@ -2158,6 +2181,7 @@ def _scroll_left_for_pointer(
         easing: EasingFunction | str | None = None,
         force: bool = False,
         on_complete: CallbackType | None = None,
+        level: AnimationLevel = "basic",
     ) -> bool:
         """Scroll left one position, taking scroll sensitivity into account.
 
@@ -2168,6 +2192,7 @@ def _scroll_left_for_pointer(
             easing: An easing method for the scrolling animation.
             force: Force scrolling even when prohibited by overflow styling.
             on_complete: A callable to invoke when the animation is finished.
+            level: Minimum level required for the animation to take place (inclusive).
 
         Returns:
             `True` if any scrolling was done.
@@ -2184,6 +2209,7 @@ def _scroll_left_for_pointer(
             easing=easing,
             force=force,
             on_complete=on_complete,
+            level=level,
         )
 
     def scroll_right(
@@ -2195,6 +2221,7 @@ def scroll_right(
         easing: EasingFunction | str | None = None,
         force: bool = False,
         on_complete: CallbackType | None = None,
+        level: AnimationLevel = "basic",
     ) -> None:
         """Scroll one cell right.
 
@@ -2205,6 +2232,7 @@ def scroll_right(
             easing: An easing method for the scrolling animation.
             force: Force scrolling even when prohibited by overflow styling.
             on_complete: A callable to invoke when the animation is finished.
+            level: Minimum level required for the animation to take place (inclusive).
         """
         self.scroll_to(
             x=self.scroll_target_x + 1,
@@ -2214,6 +2242,7 @@ def scroll_right(
             easing=easing,
             force=force,
             on_complete=on_complete,
+            level=level,
         )
 
     def _scroll_right_for_pointer(
@@ -2225,6 +2254,7 @@ def _scroll_right_for_pointer(
         easing: EasingFunction | str | None = None,
         force: bool = False,
         on_complete: CallbackType | None = None,
+        level: AnimationLevel = "basic",
     ) -> bool:
         """Scroll right one position, taking scroll sensitivity into account.
 
@@ -2235,6 +2265,7 @@ def _scroll_right_for_pointer(
             easing: An easing method for the scrolling animation.
             force: Force scrolling even when prohibited by overflow styling.
             on_complete: A callable to invoke when the animation is finished.
+            level: Minimum level required for the animation to take place (inclusive).
 
         Returns:
             `True` if any scrolling was done.
@@ -2251,6 +2282,7 @@ def _scroll_right_for_pointer(
             easing=easing,
             force=force,
             on_complete=on_complete,
+            level=level,
         )
 
     def scroll_down(
@@ -2262,6 +2294,7 @@ def scroll_down(
         easing: EasingFunction | str | None = None,
         force: bool = False,
         on_complete: CallbackType | None = None,
+        level: AnimationLevel = "basic",
     ) -> None:
         """Scroll one line down.
 
@@ -2272,6 +2305,7 @@ def scroll_down(
             easing: An easing method for the scrolling animation.
             force: Force scrolling even when prohibited by overflow styling.
             on_complete: A callable to invoke when the animation is finished.
+            level: Minimum level required for the animation to take place (inclusive).
         """
         self.scroll_to(
             y=self.scroll_target_y + 1,
@@ -2281,6 +2315,7 @@ def scroll_down(
             easing=easing,
             force=force,
             on_complete=on_complete,
+            level=level,
         )
 
     def _scroll_down_for_pointer(
@@ -2292,6 +2327,7 @@ def _scroll_down_for_pointer(
         easing: EasingFunction | str | None = None,
         force: bool = False,
         on_complete: CallbackType | None = None,
+        level: AnimationLevel = "basic",
     ) -> bool:
         """Scroll down one position, taking scroll sensitivity into account.
 
@@ -2302,6 +2338,7 @@ def _scroll_down_for_pointer(
             easing: An easing method for the scrolling animation.
             force: Force scrolling even when prohibited by overflow styling.
             on_complete: A callable to invoke when the animation is finished.
+            level: Minimum level required for the animation to take place (inclusive).
 
         Returns:
             `True` if any scrolling was done.
@@ -2318,6 +2355,7 @@ def _scroll_down_for_pointer(
             easing=easing,
             force=force,
             on_complete=on_complete,
+            level=level,
         )
 
     def scroll_up(
@@ -2329,6 +2367,7 @@ def scroll_up(
         easing: EasingFunction | str | None = None,
         force: bool = False,
         on_complete: CallbackType | None = None,
+        level: AnimationLevel = "basic",
     ) -> None:
         """Scroll one line up.
 
@@ -2339,6 +2378,7 @@ def scroll_up(
             easing: An easing method for the scrolling animation.
             force: Force scrolling even when prohibited by overflow styling.
             on_complete: A callable to invoke when the animation is finished.
+            level: Minimum level required for the animation to take place (inclusive).
         """
         self.scroll_to(
             y=self.scroll_target_y - 1,
@@ -2348,6 +2388,7 @@ def scroll_up(
             easing=easing,
             force=force,
             on_complete=on_complete,
+            level=level,
         )
 
     def _scroll_up_for_pointer(
@@ -2359,6 +2400,7 @@ def _scroll_up_for_pointer(
         easing: EasingFunction | str | None = None,
         force: bool = False,
         on_complete: CallbackType | None = None,
+        level: AnimationLevel = "basic",
     ) -> bool:
         """Scroll up one position, taking scroll sensitivity into account.
 
@@ -2369,6 +2411,7 @@ def _scroll_up_for_pointer(
             easing: An easing method for the scrolling animation.
             force: Force scrolling even when prohibited by overflow styling.
             on_complete: A callable to invoke when the animation is finished.
+            level: Minimum level required for the animation to take place (inclusive).
 
         Returns:
             `True` if any scrolling was done.
@@ -2385,6 +2428,7 @@ def _scroll_up_for_pointer(
             easing=easing,
             force=force,
             on_complete=on_complete,
+            level=level,
         )
 
     def scroll_page_up(
@@ -2396,6 +2440,7 @@ def scroll_page_up(
         easing: EasingFunction | str | None = None,
         force: bool = False,
         on_complete: CallbackType | None = None,
+        level: AnimationLevel = "basic",
     ) -> None:
         """Scroll one page up.
 
@@ -2406,6 +2451,7 @@ def scroll_page_up(
             easing: An easing method for the scrolling animation.
             force: Force scrolling even when prohibited by overflow styling.
             on_complete: A callable to invoke when the animation is finished.
+            level: Minimum level required for the animation to take place (inclusive).
         """
         self.scroll_to(
             y=self.scroll_y - self.container_size.height,
@@ -2415,6 +2461,7 @@ def scroll_page_up(
             easing=easing,
             force=force,
             on_complete=on_complete,
+            level=level,
         )
 
     def scroll_page_down(
@@ -2426,6 +2473,7 @@ def scroll_page_down(
         easing: EasingFunction | str | None = None,
         force: bool = False,
         on_complete: CallbackType | None = None,
+        level: AnimationLevel = "basic",
     ) -> None:
         """Scroll one page down.
 
@@ -2436,6 +2484,7 @@ def scroll_page_down(
             easing: An easing method for the scrolling animation.
             force: Force scrolling even when prohibited by overflow styling.
             on_complete: A callable to invoke when the animation is finished.
+            level: Minimum level required for the animation to take place (inclusive).
         """
         self.scroll_to(
             y=self.scroll_y + self.container_size.height,
@@ -2445,6 +2494,7 @@ def scroll_page_down(
             easing=easing,
             force=force,
             on_complete=on_complete,
+            level=level,
         )
 
     def scroll_page_left(
@@ -2456,6 +2506,7 @@ def scroll_page_left(
         easing: EasingFunction | str | None = None,
         force: bool = False,
         on_complete: CallbackType | None = None,
+        level: AnimationLevel = "basic",
     ) -> None:
         """Scroll one page left.
 
@@ -2466,6 +2517,7 @@ def scroll_page_left(
             easing: An easing method for the scrolling animation.
             force: Force scrolling even when prohibited by overflow styling.
             on_complete: A callable to invoke when the animation is finished.
+            level: Minimum level required for the animation to take place (inclusive).
         """
         if speed is None and duration is None:
             duration = 0.3
@@ -2477,6 +2529,7 @@ def scroll_page_left(
             easing=easing,
             force=force,
             on_complete=on_complete,
+            level=level,
         )
 
     def scroll_page_right(
@@ -2488,6 +2541,7 @@ def scroll_page_right(
         easing: EasingFunction | str | None = None,
         force: bool = False,
         on_complete: CallbackType | None = None,
+        level: AnimationLevel = "basic",
     ) -> None:
         """Scroll one page right.
 
@@ -2498,6 +2552,7 @@ def scroll_page_right(
             easing: An easing method for the scrolling animation.
             force: Force scrolling even when prohibited by overflow styling.
             on_complete: A callable to invoke when the animation is finished.
+            level: Minimum level required for the animation to take place (inclusive).
         """
         if speed is None and duration is None:
             duration = 0.3
@@ -2509,6 +2564,7 @@ def scroll_page_right(
             easing=easing,
             force=force,
             on_complete=on_complete,
+            level=level,
         )
 
     def scroll_to_widget(
@@ -2524,6 +2580,7 @@ def scroll_to_widget(
         origin_visible: bool = True,
         force: bool = False,
         on_complete: CallbackType | None = None,
+        level: AnimationLevel = "basic",
     ) -> bool:
         """Scroll scrolling to bring a widget in to view.
 
@@ -2537,6 +2594,7 @@ def scroll_to_widget(
             origin_visible: Ensure that the top left of the widget is within the window.
             force: Force scrolling even when prohibited by overflow styling.
             on_complete: A callable to invoke when the animation is finished.
+            level: Minimum level required for the animation to take place (inclusive).
 
         Returns:
             `True` if any scrolling has occurred in any descendant, otherwise `False`.
@@ -2563,6 +2621,7 @@ def scroll_to_widget(
                     origin_visible=origin_visible,
                     force=force,
                     on_complete=on_complete,
+                    level=level,
                 )
                 if scroll_offset:
                     scrolled = True
@@ -2597,6 +2656,7 @@ def scroll_to_region(
         origin_visible: bool = True,
         force: bool = False,
         on_complete: CallbackType | None = None,
+        level: AnimationLevel = "basic",
     ) -> Offset:
         """Scrolls a given region in to view, if required.
 
@@ -2614,6 +2674,7 @@ def scroll_to_region(
             origin_visible: Ensure that the top left of the widget is within the window.
             force: Force scrolling even when prohibited by overflow styling.
             on_complete: A callable to invoke when the animation is finished.
+            level: Minimum level required for the animation to take place (inclusive).
 
         Returns:
             The distance that was scrolled.
@@ -2660,6 +2721,7 @@ def scroll_to_region(
                 easing=easing,
                 force=force,
                 on_complete=on_complete,
+                level=level,
             )
         return delta
 
@@ -2673,6 +2735,7 @@ def scroll_visible(
         easing: EasingFunction | str | None = None,
         force: bool = False,
         on_complete: CallbackType | None = None,
+        level: AnimationLevel = "basic",
     ) -> None:
         """Scroll the container to make this widget visible.
 
@@ -2684,6 +2747,7 @@ def scroll_visible(
             easing: An easing method for the scrolling animation.
             force: Force scrolling even when prohibited by overflow styling.
             on_complete: A callable to invoke when the animation is finished.
+            level: Minimum level required for the animation to take place (inclusive).
         """
         parent = self.parent
         if isinstance(parent, Widget):
@@ -2697,6 +2761,7 @@ def scroll_visible(
                 easing=easing,
                 force=force,
                 on_complete=on_complete,
+                level=level,
             )
 
     def scroll_to_center(
@@ -2710,6 +2775,7 @@ def scroll_to_center(
         force: bool = False,
         origin_visible: bool = True,
         on_complete: CallbackType | None = None,
+        level: AnimationLevel = "basic",
     ) -> None:
         """Scroll this widget to the center of self.
 
@@ -2724,6 +2790,7 @@ def scroll_to_center(
             force: Force scrolling even when prohibited by overflow styling.
             origin_visible: Ensure that the top left corner of the widget remains visible after the scroll.
             on_complete: A callable to invoke when the animation is finished.
+            level: Minimum level required for the animation to take place (inclusive).
         """
 
         self.call_after_refresh(
@@ -2737,6 +2804,7 @@ def scroll_to_center(
             center=True,
             origin_visible=origin_visible,
             on_complete=on_complete,
+            level=level,
         )
 
     def can_view(self, widget: Widget) -> bool:
diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py
index 83a3237b2d..d6f79f88e3 100644
--- a/src/textual/widgets/_button.py
+++ b/src/textual/widgets/_button.py
@@ -10,6 +10,7 @@
 
 from .. import events
 from ..binding import Binding
+from ..constants import AnimationLevel
 from ..css._error_tools import friendly_list
 from ..message import Message
 from ..pad import HorizontalPad
@@ -239,22 +240,29 @@ async def _on_click(self, event: events.Click) -> None:
         event.stop()
         self.press()
 
-    def press(self) -> Self:
+    def press(self, *, level: AnimationLevel = "basic") -> Self:
         """Respond to a button press.
 
+        Args:
+            level: Minimum level required for the animation to take place (inclusive).
+
         Returns:
             The button instance."""
         if self.disabled or not self.display:
             return self
         # Manage the "active" effect:
-        self._start_active_affect()
+        self._start_active_affect(level=level)
         # ...and let other components know that we've just been clicked:
         self.post_message(Button.Pressed(self))
         return self
 
-    def _start_active_affect(self) -> None:
-        """Start a small animation to show the button was clicked."""
-        if self.active_effect_duration > 0:
+    def _start_active_affect(self, *, level: AnimationLevel = "basic") -> None:
+        """Start a small animation to show the button was clicked.
+
+        Args:
+            level: Minimum level required for the animation to take place (inclusive).
+        """
+        if self.active_effect_duration > 0 and self.app.animation_level != "none":
             self.add_class("-active")
             self.set_timer(
                 self.active_effect_duration, partial(self.remove_class, "-active")
diff --git a/src/textual/widgets/_loading_indicator.py b/src/textual/widgets/_loading_indicator.py
index e7cc4abb47..a826c85f85 100644
--- a/src/textual/widgets/_loading_indicator.py
+++ b/src/textual/widgets/_loading_indicator.py
@@ -54,6 +54,9 @@ def _on_mount(self, _: Mount) -> None:
         self.auto_refresh = 1 / 16
 
     def render(self) -> RenderableType:
+        if self.app.animation_level is "none":
+            return Text("Loading...")
+
         elapsed = time() - self._start_time
         speed = 0.8
         dot = "\u25cf"
diff --git a/src/textual/widgets/_progress_bar.py b/src/textual/widgets/_progress_bar.py
index 6127f48f4f..fea9c2fba2 100644
--- a/src/textual/widgets/_progress_bar.py
+++ b/src/textual/widgets/_progress_bar.py
@@ -105,14 +105,18 @@ def render_indeterminate(self) -> RenderResult:
         # Width used to enable the visual effect of the bar going into the corners.
         total_imaginary_width = width + highlighted_bar_width
 
-        speed = 30  # Cells per second.
-        # Compute the position of the bar.
-        start = (speed * self._get_elapsed_time()) % (2 * total_imaginary_width)
-        if start > total_imaginary_width:
-            # If the bar is to the right of its width, wrap it back from right to left.
-            start = 2 * total_imaginary_width - start  # = (tiw - (start - tiw))
-        start -= highlighted_bar_width
-        end = start + highlighted_bar_width
+        if self.app.animation_level == "none":
+            start = 0
+            end = width
+        else:
+            speed = 30  # Cells per second.
+            # Compute the position of the bar.
+            start = (speed * self._get_elapsed_time()) % (2 * total_imaginary_width)
+            if start > total_imaginary_width:
+                # If the bar is to the right of its width, wrap it back from right to left.
+                start = 2 * total_imaginary_width - start  # = (tiw - (start - tiw))
+            start -= highlighted_bar_width
+            end = start + highlighted_bar_width
 
         bar_style = self.get_component_rich_style("bar--indeterminate")
         return BarRenderable(
diff --git a/src/textual/widgets/_switch.py b/src/textual/widgets/_switch.py
index a6114ff3a8..12d5ebb7d0 100644
--- a/src/textual/widgets/_switch.py
+++ b/src/textual/widgets/_switch.py
@@ -130,7 +130,12 @@ def __init__(
     def watch_value(self, value: bool) -> None:
         target_slider_pos = 1.0 if value else 0.0
         if self._should_animate:
-            self.animate("slider_pos", target_slider_pos, duration=0.3)
+            self.animate(
+                "slider_pos",
+                target_slider_pos,
+                duration=0.3,
+                level="basic",
+            )
         else:
             self.slider_pos = target_slider_pos
         self.post_message(self.Changed(self, self.value))
diff --git a/src/textual/widgets/_tabs.py b/src/textual/widgets/_tabs.py
index 0123e17688..2bdf4b4d86 100644
--- a/src/textual/widgets/_tabs.py
+++ b/src/textual/widgets/_tabs.py
@@ -590,7 +590,10 @@ def watch_active(self, previously_active: str, active: str) -> None:
             underline.highlight_end = 0
             self.post_message(self.Cleared(self))
 
-    def _highlight_active(self, animate: bool = True) -> None:
+    def _highlight_active(
+        self,
+        animate: bool = True,
+    ) -> None:
         """Move the underline bar to under the active tab.
 
         Args:
@@ -607,7 +610,8 @@ def _highlight_active(self, animate: bool = True) -> None:
             underline.show_highlight = True
             tab_region = active_tab.virtual_region.shrink(active_tab.styles.gutter)
             start, end = tab_region.column_span
-            if animate:
+            # This is a basic animation, so we only disable it if we want no animations.
+            if animate and self.app.animation_level != "none":
 
                 def animate_underline() -> None:
                     """Animate the underline."""
@@ -620,8 +624,18 @@ def animate_underline() -> None:
                             active_tab.styles.gutter
                         )
                         start, end = tab_region.column_span
-                        underline.animate("highlight_start", start, duration=0.3)
-                        underline.animate("highlight_end", end, duration=0.3)
+                        underline.animate(
+                            "highlight_start",
+                            start,
+                            duration=0.3,
+                            level="basic",
+                        )
+                        underline.animate(
+                            "highlight_end",
+                            end,
+                            duration=0.3,
+                            level="basic",
+                        )
 
                 self.set_timer(0.02, lambda: self.call_after_refresh(animate_underline))
             else:
diff --git a/tests/animations/test_button_animation.py b/tests/animations/test_button_animation.py
new file mode 100644
index 0000000000..afbe0a8531
--- /dev/null
+++ b/tests/animations/test_button_animation.py
@@ -0,0 +1,42 @@
+"""
+Tests for the “button pressed” animation, which is considered a basic animation.
+(An animation that also plays on the level BASIC.)
+"""
+
+from textual.app import App, ComposeResult
+from textual.widgets import Button
+
+
+class ButtonApp(App[None]):
+    def compose(self) -> ComposeResult:
+        yield Button()
+
+
+async def test_button_animates_on_full() -> None:
+    """The button click animation should play on FULL."""
+    app = ButtonApp()
+    app.animation_level = "full"
+
+    async with app.run_test() as pilot:
+        await pilot.click(Button)
+        assert app.query_one(Button).has_class("-active")
+
+
+async def test_button_animates_on_basic() -> None:
+    """The button click animation should play on BASIC."""
+    app = ButtonApp()
+    app.animation_level = "basic"
+
+    async with app.run_test() as pilot:
+        await pilot.click(Button)
+        assert app.query_one(Button).has_class("-active")
+
+
+async def test_button_does_not_animate_on_none() -> None:
+    """The button click animation should play on NONE."""
+    app = ButtonApp()
+    app.animation_level = "none"
+
+    async with app.run_test() as pilot:
+        await pilot.click(Button)
+        assert not app.query_one(Button).has_class("-active")
diff --git a/tests/animations/test_disabling_animations.py b/tests/animations/test_disabling_animations.py
new file mode 100644
index 0000000000..2880262025
--- /dev/null
+++ b/tests/animations/test_disabling_animations.py
@@ -0,0 +1,163 @@
+"""
+Test that generic animations can be disabled.
+"""
+
+from textual.app import App, ComposeResult
+from textual.color import Color
+from textual.widgets import Label
+
+
+class SingleLabelApp(App[None]):
+    """Single label whose background colour we'll animate."""
+
+    CSS = """
+        Label {
+            background: red;
+        }
+    """
+
+    def compose(self) -> ComposeResult:
+        yield Label()
+
+
+async def test_style_animations_via_animate_work_on_full() -> None:
+    app = SingleLabelApp()
+    app.animation_level = "full"
+
+    async with app.run_test():
+        label = app.query_one(Label)
+        # Sanity check.
+        assert label.styles.background == Color.parse("red")
+        animator = app.animator
+        # Freeze time at 0 before triggering the animation.
+        animator._get_time = lambda *_: 0
+        label.styles.animate("background", "blue", duration=1)
+        assert len(animator._animations) > 0  # Sanity check.
+        # Freeze time around the animation midpoint.
+        animator._get_time = lambda *_: 0.5
+        # Move to the next frame.
+        animator()
+        # The animation shouldn't have completed.
+        assert label.styles.background != Color.parse("red")
+        assert label.styles.background != Color.parse("blue")
+
+
+async def test_style_animations_via_animate_are_disabled_on_basic() -> None:
+    app = SingleLabelApp()
+    app.animation_level = "basic"
+
+    async with app.run_test():
+        label = app.query_one(Label)
+        # Sanity check.
+        assert label.styles.background == Color.parse("red")
+        animator = app.animator
+        # Freeze time at 0 before triggering the animation.
+        animator._get_time = lambda *_: 0
+        label.styles.animate("background", "blue", duration=1)
+        assert len(animator._animations) > 0  # Sanity check.
+        # Freeze time after the animation start and before animation end.
+        animator._get_time = lambda *_: 0.01
+        # Move to the next frame.
+        animator()
+        # The animation should have completed.
+        assert label.styles.background == Color.parse("blue")
+
+
+async def test_style_animations_via_animate_are_disabled_on_none() -> None:
+    app = SingleLabelApp()
+    app.animation_level = "none"
+
+    async with app.run_test():
+        label = app.query_one(Label)
+        # Sanity check.
+        assert label.styles.background == Color.parse("red")
+        animator = app.animator
+        # Freeze time at 0 before triggering the animation.
+        animator._get_time = lambda *_: 0
+        label.styles.animate("background", "blue", duration=1)
+        assert len(animator._animations) > 0  # Sanity check.
+        # Freeze time after the animation start and before animation end.
+        animator._get_time = lambda *_: 0.01
+        # Move to the next frame.
+        animator()
+        # The animation should have completed.
+        assert label.styles.background == Color.parse("blue")
+
+
+class LabelWithTransitionsApp(App[None]):
+    """Single label whose background is set to animate with TCSS."""
+
+    CSS = """
+        Label {
+            background: red;
+            transition: background 1s;
+        }
+
+        Label.blue-bg {
+            background: blue;
+        }
+    """
+
+    def compose(self) -> ComposeResult:
+        yield Label()
+
+
+async def test_style_animations_via_transition_work_on_full() -> None:
+    app = LabelWithTransitionsApp()
+    app.animation_level = "full"
+
+    async with app.run_test():
+        label = app.query_one(Label)
+        # Sanity check.
+        assert label.styles.background == Color.parse("red")
+        animator = app.animator
+        # Free time at 0 before triggering the animation.
+        animator._get_time = lambda *_: 0
+        label.add_class("blue-bg")
+        assert len(animator._animations) > 0  # Sanity check.
+        # Freeze time in the middle of the animation.
+        animator._get_time = lambda *_: 0.5
+        animator()
+        # The animation should be undergoing.
+        assert label.styles.background != Color.parse("red")
+        assert label.styles.background != Color.parse("blue")
+
+
+async def test_style_animations_via_transition_are_disabled_on_basic() -> None:
+    app = LabelWithTransitionsApp()
+    app.animation_level = "basic"
+
+    async with app.run_test():
+        label = app.query_one(Label)
+        # Sanity check.
+        assert label.styles.background == Color.parse("red")
+        animator = app.animator
+        # Free time at 0 before triggering the animation.
+        animator._get_time = lambda *_: 0
+        label.add_class("blue-bg")
+        assert len(animator._animations) > 0  # Sanity check.
+        # Freeze time after the animation start and before animation end.
+        animator._get_time = lambda *_: 0.01
+        animator()
+        # The animation should have completed.
+        assert label.styles.background == Color.parse("blue")
+
+
+async def test_style_animations_via_transition_are_disabled_on_none() -> None:
+    app = LabelWithTransitionsApp()
+    app.animation_level = "none"
+
+    async with app.run_test():
+        label = app.query_one(Label)
+        # Sanity check.
+        assert label.styles.background == Color.parse("red")
+        animator = app.animator
+        # Free time at 0 before triggering the animation.
+        animator._get_time = lambda *_: 0
+        label.add_class("blue-bg")
+        assert len(animator._animations) > 0  # Sanity check.
+        # Freeze time after the animation start and before animation end.
+        animator._get_time = lambda *_: 0.01
+        animator()
+        # The animation should have completed.
+        assert label.styles.background == Color.parse("blue")
diff --git a/tests/animations/test_environment_variable.py b/tests/animations/test_environment_variable.py
new file mode 100644
index 0000000000..49359d6a75
--- /dev/null
+++ b/tests/animations/test_environment_variable.py
@@ -0,0 +1,32 @@
+import pytest
+
+from textual import constants
+from textual.app import App
+from textual.constants import _get_textual_animations
+
+
+@pytest.mark.parametrize(
+    ["env_variable", "value"],
+    [
+        ("", "full"),  # default
+        ("FULL", "full"),
+        ("BASIC", "basic"),
+        ("NONE", "none"),
+        ("garbanzo beans", "full"),  # fallback
+    ],
+)
+def test__get_textual_animations(monkeypatch, env_variable, value):  # type: ignore
+    """Test that we parse the correct values from the env variable."""
+    monkeypatch.setenv("TEXTUAL_ANIMATIONS", env_variable)
+    assert _get_textual_animations() == value
+
+
+@pytest.mark.parametrize(
+    ["value"],
+    [("full",), ("basic",), ("none",)],
+)
+def test_app_show_animations(monkeypatch, value):  # type: ignore
+    """Test that the app gets the value of `show_animations` correctly."""
+    monkeypatch.setattr(constants, "TEXTUAL_ANIMATIONS", value)
+    app = App()
+    assert app.animation_level == value
diff --git a/tests/animations/test_loading_indicator_animation.py b/tests/animations/test_loading_indicator_animation.py
new file mode 100644
index 0000000000..3f1df80a55
--- /dev/null
+++ b/tests/animations/test_loading_indicator_animation.py
@@ -0,0 +1,43 @@
+"""
+Tests for the loading indicator animation, which is considered a basic animation.
+(An animation that also plays on the level BASIC.)
+"""
+
+from textual.app import App
+from textual.widgets import LoadingIndicator
+
+
+async def test_loading_indicator_is_not_static_on_full() -> None:
+    """The loading indicator doesn't fall back to the static render on FULL."""
+    app = App()
+    app.animation_level = "full"
+
+    async with app.run_test() as pilot:
+        app.screen.loading = True
+        await pilot.pause()
+        indicator = app.query_one(LoadingIndicator)
+        assert str(indicator.render()) != "Loading..."
+
+
+async def test_loading_indicator_is_not_static_on_basic() -> None:
+    """The loading indicator doesn't fall back to the static render on BASIC."""
+    app = App()
+    app.animation_level = "basic"
+
+    async with app.run_test() as pilot:
+        app.screen.loading = True
+        await pilot.pause()
+        indicator = app.query_one(LoadingIndicator)
+        assert str(indicator.render()) != "Loading..."
+
+
+async def test_loading_indicator_is_static_on_none() -> None:
+    """The loading indicator falls back to the static render on NONE."""
+    app = App()
+    app.animation_level = "none"
+
+    async with app.run_test() as pilot:
+        app.screen.loading = True
+        await pilot.pause()
+        indicator = app.query_one(LoadingIndicator)
+        assert str(indicator.render()) == "Loading..."
diff --git a/tests/animations/test_progress_bar_animation.py b/tests/animations/test_progress_bar_animation.py
new file mode 100644
index 0000000000..82e2add599
--- /dev/null
+++ b/tests/animations/test_progress_bar_animation.py
@@ -0,0 +1,47 @@
+"""
+Tests for the indeterminate progress bar animation, which is considered a basic
+animation. (An animation that also plays on the level BASIC.)
+"""
+
+from textual.app import App, ComposeResult
+from textual.widgets import ProgressBar
+from textual.widgets._progress_bar import Bar
+
+
+class ProgressBarApp(App[None]):
+    def compose(self) -> ComposeResult:
+        yield ProgressBar()
+
+
+async def test_progress_bar_animates_on_full() -> None:
+    """An indeterminate progress bar is not fully highlighted when animating."""
+    app = ProgressBarApp()
+    app.animation_level = "full"
+
+    async with app.run_test():
+        bar_renderable = app.query_one(Bar).render()
+        start, end = bar_renderable.highlight_range
+        assert start != 0 or end != app.query_one(Bar).size.width
+
+
+async def test_progress_bar_animates_on_basic() -> None:
+    """An indeterminate progress bar is not fully highlighted when animating."""
+    app = ProgressBarApp()
+    app.animation_level = "basic"
+
+    async with app.run_test():
+        bar_renderable = app.query_one(Bar).render()
+        start, end = bar_renderable.highlight_range
+        assert start != 0 or end != app.query_one(Bar).size.width
+
+
+async def test_progress_bar_does_not_animate_on_none() -> None:
+    """An indeterminate progress bar is fully highlighted when not animating."""
+    app = ProgressBarApp()
+    app.animation_level = "none"
+
+    async with app.run_test():
+        bar_renderable = app.query_one(Bar).render()
+        start, end = bar_renderable.highlight_range
+        assert start == 0
+        assert end == app.query_one(Bar).size.width
diff --git a/tests/animations/test_scrolling_animation.py b/tests/animations/test_scrolling_animation.py
new file mode 100644
index 0000000000..172dc09c61
--- /dev/null
+++ b/tests/animations/test_scrolling_animation.py
@@ -0,0 +1,69 @@
+"""
+Tests for scrolling animations, which are considered a basic animation.
+(An animation that also plays on the level BASIC.)
+"""
+
+from textual.app import App, ComposeResult
+from textual.containers import VerticalScroll
+from textual.widgets import Label
+
+
+class TallApp(App[None]):
+    def compose(self) -> ComposeResult:
+        with VerticalScroll():
+            for _ in range(100):
+                yield Label()
+
+
+async def test_scrolling_animates_on_full() -> None:
+    app = TallApp()
+    app.animation_level = "full"
+
+    async with app.run_test() as pilot:
+        vertical_scroll = app.query_one(VerticalScroll)
+        animator = app.animator
+        # Freeze time at 0 before triggering the animation.
+        animator._get_time = lambda *_: 0
+        vertical_scroll.scroll_end(duration=10000)
+        await pilot.pause()
+        # Freeze time after the animation start and before animation end.
+        animator._get_time = lambda *_: 0.01
+        # Move to the next frame.
+        animator()
+        assert animator.is_being_animated(vertical_scroll, "scroll_y")
+
+
+async def test_scrolling_animates_on_basic() -> None:
+    app = TallApp()
+    app.animation_level = "basic"
+
+    async with app.run_test() as pilot:
+        vertical_scroll = app.query_one(VerticalScroll)
+        animator = app.animator
+        # Freeze time at 0 before triggering the animation.
+        animator._get_time = lambda *_: 0
+        vertical_scroll.scroll_end(duration=10000)
+        await pilot.pause()
+        # Freeze time after the animation start and before animation end.
+        animator._get_time = lambda *_: 0.01
+        # Move to the next frame.
+        animator()
+        assert animator.is_being_animated(vertical_scroll, "scroll_y")
+
+
+async def test_scrolling_does_not_animate_on_none() -> None:
+    app = TallApp()
+    app.animation_level = "none"
+
+    async with app.run_test() as pilot:
+        vertical_scroll = app.query_one(VerticalScroll)
+        animator = app.animator
+        # Freeze time at 0 before triggering the animation.
+        animator._get_time = lambda *_: 0
+        vertical_scroll.scroll_end(duration=10000)
+        await pilot.pause()
+        # Freeze time after the animation start and before animation end.
+        animator._get_time = lambda *_: 0.01
+        # Move to the next frame.
+        animator()
+        assert not animator.is_being_animated(vertical_scroll, "scroll_y")
diff --git a/tests/animations/test_switch_animation.py b/tests/animations/test_switch_animation.py
new file mode 100644
index 0000000000..333e6903f3
--- /dev/null
+++ b/tests/animations/test_switch_animation.py
@@ -0,0 +1,69 @@
+"""
+Tests for the switch toggle animation, which is considered a basic animation.
+(An animation that also plays on the level BASIC.)
+"""
+
+from textual.app import App, ComposeResult
+from textual.widgets import Switch
+
+
+class SwitchApp(App[None]):
+    def compose(self) -> ComposeResult:
+        yield Switch()
+
+
+async def test_switch_animates_on_full() -> None:
+    app = SwitchApp()
+    app.animation_level = "full"
+
+    async with app.run_test() as pilot:
+        switch = app.query_one(Switch)
+        animator = app.animator
+        # Freeze time at 0 before triggering the animation.
+        animator._get_time = lambda *_: 0
+        switch.action_toggle()
+        await pilot.pause()
+        # Freeze time after the animation start and before animation end.
+        animator._get_time = lambda *_: 0.01
+        # Move to the next frame.
+        animator()
+        # The animation should still be running.
+        assert app.animator.is_being_animated(switch, "slider_pos")
+
+
+async def test_switch_animates_on_basic() -> None:
+    app = SwitchApp()
+    app.animation_level = "basic"
+
+    async with app.run_test() as pilot:
+        switch = app.query_one(Switch)
+        animator = app.animator
+        # Freeze time at 0 before triggering the animation.
+        animator._get_time = lambda *_: 0
+        switch.action_toggle()
+        await pilot.pause()
+        # Freeze time after the animation start and before animation end.
+        animator._get_time = lambda *_: 0.01
+        # Move to the next frame.
+        animator()
+        # The animation should still be running.
+        assert app.animator.is_being_animated(switch, "slider_pos")
+
+
+async def test_switch_does_not_animate_on_none() -> None:
+    app = SwitchApp()
+    app.animation_level = "none"
+
+    async with app.run_test() as pilot:
+        switch = app.query_one(Switch)
+        animator = app.animator
+        # Freeze time at 0 before triggering the animation.
+        animator._get_time = lambda *_: 0
+        switch.action_toggle()
+        await pilot.pause()
+        # Freeze time after the animation start and before animation end.
+        animator._get_time = lambda *_: 0.01
+        # Move to the next frame.
+        animator()
+        # The animation should still be running.
+        assert not app.animator.is_being_animated(switch, "slider_pos")
diff --git a/tests/animations/test_tabs_underline_animation.py b/tests/animations/test_tabs_underline_animation.py
new file mode 100644
index 0000000000..05e83e9e5d
--- /dev/null
+++ b/tests/animations/test_tabs_underline_animation.py
@@ -0,0 +1,75 @@
+"""
+Tests for the tabs underline animation, which is considered a basic animation.
+(An animation that also plays on the level BASIC.)
+"""
+
+from textual.app import App, ComposeResult
+from textual.widgets import Label, TabbedContent, Tabs
+from textual.widgets._tabs import Underline
+
+
+class TabbedContentApp(App[None]):
+    def compose(self) -> ComposeResult:
+        with TabbedContent():
+            for _ in range(10):
+                yield Label("Hey!")
+
+
+async def test_tabs_underline_animates_on_full() -> None:
+    """The underline takes some time to move when animated."""
+    app = TabbedContentApp()
+    app.animation_level = "full"
+
+    async with app.run_test() as pilot:
+        underline = app.query_one(Underline)
+        animator = app.animator
+        # Freeze time at 0 before triggering the animation.
+        animator._get_time = lambda *_: 0
+        app.query_one(Tabs).action_previous_tab()
+        await pilot.pause()
+        # Freeze time after the animation start and before animation end.
+        animator._get_time = lambda *_: 0.01
+        # Move to the next frame.
+        animator()
+        assert animator.is_being_animated(underline, "highlight_start")
+        assert animator.is_being_animated(underline, "highlight_end")
+
+
+async def test_tabs_underline_animates_on_basic() -> None:
+    """The underline takes some time to move when animated."""
+    app = TabbedContentApp()
+    app.animation_level = "basic"
+
+    async with app.run_test() as pilot:
+        underline = app.query_one(Underline)
+        animator = app.animator
+        # Freeze time at 0 before triggering the animation.
+        animator._get_time = lambda *_: 0
+        app.query_one(Tabs).action_previous_tab()
+        await pilot.pause()
+        # Freeze time after the animation start and before animation end.
+        animator._get_time = lambda *_: 0.01
+        # Move to the next frame.
+        animator()
+        assert animator.is_being_animated(underline, "highlight_start")
+        assert animator.is_being_animated(underline, "highlight_end")
+
+
+async def test_tabs_underline_does_not_animate_on_none() -> None:
+    """The underline jumps to its final position when not animated."""
+    app = TabbedContentApp()
+    app.animation_level = "none"
+
+    async with app.run_test() as pilot:
+        underline = app.query_one(Underline)
+        animator = app.animator
+        # Freeze time at 0 before triggering the animation.
+        animator._get_time = lambda *_: 0
+        app.query_one(Tabs).action_previous_tab()
+        await pilot.pause()
+        # Freeze time after the animation start and before animation end.
+        animator._get_time = lambda *_: 0.01
+        # Move to the next frame.
+        animator()
+        assert not animator.is_being_animated(underline, "highlight_start")
+        assert not animator.is_being_animated(underline, "highlight_end")


Code review comments:
diff_hunk: @@ -4,6 +4,7 @@
 
 from .._animator import Animation, EasingFunction
 from .._types import CallbackType
+from ..constants import AnimationLevel
path: src/textual/css/scalar_animation.py
text: @user1:
This is the wrong place for this symbol, as its not a constant per se. Suggest `types.py`

@author:
Good call. I'll move it to `_types`, though, because that's where we have those types.
`types.py` is just to expose things to the documentation.

diff_hunk: @@ -239,22 +240,29 @@ async def _on_click(self, event: events.Click) -> None:
         event.stop()
         self.press()
 
-    def press(self) -> Self:
+    def press(self, *, level: AnimationLevel = "basic") -> Self:
path: src/textual/widgets/_button.py
text: @user1:
After a discussion in the office, we don't think this qualifies as an animation at all

@author:
👌