Skip to main content

How to: Work with ScrollViewer

This guide covers common ScrollViewer scenarios including controlling scroll behavior, programmatic scrolling, responding to scroll events, and handling nested scroll regions.

Basic usage

Wrap any content that may exceed the available space inside a ScrollViewer:

<ScrollViewer>
<StackPanel Spacing="8">
<!-- Content that may be taller than the viewport -->
<TextBlock Text="Item 1" />
<TextBlock Text="Item 2" />
<!-- ... many more items ... -->
</StackPanel>
</ScrollViewer>

ScrollViewer shows scrollbars automatically when its content overflows the visible area.

tip

A ScrollViewer cannot be placed inside a container that offers infinite height or width in the scrolling direction, such as a StackPanel. If you do this, the ScrollViewer will never detect overflow because its parent gives it unlimited space. Use a container with constrained dimensions (for example, Grid, DockPanel, or a fixed Height/MaxHeight) instead.

Scrollbar visibility

You control when each scrollbar appears by setting HorizontalScrollBarVisibility and VerticalScrollBarVisibility:

<!-- Always show vertical scrollbar, never show horizontal -->
<ScrollViewer VerticalScrollBarVisibility="Visible"
HorizontalScrollBarVisibility="Disabled">
<TextBlock Text="{Binding LongText}" TextWrapping="Wrap" />
</ScrollViewer>
ValueBehavior
AutoShows the scrollbar only when content overflows (default for vertical)
VisibleAlways shows the scrollbar, even when content fits
HiddenHides the scrollbar but still allows scrolling via touch, mouse wheel, or keyboard
DisabledDisables scrolling in that direction entirely
note

If you set HorizontalScrollBarVisibility to Disabled (the default), content wider than the viewport will be clipped. Set it to Auto or Visible when you need horizontal scrolling.

Programmatic scrolling

Scroll to a specific position

Set the Offset property to jump directly to a position:

// Scroll to 500 pixels from the top
scrollViewer.Offset = new Vector(0, 500);

The Offset is measured in device-independent pixels. The value is clamped automatically, so setting a value beyond the scrollable range scrolls to the end rather than throwing an exception.

Scroll to top or bottom

// Scroll to top
scrollViewer.Offset = new Vector(scrollViewer.Offset.X, 0);

// Scroll to bottom
scrollViewer.Offset = new Vector(
scrollViewer.Offset.X,
scrollViewer.Extent.Height - scrollViewer.Viewport.Height);

Bring a child element into view

Use BringIntoView on a child control to scroll just enough to make it visible. This is especially useful when you know the target control but not its exact position:

targetControl.BringIntoView();

You can also specify a rectangle relative to the target control:

targetControl.BringIntoView(new Rect(0, 0, targetControl.Bounds.Width, targetControl.Bounds.Height));
tip

BringIntoView works with virtualized panels as well. When you call it on an item inside an ItemsControl that uses virtualization, the panel materializes the item first and then scrolls to it.

Respond to scroll events

Monitor scroll position changes

Subscribe to the ScrollChanged event to react when the user scrolls:

scrollViewer.ScrollChanged += (sender, e) =>
{
var offset = scrollViewer.Offset;
var extent = scrollViewer.Extent;
var viewport = scrollViewer.Viewport;

// Check if scrolled to bottom (with a 1-pixel tolerance)
var isAtBottom = offset.Y >= extent.Height - viewport.Height - 1;

if (isAtBottom)
{
LoadMoreItems();
}
};

Observe the offset property

For reactive-style code, observe the Offset property directly:

scrollViewer.GetObservable(ScrollViewer.OffsetProperty).Subscribe(offset =>
{
Debug.WriteLine($"Scrolled to: {offset.Y}");
});

This approach integrates well with Avalonia's reactive property system and fires on every offset change, including programmatic ones.

Implement infinite scrolling

A common pattern is to load more content as your user scrolls near the bottom. Combine a scroll position check with an async data-loading method:

public partial class InfiniteListViewModel : ObservableObject
{
private int _page = 0;
private bool _isLoading;

public ObservableCollection<Item> Items { get; } = new();

public async Task LoadMoreAsync()
{
if (_isLoading) return;
_isLoading = true;

try
{
var newItems = await _api.GetItemsAsync(_page++, pageSize: 20);
foreach (var item in newItems)
Items.Add(item);
}
finally
{
_isLoading = false;
}
}
}

In your code-behind, trigger loading when the user scrolls within a threshold of the bottom:

private async void OnScrollChanged(object? sender, ScrollChangedEventArgs e)
{
if (sender is not ScrollViewer sv) return;

var distanceFromBottom = sv.Extent.Height - sv.Viewport.Height - sv.Offset.Y;
if (distanceFromBottom < 100)
{
await ((InfiniteListViewModel)DataContext!).LoadMoreAsync();
}
}
note

The threshold value (100 pixels in this example) controls how early loading begins. A larger threshold gives your data source more time to respond before the user reaches the end, creating a smoother experience.

Handle nested ScrollViewers

When you nest scrollable content, disable the inner scroll direction that the outer ScrollViewer already handles. This prevents the two scroll regions from competing for the same input:

<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="16">
<TextBlock Text="Section 1" FontSize="20" />

<!-- Inner horizontal scroll only -->
<ScrollViewer HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Disabled">
<StackPanel Orientation="Horizontal" Spacing="8">
<Border Width="200" Height="150" Background="Red" />
<Border Width="200" Height="150" Background="Blue" />
<Border Width="200" Height="150" Background="Green" />
</StackPanel>
</ScrollViewer>

<TextBlock Text="Section 2" FontSize="20" />
<!-- More content... -->
</StackPanel>
</ScrollViewer>

If the inner control can scroll in the same direction as the outer one, you can control whether scroll events "chain" up to the parent by setting the ScrollViewer.IsScrollChainingEnabled attached property on the inner control:

<!-- Prevent inner scroll from chaining to the outer ScrollViewer -->
<ListBox ScrollViewer.IsScrollChainingEnabled="False"
Height="200"
ItemsSource="{Binding InnerItems}" />

Use scroll snap points

Enable snap points for carousel-like scrolling:

<ScrollViewer HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Disabled"
IsScrollChainingEnabled="True">
<StackPanel Orientation="Horizontal" Spacing="16">
<!-- Cards that snap into view -->
<Border Width="300" Height="200" Background="#6366F1" CornerRadius="8" />
<Border Width="300" Height="200" Background="#8B5CF6" CornerRadius="8" />
<Border Width="300" Height="200" Background="#A78BFA" CornerRadius="8" />
</StackPanel>
</ScrollViewer>

Create a sticky header layout

Use a Grid to keep a header fixed while content scrolls beneath it:

<Grid RowDefinitions="Auto,*">
<!-- Fixed header -->
<Border Grid.Row="0" Background="White" Padding="16"
ZIndex="1" BoxShadow="0 2 4 0 #20000000">
<TextBlock Text="Fixed Header" FontWeight="Bold" />
</Border>

<!-- Scrollable content -->
<ScrollViewer Grid.Row="1">
<StackPanel Spacing="8" Margin="16">
<!-- Your scrollable content here -->
</StackPanel>
</ScrollViewer>
</Grid>

This pattern keeps the header visible at all times. The ZIndex on the header Border ensures it renders above the scrollable content if they overlap during transitions or animations.

Key properties

PropertyTypeDescription
OffsetVectorCurrent scroll position (X, Y)
ExtentSizeTotal size of the scrollable content
ViewportSizeSize of the visible area
HorizontalScrollBarVisibilityScrollBarVisibilityControls horizontal scrollbar behavior
VerticalScrollBarVisibilityScrollBarVisibilityControls vertical scrollbar behavior
AllowAutoHideboolWhether scrollbars auto-hide after a period of inactivity (default true)
IsScrollChainingEnabledboolWhether scroll events chain to parent scroll regions

See also