diff --git a/samples/ControlCatalog/MainView.xaml b/samples/ControlCatalog/MainView.xaml
index c8c496b50c..b6249fe17f 100644
--- a/samples/ControlCatalog/MainView.xaml
+++ b/samples/ControlCatalog/MainView.xaml
@@ -161,6 +161,9 @@
+
+
+
diff --git a/samples/ControlCatalog/Pages/PipsPager/PipsPagerCarouselPage.xaml b/samples/ControlCatalog/Pages/PipsPager/PipsPagerCarouselPage.xaml
new file mode 100644
index 0000000000..b75b5c37c2
--- /dev/null
+++ b/samples/ControlCatalog/Pages/PipsPager/PipsPagerCarouselPage.xaml
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/PipsPager/PipsPagerCarouselPage.xaml.cs b/samples/ControlCatalog/Pages/PipsPager/PipsPagerCarouselPage.xaml.cs
new file mode 100644
index 0000000000..f42bb10ce9
--- /dev/null
+++ b/samples/ControlCatalog/Pages/PipsPager/PipsPagerCarouselPage.xaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace ControlCatalog.Pages;
+
+public partial class PipsPagerCarouselPage : UserControl
+{
+ public PipsPagerCarouselPage()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomButtonsPage.xaml b/samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomButtonsPage.xaml
new file mode 100644
index 0000000000..8b9856424d
--- /dev/null
+++ b/samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomButtonsPage.xaml
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomButtonsPage.xaml.cs b/samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomButtonsPage.xaml.cs
new file mode 100644
index 0000000000..4fc74995bc
--- /dev/null
+++ b/samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomButtonsPage.xaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace ControlCatalog.Pages;
+
+public partial class PipsPagerCustomButtonsPage : UserControl
+{
+ public PipsPagerCustomButtonsPage()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomColorsPage.xaml b/samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomColorsPage.xaml
new file mode 100644
index 0000000000..260536d7ae
--- /dev/null
+++ b/samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomColorsPage.xaml
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomColorsPage.xaml.cs b/samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomColorsPage.xaml.cs
new file mode 100644
index 0000000000..a9276f11b0
--- /dev/null
+++ b/samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomColorsPage.xaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace ControlCatalog.Pages;
+
+public partial class PipsPagerCustomColorsPage : UserControl
+{
+ public PipsPagerCustomColorsPage()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomTemplatesPage.xaml b/samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomTemplatesPage.xaml
new file mode 100644
index 0000000000..fe748b248d
--- /dev/null
+++ b/samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomTemplatesPage.xaml
@@ -0,0 +1,197 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomTemplatesPage.xaml.cs b/samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomTemplatesPage.xaml.cs
new file mode 100644
index 0000000000..cce9e6c5e5
--- /dev/null
+++ b/samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomTemplatesPage.xaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace ControlCatalog.Pages;
+
+public partial class PipsPagerCustomTemplatesPage : UserControl
+{
+ public PipsPagerCustomTemplatesPage()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/samples/ControlCatalog/Pages/PipsPager/PipsPagerEventsPage.xaml b/samples/ControlCatalog/Pages/PipsPager/PipsPagerEventsPage.xaml
new file mode 100644
index 0000000000..a69c101687
--- /dev/null
+++ b/samples/ControlCatalog/Pages/PipsPager/PipsPagerEventsPage.xaml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/PipsPager/PipsPagerEventsPage.xaml.cs b/samples/ControlCatalog/Pages/PipsPager/PipsPagerEventsPage.xaml.cs
new file mode 100644
index 0000000000..d97165397a
--- /dev/null
+++ b/samples/ControlCatalog/Pages/PipsPager/PipsPagerEventsPage.xaml.cs
@@ -0,0 +1,29 @@
+using System.Collections.ObjectModel;
+using Avalonia.Controls;
+
+namespace ControlCatalog.Pages;
+
+public partial class PipsPagerEventsPage : UserControl
+{
+ private readonly ObservableCollection _events = new();
+
+ public PipsPagerEventsPage()
+ {
+ InitializeComponent();
+
+ EventLog.ItemsSource = _events;
+
+ EventPager.PropertyChanged += (_, e) =>
+ {
+ if (e.Property != PipsPager.SelectedPageIndexProperty)
+ return;
+
+ var newIndex = (int)e.NewValue!;
+ StatusText.Text = $"Selected: {newIndex}";
+ _events.Insert(0, $"SelectedPageIndex changed to {newIndex}");
+
+ if (_events.Count > 20)
+ _events.RemoveAt(_events.Count - 1);
+ };
+ }
+}
diff --git a/samples/ControlCatalog/Pages/PipsPager/PipsPagerGettingStartedPage.xaml b/samples/ControlCatalog/Pages/PipsPager/PipsPagerGettingStartedPage.xaml
new file mode 100644
index 0000000000..5eead2fb31
--- /dev/null
+++ b/samples/ControlCatalog/Pages/PipsPager/PipsPagerGettingStartedPage.xaml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/PipsPager/PipsPagerGettingStartedPage.xaml.cs b/samples/ControlCatalog/Pages/PipsPager/PipsPagerGettingStartedPage.xaml.cs
new file mode 100644
index 0000000000..80a1569f30
--- /dev/null
+++ b/samples/ControlCatalog/Pages/PipsPager/PipsPagerGettingStartedPage.xaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace ControlCatalog.Pages;
+
+public partial class PipsPagerGettingStartedPage : UserControl
+{
+ public PipsPagerGettingStartedPage()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/samples/ControlCatalog/Pages/PipsPager/PipsPagerLargeCollectionPage.xaml b/samples/ControlCatalog/Pages/PipsPager/PipsPagerLargeCollectionPage.xaml
new file mode 100644
index 0000000000..5cc416d413
--- /dev/null
+++ b/samples/ControlCatalog/Pages/PipsPager/PipsPagerLargeCollectionPage.xaml
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/PipsPager/PipsPagerLargeCollectionPage.xaml.cs b/samples/ControlCatalog/Pages/PipsPager/PipsPagerLargeCollectionPage.xaml.cs
new file mode 100644
index 0000000000..2dc936b544
--- /dev/null
+++ b/samples/ControlCatalog/Pages/PipsPager/PipsPagerLargeCollectionPage.xaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace ControlCatalog.Pages;
+
+public partial class PipsPagerLargeCollectionPage : UserControl
+{
+ public PipsPagerLargeCollectionPage()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/samples/ControlCatalog/Pages/PipsPagerPage.xaml b/samples/ControlCatalog/Pages/PipsPagerPage.xaml
new file mode 100644
index 0000000000..54112daae0
--- /dev/null
+++ b/samples/ControlCatalog/Pages/PipsPagerPage.xaml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/PipsPagerPage.xaml.cs b/samples/ControlCatalog/Pages/PipsPagerPage.xaml.cs
new file mode 100644
index 0000000000..8f27cc61f8
--- /dev/null
+++ b/samples/ControlCatalog/Pages/PipsPagerPage.xaml.cs
@@ -0,0 +1,47 @@
+using System;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+
+namespace ControlCatalog.Pages
+{
+ public partial class PipsPagerPage : UserControl
+ {
+ private static readonly (string Group, string Title, string Description, Func Factory)[] Demos =
+ {
+ ("Getting Started", "First Look",
+ "Default PipsPager with horizontal and vertical orientation, with and without navigation buttons.",
+ () => new PipsPagerGettingStartedPage()),
+
+ ("Features", "Carousel Integration",
+ "Bind SelectedPageIndex to a Carousel's SelectedIndex for two-way synchronized page navigation.",
+ () => new PipsPagerCarouselPage()),
+ ("Features", "Large Collections",
+ "Use MaxVisiblePips to limit visible indicators when the page count is large. Pips scroll automatically.",
+ () => new PipsPagerLargeCollectionPage()),
+ ("Features", "Events",
+ "Monitor SelectedPageIndex changes to react to user navigation.",
+ () => new PipsPagerEventsPage()),
+
+ ("Appearance", "Custom Colors",
+ "Override pip indicator colors using resource keys for normal, selected, and hover states.",
+ () => new PipsPagerCustomColorsPage()),
+ ("Appearance", "Custom Buttons",
+ "Replace the default chevron navigation buttons with custom styled buttons.",
+ () => new PipsPagerCustomButtonsPage()),
+ ("Appearance", "Custom Templates",
+ "Override pip item templates to create squares, pills, numbers, or any custom shape.",
+ () => new PipsPagerCustomTemplatesPage()),
+ };
+
+ public PipsPagerPage()
+ {
+ InitializeComponent();
+ Loaded += OnLoaded;
+ }
+
+ private async void OnLoaded(object? sender, RoutedEventArgs e)
+ {
+ await SampleNav.PushAsync(NavigationDemoHelper.CreateGalleryHomePage(SampleNav, Demos), null);
+ }
+ }
+}
diff --git a/src/Avalonia.Controls/Automation/Peers/PipsPagerAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/PipsPagerAutomationPeer.cs
new file mode 100644
index 0000000000..b40a9b4159
--- /dev/null
+++ b/src/Avalonia.Controls/Automation/Peers/PipsPagerAutomationPeer.cs
@@ -0,0 +1,85 @@
+using System.Collections.Generic;
+using Avalonia.Automation.Provider;
+using Avalonia.Controls;
+
+namespace Avalonia.Automation.Peers
+{
+ ///
+ /// An automation peer for .
+ ///
+ public class PipsPagerAutomationPeer : ControlAutomationPeer, ISelectionProvider
+ {
+ private ListBox? _pipsList;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The control associated with this peer.
+ public PipsPagerAutomationPeer(PipsPager owner) : base(owner)
+ {
+ owner.SelectedIndexChanged += OnSelectionChanged;
+ }
+
+ ///
+ /// Gets the owner as a .
+ ///
+ private new PipsPager Owner => (PipsPager)base.Owner;
+
+ ///
+ public bool CanSelectMultiple => false;
+
+ ///
+ public bool IsSelectionRequired => true;
+
+ ///
+ public IReadOnlyList GetSelection()
+ {
+ var result = new List();
+ var owner = Owner;
+
+ if (owner.SelectedPageIndex >= 0 && owner.SelectedPageIndex < owner.NumberOfPages)
+ {
+ _pipsList ??= owner.FindNameScope()?.Find("PART_PipsPagerList");
+
+ if (_pipsList != null)
+ {
+ var container = _pipsList.ContainerFromIndex(owner.SelectedPageIndex);
+ if (container is Control c)
+ {
+ var peer = GetOrCreate(c);
+ result.Add(peer);
+ }
+ }
+ }
+
+ return result;
+ }
+
+ ///
+ protected override AutomationControlType GetAutomationControlTypeCore()
+ {
+ return AutomationControlType.List;
+ }
+
+ ///
+ protected override string GetClassNameCore()
+ {
+ return nameof(PipsPager);
+ }
+
+ ///
+ protected override string? GetNameCore()
+ {
+ var name = base.GetNameCore();
+ return string.IsNullOrWhiteSpace(name) ? "Pips Pager" : name;
+ }
+
+ private void OnSelectionChanged(object? sender, Controls.PipsPagerSelectedIndexChangedEventArgs e)
+ {
+ RaisePropertyChangedEvent(
+ SelectionPatternIdentifiers.SelectionProperty,
+ e.OldIndex,
+ e.NewIndex);
+ }
+ }
+}
diff --git a/src/Avalonia.Controls/PipsPager/PipsPager.cs b/src/Avalonia.Controls/PipsPager/PipsPager.cs
new file mode 100644
index 0000000000..b976df4826
--- /dev/null
+++ b/src/Avalonia.Controls/PipsPager/PipsPager.cs
@@ -0,0 +1,662 @@
+using System;
+using System.Threading;
+using Avalonia.Threading;
+using Avalonia.Controls.Metadata;
+using Avalonia.Automation;
+using Avalonia.Automation.Peers;
+using Avalonia.Controls.Primitives;
+using Avalonia.Data;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Layout;
+using Avalonia.Styling;
+using System.Collections.Generic;
+
+namespace Avalonia.Controls
+{
+ ///
+ /// Represents a control that lets the user navigate through a paginated collection using a set of pips.
+ ///
+ [TemplatePart(PART_PreviousButton, typeof(Button))]
+ [TemplatePart(PART_NextButton, typeof(Button))]
+ [TemplatePart(PART_PipsPagerList, typeof(ListBox))]
+ [PseudoClasses(PC_FirstPage, PC_LastPage, PC_Vertical, PC_Horizontal)]
+ public class PipsPager : TemplatedControl
+ {
+ private const string PART_PreviousButton = "PART_PreviousButton";
+ private const string PART_NextButton = "PART_NextButton";
+ private const string PART_PipsPagerList = "PART_PipsPagerList";
+
+ private const string PC_FirstPage = ":first-page";
+ private const string PC_LastPage = ":last-page";
+ private const string PC_Vertical = ":vertical";
+ private const string PC_Horizontal = ":horizontal";
+
+ private Button? _previousButton;
+ private Button? _nextButton;
+ private ListBox? _pipsPagerList;
+ private bool _scrollPending;
+ private bool _updatingPagerSize;
+ private bool _isInitialLoad;
+ private int _lastSelectedPageIndex;
+ private CancellationTokenSource? _scrollAnimationCts;
+ private PipsPagerTemplateSettings _templateSettings = new PipsPagerTemplateSettings();
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty MaxVisiblePipsProperty =
+ AvaloniaProperty.Register(nameof(MaxVisiblePips), 5);
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty IsNextButtonVisibleProperty =
+ AvaloniaProperty.Register(nameof(IsNextButtonVisible), true);
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty NumberOfPagesProperty =
+ AvaloniaProperty.Register(nameof(NumberOfPages));
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty OrientationProperty =
+ AvaloniaProperty.Register(nameof(Orientation), Orientation.Horizontal);
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty IsPreviousButtonVisibleProperty =
+ AvaloniaProperty.Register(nameof(IsPreviousButtonVisible), true);
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty SelectedPageIndexProperty =
+ AvaloniaProperty.Register(nameof(SelectedPageIndex),
+ defaultBindingMode: BindingMode.TwoWay);
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly DirectProperty TemplateSettingsProperty =
+ AvaloniaProperty.RegisterDirect(nameof(TemplateSettings),
+ x => x.TemplateSettings);
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty PreviousButtonStyleProperty =
+ AvaloniaProperty.Register(nameof(PreviousButtonStyle));
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty NextButtonStyleProperty =
+ AvaloniaProperty.Register(nameof(NextButtonStyle));
+
+ ///
+ /// Defines the event.
+ ///
+ public static readonly RoutedEvent SelectedIndexChangedEvent =
+ RoutedEvent.Register(nameof(SelectedIndexChanged), RoutingStrategies.Bubble);
+
+ ///
+ /// Occurs when the selected index has changed.
+ ///
+ public event EventHandler? SelectedIndexChanged
+ {
+ add => AddHandler(SelectedIndexChangedEvent, value);
+ remove => RemoveHandler(SelectedIndexChangedEvent, value);
+ }
+
+ static PipsPager()
+ {
+ SelectedPageIndexProperty.Changed.AddClassHandler((x, e) => x.OnSelectedPageIndexChanged(e));
+ NumberOfPagesProperty.Changed.AddClassHandler((x, e) => x.OnNumberOfPagesChanged(e));
+ IsPreviousButtonVisibleProperty.Changed.AddClassHandler((x, e) => x.OnIsPreviousButtonVisibleChanged(e));
+ IsNextButtonVisibleProperty.Changed.AddClassHandler((x, e) => x.OnIsNextButtonVisibleChanged(e));
+ OrientationProperty.Changed.AddClassHandler((x, e) => x.OnOrientationChanged(e));
+ MaxVisiblePipsProperty.Changed.AddClassHandler((x, e) => x.OnMaxVisiblePipsChanged(e));
+ }
+
+ ///
+ /// Initializes a new instance of .
+ ///
+ public PipsPager()
+ {
+ UpdatePseudoClasses();
+ }
+
+ ///
+ /// Gets or sets the maximum number of visible pips.
+ ///
+ public int MaxVisiblePips
+ {
+ get => GetValue(MaxVisiblePipsProperty);
+ set => SetValue(MaxVisiblePipsProperty, value);
+ }
+
+ ///
+ /// Gets or sets the visibility of the next button.
+ ///
+ public bool IsNextButtonVisible
+ {
+ get => GetValue(IsNextButtonVisibleProperty);
+ set => SetValue(IsNextButtonVisibleProperty, value);
+ }
+
+ ///
+ /// Gets or sets the number of pages.
+ ///
+ public int NumberOfPages
+ {
+ get => GetValue(NumberOfPagesProperty);
+ set => SetValue(NumberOfPagesProperty, value);
+ }
+
+ ///
+ /// Gets or sets the orientation of the pips.
+ ///
+ public Orientation Orientation
+ {
+ get => GetValue(OrientationProperty);
+ set => SetValue(OrientationProperty, value);
+ }
+
+ ///
+ /// Gets or sets the visibility of the previous button.
+ ///
+ public bool IsPreviousButtonVisible
+ {
+ get => GetValue(IsPreviousButtonVisibleProperty);
+ set => SetValue(IsPreviousButtonVisibleProperty, value);
+ }
+
+ ///
+ /// Gets or sets the current selected page index.
+ ///
+ public int SelectedPageIndex
+ {
+ get => GetValue(SelectedPageIndexProperty);
+ set => SetValue(SelectedPageIndexProperty, value);
+ }
+
+ ///
+ /// Gets the template settings.
+ ///
+ public PipsPagerTemplateSettings TemplateSettings
+ {
+ get => _templateSettings;
+ private set => SetAndRaise(TemplateSettingsProperty, ref _templateSettings, value);
+ }
+
+ ///
+ /// Gets or sets the style for the previous button.
+ ///
+ public ControlTheme? PreviousButtonStyle
+ {
+ get => GetValue(PreviousButtonStyleProperty);
+ set => SetValue(PreviousButtonStyleProperty, value);
+ }
+
+ ///
+ /// Gets or sets the style for the next button.
+ ///
+ public ControlTheme? NextButtonStyle
+ {
+ get => GetValue(NextButtonStyleProperty);
+ set => SetValue(NextButtonStyleProperty, value);
+ }
+
+ ///
+ protected override AutomationPeer OnCreateAutomationPeer()
+ {
+ return new PipsPagerAutomationPeer(this);
+ }
+
+ ///
+ protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
+ {
+ base.OnApplyTemplate(e);
+
+ _scrollAnimationCts?.Cancel();
+ _scrollAnimationCts?.Dispose();
+ _scrollAnimationCts = null;
+ _isInitialLoad = true;
+
+ // Unsubscribe from previous button events
+ if (_previousButton != null)
+ {
+ _previousButton.Click -= PreviousButton_Click;
+ }
+
+ if (_nextButton != null)
+ {
+ _nextButton.Click -= NextButton_Click;
+ }
+
+ // Unsubscribe from previous list events
+ if (_pipsPagerList != null)
+ {
+ _pipsPagerList.SizeChanged -= OnPipsPagerListSizeChanged;
+ _pipsPagerList.ContainerPrepared -= OnContainerPrepared;
+ _pipsPagerList.ContainerIndexChanged -= OnContainerIndexChanged;
+ }
+
+ // Get template parts
+ _previousButton = e.NameScope.Find