diff --git a/osu.Game.Rulesets.Catch.Tests/Mods/CatchModMirrorTest.cs b/osu.Game.Rulesets.Catch.Tests/Mods/CatchModMirrorTest.cs
new file mode 100644
index 0000000000..fbbfee6b60
--- /dev/null
+++ b/osu.Game.Rulesets.Catch.Tests/Mods/CatchModMirrorTest.cs
@@ -0,0 +1,120 @@
+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Collections.Generic;
+using NUnit.Framework;
+using osu.Framework.Utils;
+using osu.Game.Beatmaps;
+using osu.Game.Beatmaps.ControlPoints;
+using osu.Game.Rulesets.Catch.Beatmaps;
+using osu.Game.Rulesets.Catch.Mods;
+using osu.Game.Rulesets.Catch.Objects;
+using osu.Game.Rulesets.Catch.UI;
+using osu.Game.Rulesets.Objects;
+using osuTK;
+
+namespace osu.Game.Rulesets.Catch.Tests.Mods
+{
+    [TestFixture]
+    public class CatchModMirrorTest
+    {
+        [Test]
+        public void TestModMirror()
+        {
+            IBeatmap original = createBeatmap(false);
+            IBeatmap mirrored = createBeatmap(true);
+
+            assertEffectivePositionsMirrored(original, mirrored);
+        }
+
+        private static IBeatmap createBeatmap(bool withMirrorMod)
+        {
+            var beatmap = createRawBeatmap();
+            var mirrorMod = new CatchModMirror();
+
+            var beatmapProcessor = new CatchBeatmapProcessor(beatmap);
+            beatmapProcessor.PreProcess();
+
+            foreach (var hitObject in beatmap.HitObjects)
+                hitObject.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
+
+            beatmapProcessor.PostProcess();
+
+            if (withMirrorMod)
+                mirrorMod.ApplyToBeatmap(beatmap);
+
+            return beatmap;
+        }
+
+        private static IBeatmap createRawBeatmap() => new Beatmap
+        {
+            HitObjects = new List<HitObject>
+            {
+                new Fruit
+                {
+                    OriginalX = 150,
+                    StartTime = 0
+                },
+                new Fruit
+                {
+                    OriginalX = 450,
+                    StartTime = 500
+                },
+                new JuiceStream
+                {
+                    OriginalX = 250,
+                    Path = new SliderPath
+                    {
+                        ControlPoints =
+                        {
+                            new PathControlPoint(new Vector2(-100, 1)),
+                            new PathControlPoint(new Vector2(0, 2)),
+                            new PathControlPoint(new Vector2(100, 3)),
+                            new PathControlPoint(new Vector2(0, 4))
+                        }
+                    },
+                    StartTime = 1000,
+                },
+                new BananaShower
+                {
+                    StartTime = 5000,
+                    Duration = 5000
+                }
+            }
+        };
+
+        private static void assertEffectivePositionsMirrored(IBeatmap original, IBeatmap mirrored)
+        {
+            if (original.HitObjects.Count != mirrored.HitObjects.Count)
+                Assert.Fail($"Top-level object count mismatch (original: {original.HitObjects.Count}, mirrored: {mirrored.HitObjects.Count})");
+
+            for (int i = 0; i < original.HitObjects.Count; ++i)
+            {
+                var originalObject = (CatchHitObject)original.HitObjects[i];
+                var mirroredObject = (CatchHitObject)mirrored.HitObjects[i];
+
+                // banana showers themselves are exempt, as we only really care about their nested bananas' positions.
+                if (!effectivePositionMirrored(originalObject, mirroredObject) && !(originalObject is BananaShower))
+                    Assert.Fail($"{originalObject.GetType().Name} at time {originalObject.StartTime} is not mirrored ({printEffectivePositions(originalObject, mirroredObject)})");
+
+                if (originalObject.NestedHitObjects.Count != mirroredObject.NestedHitObjects.Count)
+                    Assert.Fail($"{originalObject.GetType().Name} nested object count mismatch (original: {originalObject.NestedHitObjects.Count}, mirrored: {mirroredObject.NestedHitObjects.Count})");
+
+                for (int j = 0; j < originalObject.NestedHitObjects.Count; ++j)
+                {
+                    var originalNested = (CatchHitObject)originalObject.NestedHitObjects[j];
+                    var mirroredNested = (CatchHitObject)mirroredObject.NestedHitObjects[j];
+
+                    if (!effectivePositionMirrored(originalNested, mirroredNested))
+                        Assert.Fail($"{originalObject.GetType().Name}'s nested {originalNested.GetType().Name} at time {originalObject.StartTime} is not mirrored ({printEffectivePositions(originalNested, mirroredNested)})");
+                }
+            }
+        }
+
+        private static string printEffectivePositions(CatchHitObject original, CatchHitObject mirrored)
+            => $"original X: {original.EffectiveX}, mirrored X is: {mirrored.EffectiveX}, mirrored X should be: {CatchPlayfield.WIDTH - original.EffectiveX}";
+
+        private static bool effectivePositionMirrored(CatchHitObject original, CatchHitObject mirrored)
+            => Precision.AlmostEquals(original.EffectiveX, CatchPlayfield.WIDTH - mirrored.EffectiveX);
+    }
+}
diff --git a/osu.Game.Rulesets.Catch/CatchRuleset.cs b/osu.Game.Rulesets.Catch/CatchRuleset.cs
index eafa1b9b9d..9fee6b2bc1 100644
--- a/osu.Game.Rulesets.Catch/CatchRuleset.cs
+++ b/osu.Game.Rulesets.Catch/CatchRuleset.cs
@@ -117,6 +117,7 @@ namespace osu.Game.Rulesets.Catch
                     {
                         new CatchModDifficultyAdjust(),
                         new CatchModClassic(),
+                        new CatchModMirror(),
                     };
 
                 case ModType.Automation:
diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModMirror.cs b/osu.Game.Rulesets.Catch/Mods/CatchModMirror.cs
new file mode 100644
index 0000000000..932c8cad85
--- /dev/null
+++ b/osu.Game.Rulesets.Catch/Mods/CatchModMirror.cs
@@ -0,0 +1,87 @@
+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Linq;
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets.Catch.Beatmaps;
+using osu.Game.Rulesets.Catch.Objects;
+using osu.Game.Rulesets.Mods;
+using osu.Game.Rulesets.Catch.UI;
+using osu.Game.Rulesets.Objects;
+using osuTK;
+
+namespace osu.Game.Rulesets.Catch.Mods
+{
+    public class CatchModMirror : ModMirror, IApplicableToBeatmap
+    {
+        public override string Description => "Fruits are flipped horizontally.";
+
+        /// <remarks>
+        /// <see cref="IApplicableToBeatmap"/> is used instead of <see cref="IApplicableToHitObject"/>,
+        /// as <see cref="CatchBeatmapProcessor"/> applies offsets in <see cref="CatchBeatmapProcessor.PostProcess"/>.
+        /// <see cref="IApplicableToBeatmap"/> runs after post-processing, while <see cref="IApplicableToHitObject"/> runs before it.
+        /// </remarks>
+        public void ApplyToBeatmap(IBeatmap beatmap)
+        {
+            foreach (var hitObject in beatmap.HitObjects)
+                applyToHitObject(hitObject);
+        }
+
+        private void applyToHitObject(HitObject hitObject)
+        {
+            var catchObject = (CatchHitObject)hitObject;
+
+            switch (catchObject)
+            {
+                case Fruit fruit:
+                    mirrorEffectiveX(fruit);
+                    break;
+
+                case JuiceStream juiceStream:
+                    mirrorEffectiveX(juiceStream);
+                    mirrorJuiceStreamPath(juiceStream);
+                    break;
+
+                case BananaShower bananaShower:
+                    mirrorBananaShower(bananaShower);
+                    break;
+            }
+        }
+
+        /// <summary>
+        /// Mirrors the effective X position of <paramref name="catchObject"/> and its nested hit objects.
+        /// </summary>
+        private static void mirrorEffectiveX(CatchHitObject catchObject)
+        {
+            catchObject.OriginalX = CatchPlayfield.WIDTH - catchObject.OriginalX;
+            catchObject.XOffset = -catchObject.XOffset;
+
+            foreach (var nested in catchObject.NestedHitObjects.Cast<CatchHitObject>())
+            {
+                nested.OriginalX = CatchPlayfield.WIDTH - nested.OriginalX;
+                nested.XOffset = -nested.XOffset;
+            }
+        }
+
+        /// <summary>
+        /// Mirrors the path of the <paramref name="juiceStream"/>.
+        /// </summary>
+        private static void mirrorJuiceStreamPath(JuiceStream juiceStream)
+        {
+            var controlPoints = juiceStream.Path.ControlPoints.Select(p => new PathControlPoint(p.Position.Value, p.Type.Value)).ToArray();
+            foreach (var point in controlPoints)
+                point.Position.Value = new Vector2(-point.Position.Value.X, point.Position.Value.Y);
+
+            juiceStream.Path = new SliderPath(controlPoints, juiceStream.Path.ExpectedDistance.Value);
+        }
+
+        /// <summary>
+        /// Mirrors X positions of all bananas in the <paramref name="bananaShower"/>.
+        /// </summary>
+        private static void mirrorBananaShower(BananaShower bananaShower)
+        {
+            foreach (var banana in bananaShower.NestedHitObjects.OfType<Banana>())
+                banana.XOffset = CatchPlayfield.WIDTH - banana.XOffset;
+        }
+    }
+}