Modal
ModalView is a modal window: a titled panel over a backdrop that owns the screen until it is dismissed. Every region is optional and replaceable — the header, the close button, the body and the footer — and what it does not leave to you is the modal contract itself: focus moves in and is trapped, the page behind stops scrolling, Escape and the backdrop dismiss, focus goes back where it came from.
Features
Section titled “Features”- Configurable header — a
Title(plusSubtitleandIcon), your ownHeaderTemplate, or no header at all - Configurable close button — the built-in ✕, your own
CloseButtonTemplate, or none - Content template — anything you like, scrolling in the body while the header and footer stay pinned
- Configurable footer — a list of
ModalButtonrendered as themed ShinyButtons, or aFooterTemplate - Events —
Opened, cancellableClosing, andClosedcarrying what dismissed it - A window when you want one — drag by the header, resize from a corner grip, maximise and restore
- Sizes, placement, motion — five sizes, three placements, six animations, a blurrable backdrop
- Accessible by default — focus trap, focus restore, scroll lock,
role="dialog" aria-modal="true" - Stacks — a modal can open another; only the topmost answers Escape
No DI registration and no host component — ModalView renders where you put it.
@* _Imports.razor *@@using Shiny.Blazor.Controls<button @onclick="() => showEdit = true">Edit customer</button>
<ModalView @bind-IsOpen="showEdit" Title="Edit customer" Subtitle="Changes apply immediately" Size="ModalSize.Large" Buttons="@buttons" Closing="OnClosing" Closed="@(reason => status = $"closed by {reason}")"> <EditForm Model="customer"> <InputText @bind-Value="customer.Name" data-shiny-autofocus /> </EditForm></ModalView>
@code { bool showEdit; string? status;
// A field, not an expression: a ModalButton carries state (State, Disabled) that a list rebuilt // on every render would throw away. readonly List<ModalButton> buttons = [];
protected override void OnInitialized() => this.buttons.AddRange( [ new("Cancel") { Type = ButtonType.Secondary, Appearance = ButtonAppearance.Text }, new("Save") { ClosesModal = false, OnClick = SaveAsync } ]);
async Task SaveAsync() { var save = this.buttons[1]; save.State = ButtonState.Busy; // the modal stays up while the work runs StateHasChanged();
await this.api.SaveAsync(this.customer);
save.State = ButtonState.Normal; this.showEdit = false; }
// The dirty-form veto. Every dismissal route goes through it. void OnClosing(ModalClosingEventArgs e) => e.Cancel = this.customer.IsDirty && e.Reason != ModalCloseReason.Button;}From code
Section titled “From code”<ModalView @ref="modal" Title="Pick a date">…</ModalView>
@code { ModalView? modal;
async Task Run() { await this.modal!.ShowAsync();
// false when Closing vetoed it, or it was already closed var closed = await this.modal.CloseAsync();
await this.modal.ToggleAsync(); await this.modal.SetMaximizedAsync(true); }}The three regions
Section titled “The three regions”Each one has a built-in form, a template that replaces it, and an off switch.
| Region | Built in | Yours | Off |
|---|---|---|---|
| Header | Title, Subtitle, Icon |
HeaderTemplate — replaces the title block but keeps the bar, so the close button still has a home |
ShowHeader="false" |
| Close | the ✕ button | CloseButtonTemplate — already wired, no handler needed |
ShowCloseButton="false" |
| Footer | Buttons |
FooterTemplate — wins over Buttons |
set neither |
<ModalView @bind-IsOpen="open" AriaLabel="Invite teammates"> <HeaderTemplate> <div class="invite-header"> <Avatar Url="@user.Avatar" /> <div>Invite teammates</div> </div> </HeaderTemplate> <CloseButtonTemplate> <span class="link">Dismiss</span> </CloseButtonTemplate> <ChildContent> <input data-shiny-autofocus placeholder="ada@example.com" /> </ChildContent> <FooterTemplate> <a href="#">Copy invite link</a> <button @onclick="Send">Send invites</button> </FooterTemplate></ModalView>Events
Section titled “Events”| Event | Type | When |
|---|---|---|
Opened |
EventCallback |
The panel is up and focus is inside it |
Closing |
EventCallback<ModalClosingEventArgs> |
Before anything unwinds. Set Cancel to keep it open |
Closed |
EventCallback<ModalCloseReason> |
After it has left the screen |
IsOpenChanged |
EventCallback<bool> |
Two-way binding hook |
ModalCloseReason is CloseButton, Backdrop, Escape, Button (a footer button with ClosesModal) or Programmatic.
Buttons
Section titled “Buttons”new ModalButton("Delete project"){ Type = ButtonType.Critical, // Primary/Secondary/Success/Warning/Critical/Info Appearance = ButtonAppearance.Filled, // Filled/Tonal/Outlined/Text/Elevated Icon = "<svg …>", // inline SVG, before the text Disabled = false, State = ButtonState.Normal, // flip to Busy from OnClick ClosesModal = true, // false keeps the modal up while work runs OnClick = DeleteAsync, // awaited before the close Tag = project // whatever a shared handler needs back}OnClick is awaited before the close runs, so “save then close” needs no IsOpen juggling. Mutating a button in place needs a StateHasChanged unless the handler that changed it is the one returning.
Sizing, placement and motion
Section titled “Sizing, placement and motion”| Parameter | Type | Default | Notes |
|---|---|---|---|
Size |
ModalSize |
Medium |
Small 360 · Medium 520 · Large 760 · ExtraLarge 1080 · Full. Caps, not fixed widths, so a phone still gets a full-width panel |
Placement |
ModalPlacement |
Center |
Center, Top, Bottom |
Width / Height / MaxWidth / MaxHeight |
string? |
— | CSS. Width beats Size |
ScrollBody |
bool |
true |
Body scrolls, header and footer stay pinned |
Animation |
ModalAnimation |
Pop |
None, Fade, Zoom, Pop, SlideTop, SlideBottom |
AnimationDuration |
int |
200 |
Milliseconds |
ShowBackdrop / BackdropOpacity / BlurBackdrop |
bool / double / bool |
true / 0.45 / false |
With no backdrop the click surface stays, invisible |
CornerRadius / Background / ContentPadding / CssClass |
string? |
— | CSS; bare numbers in padding are read as pixels |
Window behaviour
Section titled “Window behaviour”<ModalView @bind-IsOpen="open" Title="Query editor" Draggable="true" Resizable="true" ShowMaximizeButton="true" MaximizeOnHeaderDoubleClick="true" @bind-IsMaximized="maximized"> …</ModalView>| Parameter | Default | Notes |
|---|---|---|
Draggable |
false |
Move by the header. Header buttons are excluded from the drag surface, so grabbing near one still presses it |
Resizable |
false |
Bottom-right grip |
AllowMaximize |
false |
The capability on its own — a window that maximises on double-click but carries no button |
ShowMaximizeButton |
false |
The header button. Implies AllowMaximize |
MaximizeOnHeaderDoubleClick |
true |
Only acts when maximising is allowed, so it costs nothing to leave on |
IsMaximized |
false |
Two-way bindable |
Maximising drops any drag offset and resized size, so restoring lands where the stylesheet says rather than wherever the panel happened to be.
Dismissal
Section titled “Dismissal”| Parameter | Default | Notes |
|---|---|---|
CloseOnBackdropClick |
true |
|
CloseOnEscape |
true |
Only the topmost modal answers |
NudgeOnBlockedDismiss |
true |
A refused backdrop click shoves the panel, so it reads as “no” rather than as a dead click |
An undismissable modal — the only way out is a button you provide:
<ModalView @bind-IsOpen="open" ShowHeader="false" ShowCloseButton="false" CloseOnBackdropClick="false" CloseOnEscape="false" AriaLabel="Finish setup" Buttons="@[new ModalButton("Get started")]"> …</ModalView>Accessibility
Section titled “Accessibility”| Parameter | Default | Notes |
|---|---|---|
AutoFocus |
true |
Focuses the first focusable element, or whatever carries data-shiny-autofocus |
TrapFocus |
true |
Tab stays inside the panel |
RestoreFocus |
true |
Focus returns to whatever had it when the modal opened |
LockScroll |
true |
The page behind stops scrolling, and the scrollbar’s width is replaced with padding so the layout does not lurch sideways |
AriaLabel / AriaDescribedBy |
— | The panel is labelled by its own Title when it has one |
Stacking
Section titled “Stacking”A modal can open another. The newest sits on top, Escape reaches only the topmost, and the page’s scrollbar comes back only when the last one closes.
<ModalView @bind-IsOpen="settingsOpen" Title="Settings"> <button @onclick="() => confirmOpen = true">Reset everything…</button></ModalView>
<ModalView @bind-IsOpen="confirmOpen" Title="Are you sure?" Size="ModalSize.Small"> …</ModalView>Step 1 — Add the marketplace:
claude plugin marketplace add shinyorg/skillsStep 2 — Install the plugin:
claude plugin install shiny@shinyOne 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.
Step 1 — Add the marketplace:
copilot plugin marketplace add https://github.com/shinyorg/skillsStep 2 — Install the plugin:
copilot plugin install shiny@shinyOne 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.


