Sensor-based Parallax in .NET MAUI using the Gyroscope #MAUIUIJuly
Parallax effects add depth and interactivity to your app's UI by making different layers move at different speeds to simulate 3D depth. In this article, we will cover how to implement a flexible parallax system in your .NET MAUI app.

Intro
For this article, we will use the Gyroscope sensor. If you've missed it, I wrote an article about all the sensors and the values we can get from them :

Many people have requested this feature to be added to MAUI over the years, and I still couldn't find any nice example ... So, I made one. As simple as I could:

And here is how it looks like:
The Parallax Layer: The Foundation
A ParallaxLayer is a custom view that applies translation effects to its child content based on parallax offset values. Each layer moves independently to simulate depth.
Key features:
- Attaches to a
ParallaxOffsetSource
(the driver of the effect)- Adds itself to the Source's list of Listeners
- Configurable maximum movement distances for X and Y axes
- A negative maximum movement distance allows you to have a reverse Parallax effect for background layers, see below.
- Listens for offset changes, updates its translation accordingly
/// <summary>
/// A content view that applies parallax translation effects to its child content.
/// </summary>
public class ParallaxLayer : ContentView, IParallaxOffsetListener
{
/// <summary>
/// Initializes a new instance of the ParallaxLayer class.
/// </summary>
public ParallaxLayer()
{
VerticalOptions = LayoutOptions.Fill;
HorizontalOptions = LayoutOptions.Fill;
}
/// <summary>
/// Gets or sets the current horizontal parallax offset value.
/// </summary>
public double ParallaxX { get; set; } = 0.0;
/// <summary>
/// Gets or sets the current vertical parallax offset value.
/// </summary>
public double ParallaxY { get; set; } = 0.0;
/// <summary>
/// Called when the parallax offset source provides new offset values.
/// </summary>
/// <param name="x">The horizontal offset value between -1 and 1.</param>
/// <param name="y">The vertical offset value between -1 and 1.</param>
public void OnParallaxOffsetChanged(double x, double y)
{
ParallaxX = x;
ParallaxY = y;
UpdateTranslation();
}
/// <summary>
/// Updates the translation transform based on current parallax values and maximum distances.
/// </summary>
public void UpdateTranslation()
{
// Calculate translation based on parallax offset and maximum distance
TranslationX = ParallaxX * ParallaxMaxDistanceX;
TranslationY = ParallaxY * ParallaxMaxDistanceY;
}
/// <summary>
/// Gets or sets the parallax offset source that provides the parallax data.
/// </summary>
public ParallaxOffsetSource? ParallaxOffsetSource
{
get => (ParallaxOffsetSource?)GetValue(ParallaxOffsetSourceProperty);
set => SetValue(ParallaxOffsetSourceProperty, value);
}
/// <summary>
/// Bindable property for ParallaxOffsetSource.
/// </summary>
public readonly static BindableProperty ParallaxOffsetSourceProperty =
BindableProperty.Create(nameof(ParallaxOffsetSource), typeof(ParallaxOffsetSource), typeof(ParallaxLayer), null,
propertyChanged: OnParallaxOffsetSourceChanged);
/// <summary>
/// Gets or sets the maximum horizontal distance for parallax movement in pixels.
/// </summary>
public double ParallaxMaxDistanceX
{
get => (double)GetValue(ParallaxMaxDistanceXProperty);
set => SetValue(ParallaxMaxDistanceXProperty, value);
}
/// <summary>
/// Bindable property for ParallaxMaxDistanceX.
/// </summary>
public readonly static BindableProperty ParallaxMaxDistanceXProperty =
BindableProperty.Create(nameof(ParallaxMaxDistanceX), typeof(double), typeof(ParallaxLayer), 10.0,
propertyChanged: OnParallaxDistanceChanged);
/// <summary>
/// Gets or sets the maximum vertical distance for parallax movement in pixels.
/// </summary>
public double ParallaxMaxDistanceY
{
get => (double)GetValue(ParallaxMaxDistanceYProperty);
set => SetValue(ParallaxMaxDistanceYProperty, value);
}
/// <summary>
/// Bindable property for ParallaxMaxDistanceY.
/// </summary>
public readonly static BindableProperty ParallaxMaxDistanceYProperty =
BindableProperty.Create(nameof(ParallaxMaxDistanceY), typeof(double), typeof(ParallaxLayer), 10.0,
propertyChanged: OnParallaxDistanceChanged);
/// <summary>
/// Handles changes to the parallax distance properties.
/// </summary>
private static void OnParallaxDistanceChanged(BindableObject bindable, object oldValue, object newValue)
{
if (bindable is ParallaxLayer layer)
{
layer.UpdateTranslation();
}
}
/// <summary>
/// Handles changes to the ParallaxOffsetSource property.
/// </summary>
private static void OnParallaxOffsetSourceChanged(BindableObject bindable, object oldValue, object newValue)
{
if (bindable is not ParallaxLayer layer)
return;
// Remove listener from old source
if (oldValue is ParallaxOffsetSource oldSource)
{
oldSource.RemoveListener(layer);
}
// Add listener to new source
if (newValue is ParallaxOffsetSource newSource)
{
newSource.AddListener(layer);
}
}
}
Example usage:
<views:ParallaxLayer ParallaxOffsetSource="{StaticResource SharedParallaxSource}"
ParallaxMaxDistanceX="40"
ParallaxMaxDistanceY="40">
<!– Your content here -->
</views:ParallaxLayer>
Bonus: I have extracted a IParallaxOffsetListener
:
/// <summary>
/// Interface for objects that can listen to parallax offset changes.
/// </summary>
public interface IParallaxOffsetListener
{
/// <summary>
/// Called when the parallax offset values change.
/// The X and Y values will be between -1 and 1, representing the parallax effect.
/// </summary>
/// <param name="x">The horizontal offset value between -1 and 1.</param>
/// <param name="y">The vertical offset value between -1 and 1.</param>
void OnParallaxOffsetChanged(double x, double y);
}
This allows you to also apply a parallax effect on your own elements, without the need to put it inside a Layer.
The Parallax Source: The controller
Now that we have a ParallaxLayer
that moves, we need to tell it how to move. The ParallaxOffsetSource
is there for that.
Regardless of the source for the X and Y values, the Sources should call NotifyListeners(double x, double y)
with the new values.
/// <summary>
/// Abstract base class for parallax offset sources that provide X and Y values from -1 to 1.
/// </summary>
public abstract class ParallaxOffsetSource : BindableObject
{
private List<IParallaxOffsetListener> Listeners { get; } = new List<IParallaxOffsetListener>();
public virtual void AddListener(IParallaxOffsetListener listener)
{
if (listener == null)
throw new ArgumentNullException(nameof(listener));
lock (Listeners)
{
// Ensure the listener is not already added
if (Listeners.Contains(listener))
return;
// Add the listener to the list
Listeners.Add(listener);
}
}
public virtual void RemoveListener(IParallaxOffsetListener listener)
{
if (listener == null)
throw new ArgumentNullException(nameof(listener));
lock (Listeners)
{
Listeners.Remove(listener);
}
}
// Useful for cumulative calculation and debug
public double CurrentOffsetX { get; private set; }
public double CurrentOffsetY { get; private set; }
/// <summary>
/// Notifies all listeners about the new parallax offset.
/// </summary>
protected void NotifyListeners(double x, double y)
{
x = Math.Clamp(x, -1, 1);
y = Math.Clamp(y, -1, 1);
lock (Listeners)
{
foreach (var listener in Listeners)
{
listener.OnParallaxOffsetChanged(x, y);
}
}
CurrentOffsetX = x;
CurrentOffsetY = y;
OnPropertyChanged(nameof(CurrentOffsetX));
OnPropertyChanged(nameof(CurrentOffsetY));
}
}
Manual Control: Bindable Parallax Source
For demos, sliders, or custom animations, you may want to control the parallax offset directly. The ParallaxOffsetFromBindingSource
class exposes bindable properties for X and Y offsets, making it easy to connect to UI controls.
How it works:
- Exposes
ParallaxXValue
andParallaxYValue
as bindable properties - Notifies all registered layers when values change
- Values are clamped between -1 and 1
/// <summary>
/// Parallax offset source that uses bindable properties for X and Y values.
/// </summary>
public class ParallaxOffsetFromBindingSource : ParallaxOffsetSource
{
/// <summary>
/// Initializes a new instance of the ParallaxOffsetFromBindingSource.
/// </summary>
public ParallaxOffsetFromBindingSource()
{
}
/// <summary>
/// Gets or sets the X offset value for parallax effect.
/// </summary>
public double ParallaxXValue
{
get => (double)GetValue(ParallaxXValueProperty);
set => SetValue(ParallaxXValueProperty, value);
}
/// <summary>
/// Bindable property for ParallaxXValue.
/// </summary>
public readonly static BindableProperty ParallaxXValueProperty =
BindableProperty.Create(nameof(ParallaxXValue), typeof(double), typeof(ParallaxOffsetFromBindingSource), 0.0, propertyChanged: OnParallaxXValueChanged);
/// <summary>
/// Gets or sets the Y offset value for parallax effect.
/// </summary>
public double ParallaxYValue
{
get => (double)GetValue(ParallaxYValueProperty);
set => SetValue(ParallaxYValueProperty, value);
}
/// <summary>
/// Bindable property for ParallaxYValue.
/// </summary>
public readonly static BindableProperty ParallaxYValueProperty =
BindableProperty.Create(nameof(ParallaxYValue), typeof(double), typeof(ParallaxOffsetFromBindingSource), 0.0, propertyChanged: OnParallaxYValueChanged);
/// <summary>
/// Handles changes to the ParallaxXValue property.
/// </summary>
protected static void OnParallaxXValueChanged(BindableObject bindable, object oldValue, object newValue)
{
if (bindable is ParallaxOffsetFromBindingSource source)
{
source.OnParallaxValueChanged();
}
}
/// <summary>
/// Handles changes to the ParallaxYValue property.
/// </summary>
protected static void OnParallaxYValueChanged(BindableObject bindable, object oldValue, object newValue)
{
if (bindable is ParallaxOffsetFromBindingSource source)
{
source.OnParallaxValueChanged();
}
}
/// <summary>
/// Notifies all listeners when either X or Y values change.
/// </summary>
protected void OnParallaxValueChanged()
{
NotifyListeners(ParallaxXValue, ParallaxYValue);
}
}
Example XAML :
<ContentPage.Resources>
<ResourceDictionary>
<views:ParallaxOffsetFromBindingSource x:Key="SharedParallaxSource" />
</ResourceDictionary>
</ContentPage.Resources>
<!-- Sliders bound to the parallax source -->
<Slider Value="{Binding Source={StaticResource SharedParallaxSource}, Path=ParallaxXValue, Mode=TwoWay}" />
<Slider Value="{Binding Source={StaticResource SharedParallaxSource}, Path=ParallaxYValue, Mode=TwoWay}" />
This approach is perfect for tutorials, design-time tweaking, or letting users control the effect.
Sensor-Driven Parallax: Gyroscope Integration
For a more immersive experience, you can drive the parallax effect using the device's gyroscope. The ParallaxOffsetFromGyroscopeSource
class listens to real-time gyroscope data and updates the parallax offset accordingly.
Features:
- Uses a
GyroscopeSensorService
to receive angular velocity updates - Supports a
Multiplier
property to control sensitivity - Optional
IsCumulative
modefalse
: The acceleration (and its return to 0 on idle) will be the sourcetrue
: The motion adds up, making the effect smoother
But the question is, which value from our sensor do we want to take? Well, let's check our post from yesterday:
Seems like we want:
- The bottom one (Y - Pitch) for the horizontal movement
- The right one (X - Roll) for the vertical movement
How it works:
- The gyroscope measures device rotation speed
- The parallax source converts this data into X/Y offsets
/// <summary>
/// Parallax offset source that uses gyroscope sensor data to create movement effects.
/// </summary>
public class ParallaxOffsetFromGyroscopeSource : ParallaxOffsetSource, IDisposable
{
private readonly GyroscopeSensorService _gyroscopeSensorService;
public ParallaxOffsetFromGyroscopeSource()
{
_gyroscopeSensorService = MauiProgram.Services?.GetRequiredService<GyroscopeSensorService>() ?? throw new InvalidOperationException("GyroscopeSensorService is not registered in the service collection.");
_gyroscopeSensorService.AddListener(OnGyroscopeDataChanged);
}
public void Dispose()
{
_gyroscopeSensorService.RemoveListener(OnGyroscopeDataChanged);
}
private void OnGyroscopeDataChanged(GyroscopeData update)
{
double x = 0, y = 0;
float horizontalMovement = update.AngularVelocity.Y; // Pitch
float verticalMovement = update.AngularVelocity.X; // Roll
if (IsCumulative)
{
// If cumulative, add the new values to the existing offset
x = CurrentOffsetX + horizontalMovement * Multiplier;
y = CurrentOffsetY + verticalMovement * Multiplier;
}
else
{
// If not cumulative, just use the new values directly
x = horizontalMovement * Multiplier;
y = verticalMovement * Multiplier;
}
NotifyListeners(x, y);
}
/// <summary>
/// Gets or sets whether the gyroscope values are cumulative or direct.
/// </summary>
public bool IsCumulative
{
get => (bool)GetValue(IsCumulativeProperty);
set => SetValue(IsCumulativeProperty, value);
}
/// <summary>
/// Bindable property for IsCumulative.
/// </summary>
public readonly static BindableProperty IsCumulativeProperty = BindableProperty.Create(nameof(IsCumulative), typeof(bool), typeof(ParallaxOffsetFromGyroscopeSource), false);
/// <summary>
/// Gets or sets the multiplier for gyroscope values.
/// </summary>
public double Multiplier
{
get => (double)GetValue(ParallaxGyroscopeMultiplierProperty);
set => SetValue(ParallaxGyroscopeMultiplierProperty, value);
}
/// <summary>
/// Bindable property for Multiplier.
/// </summary>
public readonly static BindableProperty ParallaxGyroscopeMultiplierProperty = BindableProperty.Create(nameof(Multiplier), typeof(double), typeof(ParallaxOffsetFromGyroscopeSource), 0.2d);
}
Example XAML:
<ContentPage.Resources>
<ResourceDictionary>
<views:ParallaxOffsetFromGyroscopeSource x:Key="SharedParallaxSource" />
</ResourceDictionary>
</ContentPage.Resources>
<!-- Bind UI controls to adjust gyroscope behavior -->
<Slider Value="{Binding Source={StaticResource SharedParallaxSource}, Path=Multiplier, Mode=TwoWay}" />
<Switch IsToggled="{Binding Source={StaticResource SharedParallaxSource}, Path=IsCumulative, Mode=TwoWay}" />
Conclusion
Implementing parallax in .NET MAUI can be easy and straightforward. By separating the concept of a parallax layer from the source of its movement, you can:
- Manually control effects for demos or user input
- Leverage device sensors for immersive, real-world interaction
- Easily extend or combine sources for new effects
We could now add many sources:
- Touch / Mouse-hover: X/Y based on touch or cursor position on screen
- Compass: Horizontal only parallax
- Scroll: As the scroll view scrolls, Vertical only parallax
Experiment with different layer arrangements, movement ranges, and input sources to create unique, engaging UIs in your MAUI apps!
Check out my MAUI playground for a full sample:
Here is the code of the sample in the videos:
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:views="clr-namespace:Maui_Developer_Sample.Pages.UI.Views"
x:Class="Maui_Developer_Sample.Pages.UI.ParallaxGyroscope_Page"
Title="Parallax Gyroscope">
<ContentPage.Resources>
<ResourceDictionary>
<!-- Shared parallax offset source -->
<views:ParallaxOffsetFromGyroscopeSource x:Key="SharedParallaxSource" />
</ResourceDictionary>
</ContentPage.Resources>
<ContentPage.Content>
<Grid RowDefinitions="Auto, 1*">
<!-- Control Panel -->
<Border Grid.Row="0"
BackgroundColor="{AppThemeBinding Light={StaticResource Gray100}, Dark={StaticResource Gray900}}"
Stroke="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray600}}"
StrokeThickness="1"
Margin="10"
Padding="15">
<VerticalStackLayout Spacing="10">
<Label Text="Parallax Controls"
FontAttributes="Bold"
FontSize="16"
HorizontalOptions="Center" />
<!-- Gyroscope Multiplier -->
<Grid ColumnDefinitions="Auto, 1*, Auto"
ColumnSpacing="10">
<Label Grid.Column="0"
Text="Gyroscope Multiplier:"
VerticalOptions="Center"
MinimumWidthRequest="20" />
<Slider Grid.Column="1"
Minimum="0"
Maximum="1"
x:DataType="{x:Type views:ParallaxOffsetFromGyroscopeSource}"
Value="{Binding Source={StaticResource SharedParallaxSource}, Path=Multiplier, Mode=TwoWay}"
ThumbColor="{StaticResource Primary}" />
<Label Grid.Column="2"
x:DataType="{x:Type views:ParallaxOffsetFromGyroscopeSource}"
Text="{Binding Source={StaticResource SharedParallaxSource}, Path=Multiplier, StringFormat='{0:F2}'}"
VerticalOptions="Center"
MinimumWidthRequest="40" />
</Grid>
<!-- IsCumulative -->
<Grid ColumnDefinitions="Auto, 1*"
ColumnSpacing="10">
<Label Text="Cumulative Movement:"
VerticalOptions="Center"
MinimumWidthRequest="20" />
<Switch Grid.Column="1"
x:DataType="{x:Type views:ParallaxOffsetFromGyroscopeSource}"
IsToggled="{Binding Source={StaticResource SharedParallaxSource}, Path=IsCumulative, Mode=TwoWay}"
ThumbColor="{StaticResource Primary}" />
</Grid>
</VerticalStackLayout>
</Border>
<!-- Parallax Demo Area -->
<Border Grid.Row="3"
BackgroundColor="{AppThemeBinding Light={StaticResource Gray100}, Dark={StaticResource Gray950}}"
Stroke="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}"
StrokeThickness="2"
Margin="10">
<Grid>
<!-- Background Layer - Opposite movement -->
<views:ParallaxLayer ParallaxOffsetSource="{StaticResource SharedParallaxSource}"
ParallaxMaxDistanceX="-40"
ParallaxMaxDistanceY="-40"
Scale="1.2"
ZIndex="-1">
<Image Source="hike.jpg"
Aspect="AspectFill"
HorizontalOptions="Fill"
VerticalOptions="Fill" />
</views:ParallaxLayer>
<!-- Far Layer - Slowest movement -->
<views:ParallaxLayer ParallaxOffsetSource="{StaticResource SharedParallaxSource}"
ParallaxMaxDistanceX="20"
ParallaxMaxDistanceY="20"
ZIndex="1">
<Ellipse Fill="{StaticResource Tertiary}"
Opacity="0.3"
WidthRequest="200"
HeightRequest="200"
HorizontalOptions="Center"
VerticalOptions="Center" />
</views:ParallaxLayer>
<!-- Middle Layer - Medium movement -->
<views:ParallaxLayer ParallaxOffsetSource="{StaticResource SharedParallaxSource}"
ParallaxMaxDistanceX="40"
ParallaxMaxDistanceY="40"
ZIndex="2">
<Border BackgroundColor="{StaticResource Primary}"
Stroke="{StaticResource Secondary}"
StrokeThickness="3"
WidthRequest="120"
HeightRequest="120"
HorizontalOptions="Center"
VerticalOptions="Center">
<Border.StrokeShape>
<RoundRectangle CornerRadius="15" />
</Border.StrokeShape>
<Label Text="Layer 2"
TextColor="White"
FontAttributes="Bold"
HorizontalOptions="Center"
VerticalOptions="Center" />
</Border>
</views:ParallaxLayer>
<!-- Foreground Layer - Fastest movement -->
<views:ParallaxLayer ParallaxOffsetSource="{StaticResource SharedParallaxSource}"
ParallaxMaxDistanceX="80"
ParallaxMaxDistanceY="80"
ZIndex="3">
<Border BackgroundColor="{StaticResource Secondary}"
Stroke="{StaticResource Primary}"
StrokeThickness="2"
WidthRequest="80"
HeightRequest="80"
HorizontalOptions="Center"
VerticalOptions="Center">
<Border.StrokeShape>
<Ellipse />
</Border.StrokeShape>
<Label Text="Layer 3"
TextColor="White"
FontSize="10"
FontAttributes="Bold"
HorizontalOptions="Center"
VerticalOptions="Center" />
</Border>
</views:ParallaxLayer>
<!-- Additional decorative elements -->
<views:ParallaxLayer ParallaxOffsetSource="{StaticResource SharedParallaxSource}"
ParallaxMaxDistanceX="60"
ParallaxMaxDistanceY="30"
ZIndex="4">
<StackLayout Orientation="Horizontal"
Spacing="30"
HorizontalOptions="Center"
VerticalOptions="End"
Margin="0,0,0,50">
<BoxView BackgroundColor="{StaticResource Tertiary}"
WidthRequest="20"
HeightRequest="40" />
<BoxView BackgroundColor="{StaticResource Tertiary}"
WidthRequest="20"
HeightRequest="60" />
<BoxView BackgroundColor="{StaticResource Tertiary}"
WidthRequest="20"
HeightRequest="30" />
<BoxView BackgroundColor="{StaticResource Tertiary}"
WidthRequest="20"
HeightRequest="50" />
</StackLayout>
</views:ParallaxLayer>
<!-- Info overlay that doesn't move -->
<Border
BackgroundColor="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Black}}"
Stroke="{StaticResource Primary}"
StrokeThickness="1"
Opacity="0.9"
HorizontalOptions="Start"
VerticalOptions="Start"
Margin="20"
Padding="10"
ZIndex="10">
<Border.StrokeShape>
<RoundRectangle CornerRadius="5" />
</Border.StrokeShape>
<VerticalStackLayout Spacing="2">
<Label Text="Layer Info:"
FontAttributes="Bold"
FontSize="10" />
<Label Text="• Background: -40px distance"
FontSize="9" />
<Label Text="• Circle: 20px distance"
FontSize="9" />
<Label Text="• Square: 40px distance"
FontSize="9" />
<Label Text="• Small Circle: 80px distance"
FontSize="9" />
<Label Text="• Bars: 60px X, 30px Y"
FontSize="9" />
</VerticalStackLayout>
</Border>
</Grid>
</Border>
</Grid>
</ContentPage.Content>
</ContentPage>