diff --git a/packages/SystemUI/animation/src/com/android/systemui/surfaceeffects/glowboxeffect/GlowBoxConfig.kt b/packages/SystemUI/animation/src/com/android/systemui/surfaceeffects/glowboxeffect/GlowBoxConfig.kt
new file mode 100644
index 0000000000000000000000000000000000000000..72f0e86f9dcea6652e9bd34d478d28fdbb9ef4d0
--- /dev/null
+++ b/packages/SystemUI/animation/src/com/android/systemui/surfaceeffects/glowboxeffect/GlowBoxConfig.kt
@@ -0,0 +1,46 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.surfaceeffects.glowboxeffect
+
+/** Parameters used to play [GlowBoxEffect]. */
+data class GlowBoxConfig(
+    /** Start center position X in px. */
+    val startCenterX: Float,
+    /** Start center position Y in px. */
+    val startCenterY: Float,
+    /** End center position X in px. */
+    val endCenterX: Float,
+    /** End center position Y in px. */
+    val endCenterY: Float,
+    /** Width of the box in px. */
+    val width: Float,
+    /** Height of the box in px. */
+    val height: Float,
+    /** Color of the box in ARGB, Apply alpha value if needed. */
+    val color: Int,
+    /** Amount of blur (or glow) of the box. */
+    val blurAmount: Float,
+    /**
+     * Duration of the animation. Note that the full duration of the animation is
+     * [duration] + [easeInDuration] + [easeOutDuration].
+     */
+    val duration: Long,
+    /** Ease in duration of the animation. */
+    val easeInDuration: Long,
+    /** Ease out duration of the animation. */
+    val easeOutDuration: Long,
+)
diff --git a/packages/SystemUI/animation/src/com/android/systemui/surfaceeffects/glowboxeffect/GlowBoxEffect.kt b/packages/SystemUI/animation/src/com/android/systemui/surfaceeffects/glowboxeffect/GlowBoxEffect.kt
new file mode 100644
index 0000000000000000000000000000000000000000..8a25cfd88e7342ffeb7e6ad143d8682188ffce16
--- /dev/null
+++ b/packages/SystemUI/animation/src/com/android/systemui/surfaceeffects/glowboxeffect/GlowBoxEffect.kt
@@ -0,0 +1,147 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.surfaceeffects.glowboxeffect
+
+import android.animation.ValueAnimator
+import android.graphics.Paint
+import androidx.annotation.VisibleForTesting
+import androidx.core.animation.doOnEnd
+import com.android.systemui.surfaceeffects.PaintDrawCallback
+import com.android.systemui.surfaceeffects.utils.MathUtils.lerp
+
+/** Glow box effect where the box moves from start to end positions defined in the [config]. */
+class GlowBoxEffect(
+    private val config: GlowBoxConfig,
+    private val paintDrawCallback: PaintDrawCallback
+) {
+    private val glowBoxShader =
+        GlowBoxShader().apply {
+            setSize(config.width, config.height)
+            setCenter(config.startCenterX, config.startCenterY)
+            setBlur(config.blurAmount)
+            setColor(config.color)
+        }
+    private var animator: ValueAnimator? = null
+    @VisibleForTesting var state: AnimationState = AnimationState.NOT_PLAYING
+    private val paint = Paint().apply { shader = glowBoxShader }
+
+    fun play() {
+        if (state != AnimationState.NOT_PLAYING) {
+            return
+        }
+
+        playEaseIn()
+    }
+
+    fun finish() {
+        if (state == AnimationState.NOT_PLAYING || state == AnimationState.EASE_OUT) {
+            return
+        }
+
+        animator?.pause()
+        playEaseOut()
+    }
+
+    private fun playEaseIn() {
+        if (state == AnimationState.EASE_IN) {
+            return
+        }
+        state = AnimationState.EASE_IN
+
+        animator =
+            ValueAnimator.ofFloat(0f, 1f).apply {
+                duration = config.easeInDuration
+                addUpdateListener {
+                    val progress = it.animatedValue as Float
+                    glowBoxShader.setCenter(
+                        lerp(config.startCenterX, config.endCenterX, progress),
+                        lerp(config.startCenterY, config.endCenterY, progress)
+                    )
+
+                    draw()
+                }
+
+                doOnEnd {
+                    animator = null
+                    playMain()
+                }
+
+                start()
+            }
+    }
+
+    private fun playMain() {
+        if (state == AnimationState.MAIN) {
+            return
+        }
+        state = AnimationState.MAIN
+
+        animator =
+            ValueAnimator.ofFloat(0f, 1f).apply {
+                duration = config.duration
+                addUpdateListener { draw() }
+
+                doOnEnd {
+                    animator = null
+                    playEaseOut()
+                }
+
+                start()
+            }
+    }
+
+    private fun playEaseOut() {
+        if (state == AnimationState.EASE_OUT) return
+        state = AnimationState.EASE_OUT
+
+        animator =
+            ValueAnimator.ofFloat(0f, 1f).apply {
+                duration = config.easeOutDuration
+                addUpdateListener {
+                    val progress = it.animatedValue as Float
+                    glowBoxShader.setCenter(
+                        lerp(config.endCenterX, config.startCenterX, progress),
+                        lerp(config.endCenterY, config.startCenterY, progress)
+                    )
+
+                    draw()
+                }
+
+                doOnEnd {
+                    animator = null
+                    state = AnimationState.NOT_PLAYING
+                }
+
+                start()
+            }
+    }
+
+    private fun draw() {
+        paintDrawCallback.onDraw(paint)
+    }
+
+    /**
+     * The animation state of the effect. The animation state transitions as follows: [EASE_IN] ->
+     * [MAIN] -> [EASE_OUT] -> [NOT_PLAYING].
+     */
+    enum class AnimationState {
+        EASE_IN,
+        MAIN,
+        EASE_OUT,
+        NOT_PLAYING,
+    }
+}
diff --git a/packages/SystemUI/animation/src/com/android/systemui/surfaceeffects/glowboxeffect/GlowBoxShader.kt b/packages/SystemUI/animation/src/com/android/systemui/surfaceeffects/glowboxeffect/GlowBoxShader.kt
new file mode 100644
index 0000000000000000000000000000000000000000..36934086cc234bb11ae9213510213adf5bbebff5
--- /dev/null
+++ b/packages/SystemUI/animation/src/com/android/systemui/surfaceeffects/glowboxeffect/GlowBoxShader.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.surfaceeffects.glowboxeffect
+
+import android.graphics.RuntimeShader
+import com.android.systemui.surfaceeffects.shaderutil.SdfShaderLibrary
+
+/** Soft box shader. */
+class GlowBoxShader : RuntimeShader(GLOW_SHADER) {
+    // language=AGSL
+    private companion object {
+        private const val SHADER =
+            """
+            uniform half2 in_center;
+            uniform half2 in_size;
+            uniform half in_blur;
+            layout(color) uniform half4 in_color;
+
+            float4 main(float2 fragcoord) {
+                half glow = soften(sdBox(fragcoord - in_center, in_size), in_blur);
+                return in_color * (1. - glow);
+            }
+        """
+
+        private const val GLOW_SHADER =
+            SdfShaderLibrary.BOX_SDF + SdfShaderLibrary.SHADER_SDF_OPERATION_LIB + SHADER
+    }
+
+    fun setCenter(x: Float, y: Float) {
+        setFloatUniform("in_center", x, y)
+    }
+
+    fun setSize(width: Float, height: Float) {
+        setFloatUniform("in_size", width, height)
+    }
+
+    fun setBlur(blurAmount: Float) {
+        setFloatUniform("in_blur", blurAmount)
+    }
+
+    fun setColor(color: Int) {
+        setColorUniform("in_color", color)
+    }
+}
diff --git a/packages/SystemUI/animation/src/com/android/systemui/surfaceeffects/shaderutil/SdfShaderLibrary.kt b/packages/SystemUI/animation/src/com/android/systemui/surfaceeffects/shaderutil/SdfShaderLibrary.kt
index 78898932249bb877bcde20192e8751202b78eb58..4efab58347dd7e8a4ff4b00422c857e97575a68e 100644
--- a/packages/SystemUI/animation/src/com/android/systemui/surfaceeffects/shaderutil/SdfShaderLibrary.kt
+++ b/packages/SystemUI/animation/src/com/android/systemui/surfaceeffects/shaderutil/SdfShaderLibrary.kt
@@ -35,17 +35,26 @@ class SdfShaderLibrary {
             }
         """
 
+        const val BOX_SDF =
+            """
+                float sdBox(vec2 p, vec2 size) {
+                    size = size * 0.5;
+                    vec2 d = abs(p) - size;
+                    return length(max(d, 0.)) + min(max(d.x, d.y), 0.) / size.y;
+                }
+            """
+
         const val ROUNDED_BOX_SDF =
             """
             float sdRoundedBox(vec2 p, vec2 size, float cornerRadius) {
                 size *= 0.5;
                 cornerRadius *= 0.5;
-                vec2 d = abs(p)-size+cornerRadius;
+                vec2 d = abs(p) - size + cornerRadius;
 
                 float outside = length(max(d, 0.0));
                 float inside = min(max(d.x, d.y), 0.0);
 
-                return (outside+inside-cornerRadius)/size.y;
+                return (outside + inside - cornerRadius) / size.y;
             }
 
             float roundedBoxRing(vec2 p, vec2 size, float cornerRadius,
diff --git a/packages/SystemUI/animation/src/com/android/systemui/surfaceeffects/utils/MathUtils.kt b/packages/SystemUI/animation/src/com/android/systemui/surfaceeffects/utils/MathUtils.kt
new file mode 100644
index 0000000000000000000000000000000000000000..7ed3b87f684e8ff97cb5d0c517c1725d737c40fa
--- /dev/null
+++ b/packages/SystemUI/animation/src/com/android/systemui/surfaceeffects/utils/MathUtils.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.surfaceeffects.utils
+
+/** Copied from android.utils.MathUtils */
+object MathUtils {
+    fun lerp(start: Float, stop: Float, amount: Float): Float {
+        return start + (stop - start) * amount
+    }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/surfaceeffects/glowboxeffect/GlowBoxEffectTest.kt b/packages/SystemUI/tests/src/com/android/systemui/surfaceeffects/glowboxeffect/GlowBoxEffectTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..16132ba7cff5acc469ed95c951afaf1767b6843f
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/surfaceeffects/glowboxeffect/GlowBoxEffectTest.kt
@@ -0,0 +1,124 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.surfaceeffects.glowboxeffect
+
+import android.graphics.Color
+import android.graphics.Paint
+import android.testing.AndroidTestingRunner
+import android.testing.TestableLooper
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.animation.AnimatorTestRule
+import com.android.systemui.surfaceeffects.PaintDrawCallback
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@SmallTest
+@RunWith(AndroidTestingRunner::class)
+@TestableLooper.RunWithLooper(setAsMainLooper = true)
+class GlowBoxEffectTest : SysuiTestCase() {
+
+    @get:Rule val animatorTestRule = AnimatorTestRule(this)
+    private lateinit var config: GlowBoxConfig
+    private lateinit var glowBoxEffect: GlowBoxEffect
+    private lateinit var drawCallback: PaintDrawCallback
+
+    @Before
+    fun setup() {
+        drawCallback =
+            object : PaintDrawCallback {
+                override fun onDraw(paint: Paint) {}
+            }
+        config =
+            GlowBoxConfig(
+                startCenterX = 0f,
+                startCenterY = 0f,
+                endCenterX = 0f,
+                endCenterY = 0f,
+                width = 1f,
+                height = 1f,
+                color = Color.WHITE,
+                blurAmount = 0.1f,
+                duration = 100L,
+                easeInDuration = 100L,
+                easeOutDuration = 100L
+            )
+        glowBoxEffect = GlowBoxEffect(config, drawCallback)
+    }
+
+    @Test
+    fun play_paintCallback_triggersDrawCallback() {
+        var paintFromCallback: Paint? = null
+        drawCallback =
+            object : PaintDrawCallback {
+                override fun onDraw(paint: Paint) {
+                    paintFromCallback = paint
+                }
+            }
+        glowBoxEffect = GlowBoxEffect(config, drawCallback)
+
+        assertThat(paintFromCallback).isNull()
+
+        glowBoxEffect.play()
+        animatorTestRule.advanceTimeBy(50L)
+
+        assertThat(paintFromCallback).isNotNull()
+    }
+
+    @Test
+    fun play_followsAnimationStateInOrder() {
+        assertThat(glowBoxEffect.state).isEqualTo(GlowBoxEffect.AnimationState.NOT_PLAYING)
+
+        glowBoxEffect.play()
+
+        assertThat(glowBoxEffect.state).isEqualTo(GlowBoxEffect.AnimationState.EASE_IN)
+
+        animatorTestRule.advanceTimeBy(config.easeInDuration + 50L)
+
+        assertThat(glowBoxEffect.state).isEqualTo(GlowBoxEffect.AnimationState.MAIN)
+
+        animatorTestRule.advanceTimeBy(config.duration + 50L)
+
+        assertThat(glowBoxEffect.state).isEqualTo(GlowBoxEffect.AnimationState.EASE_OUT)
+
+        animatorTestRule.advanceTimeBy(config.easeOutDuration + 50L)
+
+        assertThat(glowBoxEffect.state).isEqualTo(GlowBoxEffect.AnimationState.NOT_PLAYING)
+    }
+
+    @Test
+    fun finish_statePlaying_finishesAnimation() {
+        assertThat(glowBoxEffect.state).isEqualTo(GlowBoxEffect.AnimationState.NOT_PLAYING)
+
+        glowBoxEffect.play()
+        glowBoxEffect.finish()
+
+        assertThat(glowBoxEffect.state).isEqualTo(GlowBoxEffect.AnimationState.EASE_OUT)
+    }
+
+    @Test
+    fun finish_stateNotPlaying_doesNotFinishAnimation() {
+        assertThat(glowBoxEffect.state).isEqualTo(GlowBoxEffect.AnimationState.NOT_PLAYING)
+
+        glowBoxEffect.finish()
+
+        assertThat(glowBoxEffect.state).isEqualTo(GlowBoxEffect.AnimationState.NOT_PLAYING)
+    }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/surfaceeffects/loadingeffect/LoadingEffectTest.kt b/packages/SystemUI/tests/src/com/android/systemui/surfaceeffects/loadingeffect/LoadingEffectTest.kt
index 6f589418cf1ef096e4f520f137ae3cbd66e84078..41d7fd54902dd39f1f75848875387ca443eabd69 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/surfaceeffects/loadingeffect/LoadingEffectTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/surfaceeffects/loadingeffect/LoadingEffectTest.kt
@@ -21,8 +21,8 @@ import android.graphics.RenderEffect
 import android.testing.AndroidTestingRunner
 import android.testing.TestableLooper
 import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
 import com.android.systemui.animation.AnimatorTestRule
-import com.android.systemui.model.SysUiStateTest
 import com.android.systemui.surfaceeffects.PaintDrawCallback
 import com.android.systemui.surfaceeffects.RenderEffectDrawCallback
 import com.android.systemui.surfaceeffects.turbulencenoise.TurbulenceNoiseAnimationConfig
@@ -35,7 +35,7 @@ import org.junit.runner.RunWith
 @SmallTest
 @RunWith(AndroidTestingRunner::class)
 @TestableLooper.RunWithLooper(setAsMainLooper = true)
-class LoadingEffectTest : SysUiStateTest() {
+class LoadingEffectTest : SysuiTestCase() {
 
     @get:Rule val animatorTestRule = AnimatorTestRule(this)