diff --git a/data/gala.gresource.xml b/data/gala.gresource.xml
index 7713468a1..9c4fc4bed 100644
--- a/data/gala.gresource.xml
+++ b/data/gala.gresource.xml
@@ -24,6 +24,7 @@
shaders/colorblindness-correction.frag
shaders/monochrome.frag
shaders/rounded-corners.frag
+ shaders/tv-effect.frag
gala-daemon.css
diff --git a/data/shaders/tv-effect.frag b/data/shaders/tv-effect.frag
new file mode 100644
index 000000000..337a7a604
--- /dev/null
+++ b/data/shaders/tv-effect.frag
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2026 elementary, Inc.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+uniform sampler2D tex;
+uniform float OCCLUSION; // 0.0 when the TV is fully on, 1.0 when it's fully off
+uniform float HEIGHT; // Screen height in pixels
+
+float cubicBezier(float x, float a, float b, float c, float d) {
+ float _x = 1.0 - x;
+ return a * _x * _x * _x +
+ b * 3.0 * _x * _x * x +
+ c * 3.0 * _x * x * x +
+ d * x * x * x;
+}
+
+vec4 tvEffect(vec2 uv, float scale, float occlusion) {
+ float y_scaled = 0.5 + (uv.y - 0.5) / (scale * scale);
+ float x_scaled = uv.x;
+
+ // Wait for scale to get small enough before shrinking the bright line to a dot
+ if (scale < 0.1) {
+ float scale_fract = scale / 0.1;
+ x_scaled = 0.5 + (uv.x - 0.5) / (scale_fract * scale_fract);
+ }
+
+ // Outside of the scaled area should be black
+ if (scale >= 0.0 && y_scaled >= 0.0 && y_scaled <= 1.0 && x_scaled >= 0.0 && x_scaled <= 1.0) {
+ vec4 color = texture2D(tex, vec2(x_scaled, y_scaled));
+ // Make it brighter as the window gets smaller
+ return color + occlusion * color + occlusion * 0.75;
+ } else {
+ return vec4(0.0, 0.0, 0.0, 1.0);
+ }
+}
+
+void main() {
+ // Ease out the occlusion
+ float occlusion = cubicBezier(OCCLUSION, 0.0, 0.98, 0.75, 1.0);
+ float scale = 1.0 - occlusion;
+ vec2 uv = cogl_tex_coord0_in.xy;
+
+ // Apply a 5x5 Gaussian blur, with support of 0.5 and sigma 1.0
+ float kernel[5];
+ kernel[0] = 0.0614;
+ kernel[1] = 0.2448;
+ kernel[2] = 0.3877;
+ kernel[3] = 0.2448;
+ kernel[4] = 0.0614;
+
+ float blurSize = occlusion * 5.0 / HEIGHT;
+
+ vec4 color = vec4(0.0);
+ for (int i = 0; i < 5; i++) {
+ for (int j = 0; j < 5; j++) {
+ vec2 offset = vec2(float(i - 2), float(j - 2)) * blurSize;
+ color += tvEffect(uv + offset, scale, occlusion) * kernel[i] * kernel[j];
+ }
+ }
+
+ cogl_color_out = color;
+}
diff --git a/src/Curtains/TVEffect.vala b/src/Curtains/TVEffect.vala
new file mode 100644
index 000000000..5a1b52e4e
--- /dev/null
+++ b/src/Curtains/TVEffect.vala
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2026 elementary, Inc.
+ * SPDX-License-Identifier: LGPL-3.0-or-later
+ */
+
+public class Gala.TVEffect : Clutter.ShaderEffect {
+ private float _occlusion = 0.0f;
+ public float occlusion { get {
+ return _occlusion;
+ } set {
+ _occlusion = value;
+ set_uniform_value ("OCCLUSION", value);
+ queue_repaint ();
+ }}
+
+ private float _height = 512.0f;
+ public float height { get {
+ return _height;
+ } set {
+ _height = value;
+ set_uniform_value ("HEIGHT", value);
+ queue_repaint ();
+ }}
+
+ public TVEffect (float occlusion = 0.0f) {
+ Object (
+#if HAS_MUTTER48
+ shader_type: Cogl.ShaderType.FRAGMENT,
+#else
+ shader_type: Clutter.ShaderType.FRAGMENT_SHADER,
+#endif
+ occlusion: occlusion
+ );
+
+ try {
+ var bytes = GLib.resources_lookup_data (
+ "/io/elementary/desktop/gala/shaders/tv-effect.frag",
+ GLib.ResourceLookupFlags.NONE
+ );
+ set_shader_source ((string) bytes.get_data ());
+ } catch (Error e) {
+ warning ("Failed to load TV effect shader: %s", e.message);
+ }
+ }
+}
diff --git a/src/Main.vala b/src/Main.vala
index a487a061c..be667f8a8 100644
--- a/src/Main.vala
+++ b/src/Main.vala
@@ -93,6 +93,9 @@ namespace Gala {
WindowStateSaver.on_shutdown ();
+ var shutdown_curtain = new Gala.ShutdownCurtain (ctx);
+ shutdown_curtain.animate ();
+
return Posix.EXIT_SUCCESS;
}
}
diff --git a/src/Widgets/Curtains/ShutdownCurtain.vala b/src/Widgets/Curtains/ShutdownCurtain.vala
new file mode 100644
index 000000000..80f85a7b6
--- /dev/null
+++ b/src/Widgets/Curtains/ShutdownCurtain.vala
@@ -0,0 +1,43 @@
+/*
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2020, 2025, 2026 elementary, Inc. (https://elementary.io)
+ */
+
+public class Gala.ShutdownCurtain : Clutter.Actor {
+ private unowned Clutter.Stage stage;
+ public uint animation_duration { get; set construct; default = 300; }
+
+ public ShutdownCurtain (Meta.Context context) {
+ int screen_width, screen_height;
+ context.get_display ().get_size (out screen_width, out screen_height);
+ width = screen_width;
+ height = screen_height;
+ stage = (Clutter.Stage) context.get_display ().get_stage ();
+ }
+
+ public void animate () {
+ var animation_thread = new Thread ("tv-effect-animation", start);
+ animation_thread.join ();
+ }
+
+ private void start () {
+ int time = 0;
+ var tv_effect = new TVEffect ();
+ tv_effect.occlusion = 0;
+ tv_effect.height = height;
+ stage.add_effect_with_name (
+ "tv-effect",
+ tv_effect
+ );
+
+ while (time < animation_duration) {
+ tv_effect.occlusion = (animation_duration - time) / (float) animation_duration;
+
+ time += 16;
+ Thread.usleep (16000);
+ }
+
+ stage.remove_effect_by_name ("tv-effect");
+ Thread.usleep (2000);
+ }
+}
diff --git a/src/meson.build b/src/meson.build
index 496ba7eca..bc8124866 100644
--- a/src/meson.build
+++ b/src/meson.build
@@ -65,10 +65,12 @@ gala_bin_sources = files(
'Widgets/PixelPicker.vala',
'Widgets/PointerLocator.vala',
'Widgets/SessionLocker.vala',
+ 'Widgets/Curtains/ShutdownCurtain.vala',
'Widgets/SelectionArea.vala',
'Widgets/WindowOverview.vala',
'Widgets/WindowSwitcher/WindowSwitcher.vala',
'Widgets/WindowSwitcher/WindowSwitcherIcon.vala',
+ 'Curtains/TVEffect.vala'
)
gala_bin = executable(