Skip to content
Shiny.Net.HttpServer v1 - A lightweight feature rich HTTP Server - Tunnels, Websockets, AOT, ASPNET Featureset, & Works EVERYWHERE!Let me see!

ShinyButton

A button that knows what it is doing: a leading and a trailing icon slot, a real working state, and success/error states, all wired to the theme and — on MAUI — to its Command.

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

MAUI (iOS)

Appearance × Type Motion icons in the slots All three busy modes at once
Appearance and Type combinations on MAUI Motion icons in the leading and trailing slots ReplaceLeftIcon, ReplaceContent, KeepContent and the native spinner, all busy
Success state, command state Shapes & sizes
Success state, plus a button disabled by its consumer Pill, square, small, large and full-width buttons

Blazor

Appearance × Type Motion icons Busy modes Success
Appearance and Type combinations on Blazor Motion icons in the slots on Blazor All busy modes on Blazor Success state on Blazor

Microsoft.Maui.Controls.Button renders text and one image. There is no way to put a spinner inside it, so the most ordinary interaction in an app — press a button, wait for the network, see whether it worked — gets hand-assembled on every page out of a Grid, an ActivityIndicator, a swapped label and an IsBusy property on the view model that exists only to drive the UI.

ShinyButton is that assembly, done once:

<!-- SaveCommand is an AsyncRelayCommand. Nothing here binds IsBusy - there is no IsBusy. -->
<shiny:ShinyButton Text="Save"
BusyText="Saving..."
LeftMotionIcon="download"
Command="{Binding SaveCommand}" />
<ContentPage xmlns:shiny="http://shiny.net/maui/controls">
<VerticalStackLayout Padding="24" Spacing="12">
<!-- Appearance is emphasis, Type is meaning; they are independent -->
<shiny:ShinyButton Text="Save" Appearance="Filled" Type="Primary" />
<shiny:ShinyButton Text="Delete" Appearance="Outlined" Type="Critical" LeftMotionIcon="trash" />
<shiny:ShinyButton Text="Cancel" Appearance="Text" />
<!-- An explicit colour always wins over the tokens -->
<shiny:ShinyButton Text="Brand" ButtonBackgroundColor="#E91E63" TextColor="White" />
<!-- Icon slots take an image, a motion icon name, or any View -->
<shiny:ShinyButton Text="Notifications" LeftMotionIcon="bell" Appearance="Tonal" />
<shiny:ShinyButton Text="Continue" RightMotionIcon="arrow-right" />
<shiny:ShinyButton Text="Attach" LeftIcon="clip.png" />
<!-- Icon-only. Give it a description - there is no label to read out. -->
<shiny:ShinyButton LeftMotionIcon="settings"
CornerRadius="22"
ContentPadding="11"
SemanticProperties.Description="Settings" />
</VerticalStackLayout>
</ContentPage>

ButtonState is Normal, Busy, Success or Error. Each non-normal state can stand in its own text and its own icon, and Success/Error return to Normal on their own after StateRevertDelay (1.5s by default; TimeSpan.Zero holds).

<shiny:ShinyButton Text="Submit"
State="{Binding SubmitState}"
BusyText="Submitting..."
SuccessText="Submitted"
ErrorText="Failed"
SuccessMotionIcon="check"
ErrorMotionIcon="warning"
StateRevertDelay="0:0:2"
Command="{Binding SubmitCommand}" />

IsBusy is a shorthand projection for a view model that only has a flag:

<shiny:ShinyButton Text="Refresh" IsBusy="{Binding IsRefreshing}" />

Setting IsBusy false only unwinds Busy — it will not cut a Success or Error short. That matters more than it sounds: a view model clearing its busy flag in a finally block is exactly the moment the outcome is on screen, and the naive projection would wipe the tick before anyone saw it.

See States & Commands for the full state machine and the MAUI command integration.

Appearance is how much of the button is painted; Type is what it means. Keeping them orthogonal is what lets a destructive action be loud (Filled + Critical) or quiet (Text + Critical) without an enum member for every pairing.

Appearance Background Foreground Stroke
Filled (default) {Type} On{Type} none
Tonal {Type}Container On{Type}Container none
Outlined transparent {Type} Outline, 1px
Text transparent {Type} none
Elevated SurfaceContainerLow {Type} none, plus shadow

Type is Primary (default), Secondary, Success, Warning, Critical or Info.

Everything resolves through SetDynamicResource against the theme tokens, so ShinyThemeManager.SetTheme restyles a live button. Any explicit colour property (ButtonBackgroundColor, TextColor, BorderColor, IconColor) short-circuits its token — which means an explicit colour survives every theme swap. Leave them unset unless you mean to pin the colour.

Each side takes three forms, in ascending order of precedence:

Property Takes
LeftIcon / RightIcon An ImageSource
LeftMotionIcon / RightMotionIcon The name of a motion icon
LeftIconView / RightIconView Any View — a BadgeView, an avatar, a control of your own

Motion icons are the path worth taking. The button colours them from its own resolved foreground and plays one cycle on tap, so they follow the button through disabled, hover and theme changes with nothing to wire up:

<shiny:ShinyButton Text="Refresh feed"
LeftMotionIcon="refresh"
RightMotionIcon="chevron-down"
Appearance="Outlined" />

A MAUI Image cannot be tinted, so IconColor reaches a FontImageSource glyph and a motion icon but leaves a PNG whatever colour it was drawn.

The button owns that playback on both hosts: the slot icons sit on MotionTrigger.Manual and the button plays them from its own tap, so a tap anywhere on the button animates them rather than only one that lands on the glyph. Set MotionIconPlayOnClick="false" to leave them still.

ContentLayout is Sides (default), Top or Bottom — named for where the icons sit relative to the text, for tile-style buttons.

<shiny:ShinyButton Text="Upload" LeftMotionIcon="upload" ContentLayout="Top" IconSize="28" />

BusyMode decides what the working state does to the content.

Mode Behaviour
ReplaceLeftIcon (default) The indicator takes the left icon’s place and the text stays put. Both are IconSize square, so the button cannot change width and a row of buttons cannot reflow.
ReplaceContent The content fades to opacity zero — keeping its layout space, so the button holds exactly the width it had — and a centred indicator takes over.
KeepContent The indicator appears after the right icon and nothing else moves.

The indicator is, in order: BusyIconView if you set one, a motion icon if BusyMotionIcon is set (default loader), or a platform ActivityIndicator if you clear BusyMotionIcon.

ReplaceContent uses opacity rather than visibility deliberately. Hiding the content would collapse the button to the width of the spinner and shove the rest of the row sideways mid-operation; keeping it laid out but invisible pins the width with no measuring on your part.

TextText, TextColor?, FontSize (15), FontFamily?, FontAttributes, CharacterSpacing, LineBreakMode (NoWrap).

SurfaceAppearance (Filled), Type (Primary), ButtonBackgroundColor?, BorderColor?, BorderThickness (-1, meaning the appearance decides), CornerRadius (10), ContentPadding (16,10), HasShadow (bool?, null = the appearance decides), DisabledOpacity (0.38), PressedOpacity (0.6).

IconsLeftIcon?, RightIcon?, LeftMotionIcon?, RightMotionIcon?, LeftIconView?, RightIconView?, IconSize (20), IconColor?, IconSpacing (8), ContentLayout (Sides), MotionIconPlayOnClick (true), MotionIconStrokeWidth (2).

StateState (TwoWay), IsBusy (TwoWay), BusyMode (ReplaceLeftIcon), BusyText?, SuccessText?, ErrorText?, BusyMotionIcon ("loader"), BusyIconView?, SuccessMotionIcon ("check"), ErrorMotionIcon ("warning"), SuccessIcon?, ErrorIcon?, StateRevertDelay (1.5s), DisableWhileBusy (true), AutoBusy (true), ShowErrorOnFault (true).

CommandCommand?, CommandParameter?, UseFeedback (true).

Events: Clicked, and StateChanged carrying From and To.

The button keeps one stable AutomationId across state changes and moves only SemanticProperties.Description, because MAUI’s AutomationId throws if it is assigned twice — a button that relabels itself cannot use it to carry the current text. Motion icons inside the button are removed from the accessibility tree so a screen reader does not announce the label twice.

Set a SemanticProperties.Description on any icon-only button. On Blazor, the rendered element is a real <button type="button">, so keyboard activation, focus ring and disabled come for free; use aria-label there.

claude plugin marketplace add shinyorg/skills
claude plugin install shiny-client@shiny
BLE, GPS, Jobs, Notifications, Push, HTTP Transfers, OBD, Music, Health, DataSync — iOS, Android, Windows, MacOS, Linux, Web
claude plugin install shiny-maui@shiny
Shell, Contact Store
claude plugin install controls@shiny
TableView, BottomSheet, PillView, ImageViewer, Scheduler, Markdown, Mermaid Diagrams — MAUI and Blazor
claude plugin install shiny-mediator@shiny
Mediator/CQRS with middleware and source generators
claude plugin install shiny-data@shiny
DocumentDB and Spatial data libraries
claude plugin install shiny-aspire@shiny
Orleans and Gluetun Aspire integrations
claude plugin install shiny-extensions@shiny
DI, Stores, Reflector, Localization, Hosting modules
copilot plugin marketplace add https://github.com/shinyorg/skills
copilot plugin install shiny-client@shiny
BLE, GPS, Jobs, Notifications, Push, HTTP Transfers, OBD, Music, Health, DataSync — iOS, Android, Windows, MacOS, Linux, Web
copilot plugin install shiny-maui@shiny
Shell, Contact Store
copilot plugin install controls@shiny
TableView, BottomSheet, PillView, ImageViewer, Scheduler, Markdown, Mermaid Diagrams — MAUI and Blazor
copilot plugin install shiny-mediator@shiny
Mediator/CQRS with middleware and source generators
copilot plugin install shiny-data@shiny
DocumentDB and Spatial data libraries
copilot plugin install shiny-aspire@shiny
Orleans and Gluetun Aspire integrations
copilot plugin install shiny-extensions@shiny
DI, Stores, Reflector, Localization, Hosting modules
View Skills Repository