Skip to content
Shiny Controls v1.0 - The Ultra Control Suite for .NET MAUI & BlazorO...M...G!

ImageViewer

A full-screen image overlay with pinch-to-zoom, pan when zoomed, double-tap to toggle zoom, animated open/close transitions, and a close button.

  • NuGet downloads for Shiny.Maui.Controls
  • NuGet downloads for Shiny.Blazor.Controls
Frameworks
.NET MAUI
Blazor

MAUI

Gallery Viewer
Full-screen viewer zoomed and panned Image gallery grid

Blazor

Thumbnail grid Full-screen, zoomed and panned
Thumbnail grid on Blazor Full-screen, zoomed and panned on Blazor
<Grid>
<!-- Page content with tappable images -->
<ScrollView>
<VerticalStackLayout>
<Image Source="photo.png">
<Image.GestureRecognizers>
<TapGestureRecognizer Command="{Binding OpenViewerCommand}"
CommandParameter="photo.png" />
</Image.GestureRecognizers>
</Image>
</VerticalStackLayout>
</ScrollView>
<!-- ImageViewer overlays on top -->
<shiny:ImageViewer Source="{Binding SelectedImage}"
IsOpen="{Binding IsViewerOpen}" />
</Grid>

The thumbnail and the full-screen overlay are both a ShinyImage, so binding Uri instead of Source brings the whole loading pipeline with it — placeholder artwork, a loading ring that fills to a real percentage, error artwork, and IImageService memory + disk caching with a bounded download queue and de-duplication.

<shiny:ImageViewer Uri="{Binding PhotoUrl}"
PlaceholderImage="blur_thumb.png"
ErrorImage="broken_image.png"
Aspect="AspectFill"
RingSize="32"
HeightRequest="180" />

Use Uri for anything that comes off a server and Source for streams, embedded resources and font images. Source wins when both are set.

Three things follow from there being two images:

  • The overlay is empty until it opens. Populating it up front would decode a second full-size bitmap for every viewer in a list. It is filled on open, from the same URI, so it comes back off the memory cache the thumbnail already warmed — there is no second download and no reason to preload it yourself.
  • ImageLoaded and ImageFailed fire once, from the thumbnail. The overlay loading the same URI on open is not a second event.
  • State, Progress, IsLoading and LoadError mirror the thumbnail — the copy that is always in the visual tree, so they still report something useful while the viewer is closed.
Property Type Default Description
Uri string? null The image to load. http/https goes through IImageService; anything else is treated as a local file or bundled resource (MAUI only)
Source ImageSource? null An explicit source. Takes precedence over Uri and skips the service
IsOpen bool false Show/hide the viewer (TwoWay bindable)
Aspect Aspect AspectFit Thumbnail aspect ratio mode (MAUI only)
OverlayAspect Aspect AspectFit Aspect ratio mode inside the overlay (MAUI only)
MaxZoom double 5.0 Maximum pinch zoom scale
CloseButtonTemplate DataTemplate? null Custom close button template (tapping closes the viewer)
HeaderTemplate DataTemplate? null Custom header overlay at the top of the viewer
FooterTemplate DataTemplate? null Custom footer overlay at the bottom of the viewer
OpenViewerOnTap bool true Whether tapping the thumbnail opens the overlay
UseFeedback bool true Haptic click on double-tap zoom
Property Type Default Description
PlaceholderImage ImageSource? null Artwork shown before and during the load, behind the ring
ErrorImage ImageSource? null Artwork shown when the load fails. Ignored when ErrorTemplate is set
LoadingTemplate DataTemplate? null Replaces the ring. Binding context is the live ImageLoadProgress
ErrorTemplate DataTemplate? null Replaces the error artwork
FadeInDuration uint 150 Milliseconds each image fades in over once loaded. 0 shows it instantly
RingSize double 48 Diameter of the loading ring
RingColor Color? null Progress arc colour; null uses the theme Primary token
RingTrackColor Color? null Unfilled track; null uses SurfaceContainerHighest
ProgressTextColor Color? null Percentage label; null uses OnSurface
ShowProgressText bool true Draw the percentage inside the ring. Never shown when indeterminate
CacheEnabled bool true Whether this image participates in the memory and disk caches
CacheDuration TimeSpan? null Overrides ImageOptions.DiskCacheDuration for this image
State ImageLoadState None Read-only: None, Queued, Downloading, Loaded, Failed
Progress ImageLoadProgress Read-only live snapshot
IsLoading bool false Read-only: true while Queued or Downloading
LoadError Exception? null Read-only: why the last load failed
ImageLoadedCommand ICommand? null Invoked with ImageLoadedEventArgs once on screen
ImageFailedCommand ICommand? null Invoked with the exception when a load fails

Events: ImageLoaded and ImageFailed. Methods: ReloadAsync() re-fetches, skipping both cache tiers; assigning ImageService overrides the resolved service for one instance.

  • Pinch-to-zoom — Two-finger pinch scales around the pinch origin, clamped between 1x and MaxZoom
  • Pan when zoomed — One-finger pan enabled after zooming in, translation clamped to image bounds
  • Double-tap to zoom — Double-tap zooms to 2.5x centered on the tap point; double-tap again resets
  • Animated open/close — Backdrop, image, and close button fade together (250ms)
  • Close button — “✕” button in the top-right corner (customizable via CloseButtonTemplate)
  • Header/Footer templates — Optional overlays at the top/bottom for custom UI (e.g. image info, action buttons)
  • Backdrop — Black overlay that swallows touches so nothing passes through to the page behind
  • Remote loading (MAUI)Uri loads through IImageService with caching, a progress ring, and placeholder/error artwork on both the thumbnail and the overlay
public partial class ImageViewerViewModel : ObservableObject
{
[ObservableProperty] ImageSource? selectedImage;
[ObservableProperty] bool isViewerOpen;
[RelayCommand]
void OpenViewer(string imageSource)
{
SelectedImage = imageSource;
IsViewerOpen = true;
}
}
@using Shiny.Blazor.Controls
<div style="position: relative;">
<!-- Tappable image -->
<img src="photo.jpg" @onclick="() => OpenViewer("photo.jpg")"
style="cursor: pointer;" />
<!-- ImageViewer overlay -->
<ImageViewer Source="@selectedImage"
IsOpen="@isViewerOpen"
IsOpenChanged="v => isViewerOpen = v"
MaxZoom="5.0" />
</div>
@code {
string? selectedImage;
bool isViewerOpen;
void OpenViewer(string imageUrl)
{
selectedImage = imageUrl;
isViewerOpen = true;
}
}
claude plugin marketplace add shinyorg/skills
claude plugin install shiny@shiny

One plugin installs all 35 Shiny skills. Your agent loads only the skill relevant to what you're building, so there's no cost to having them all available.

copilot plugin marketplace add https://github.com/shinyorg/skills
copilot plugin install shiny@shiny

One plugin installs all 35 Shiny skills. Your agent loads only the skill relevant to what you're building, so there's no cost to having them all available.

View shiny-controls Skill