Components
Modal
A real modal <dialog> with a Name, opened by the button inside a Ui.ModalTrigger of that name. No handler runs: the browser gives it the top layer, an inert page behind it, Escape, and focus back on the trigger when it closes, on a prerendered page too.
Drawn and behaving as Flux UI's modal does, measured against its live docs.
Modal
The modal draws the panel and the close button; what it holds is yours. A width in Class replaces its own. OnClose hears every way it closed.
namespace Rask.Site.Features.UiDocs.Examples;
public sealed partial class ModalExample : Component
{
private int _closed;
protected override Component? Render() =>
Div.Class("flex flex-wrap items-center gap-4")[
Ui.ModalTrigger.Name("edit-profile")[Ui.Button["Edit profile"]],
Ui.Modal.Name("edit-profile").Class("md:w-96").OnClose(() => { _closed++; })[
Div.Class("space-y-6")[
Div[
Ui.Heading.Level(2).Lg["Update profile"],
Ui.Text.Class("mt-2")["Make changes to your personal details."]
],
Ui.Input.Of<string>().Label("Name").Placeholder("Your name"),
Ui.Input.Of<string>().Label("Date of birth").Type(InputType.Date),
// Opening this inside the dialog is not the dialog closing.
Details.Data("testid", "ui-modal-more")[
Summary["More"],
P["Opening this does not close the dialog."]
],
Div.Class("flex")[Ui.Spacer, Ui.Button.Primary["Save changes"]]
]
],
Ui.Text[$"Closed {_closed} time{(_closed == 1 ? "" : "s")}."]
];
}Closed 0 times.
Data binding
For when C# decides it should appear: Open is the page's field, Flux's wire:model, and it is the same modal. OnCancel hears a dismissal, Escape or a click outside, before OnClose.
namespace Rask.Site.Features.UiDocs.Examples;
public sealed partial class ModalOpenExample : Component
{
private bool _confirming;
private string _last = "nothing yet";
protected override Component? Render() =>
Div.Class("flex flex-wrap items-center gap-4")[
Ui.Button.Danger.OnClick(() => { _confirming = true; })["Delete order"],
Ui.Modal
.Open(_confirming)
.OnCancel(() => { _last = "dismissed the dialog"; })
.OnClose(() => { _confirming = false; })[
Div.Class("space-y-6")[
Div[
Ui.Heading.Level(2).Lg["Delete order"],
Ui.Text.Class("mt-2")["This cannot be undone."]
],
Div.Class("flex gap-2")[
Ui.Spacer,
Ui.ModalClose[Ui.Button.Ghost["Cancel"]],
Ui.Button.Danger.OnClick(() =>
{
_confirming = false;
_last = "deleted the order";
})["Delete"]
]
]
],
Ui.Text[$"Last action: {_last}."]
];
}Last action: nothing yet.
Disable click outside
Dismissible(false) keeps a stray click outside from losing what is being edited.
namespace Rask.Site.Features.UiDocs.Examples;
public sealed partial class ModalDismissibleExample : Component
{
protected override Component? Render() =>
Div[
Ui.ModalTrigger.Name("demo-filters")[Ui.Button["Filters"]],
Ui.Modal.Name("demo-filters").Flyout().Dismissible(false)[
Div.Class("space-y-6")[
Ui.Heading.Level(2).Lg["Filters"],
Ui.Text["Only the close button, Escape, or Apply closes this one."],
Div.Class("flex")[Ui.Spacer, Ui.ModalClose[Ui.Button.Primary["Apply"]]]
]
]
];
}Disable escape
Escapable(false) takes Escape away; Closable(false) takes the corner button.
namespace Rask.Site.Features.UiDocs.Examples;
public sealed partial class ModalEscapableExample : Component
{
protected override Component? Render() =>
Div[
Ui.ModalTrigger.Name("demo-session")[Ui.Button["Session"]],
Ui.Modal.Name("demo-session").Escapable(false)[
Div.Class("space-y-6")[
Ui.Heading.Level(2).Lg["Session expiring"],
Ui.Text["Escape does not close this one; a click outside, the corner button or Stay does."],
Div.Class("flex")[Ui.Spacer, Ui.ModalClose[Ui.Button.Primary["Stay signed in"]]]
]
]
];
}Scrolling long content
Scroll Body lets a long modal run past the bottom of the screen, so it is plain there is more.
namespace Rask.Site.Features.UiDocs.Examples;
public sealed partial class ModalScrollExample : Component
{
protected override Component? Render() =>
Div[
Ui.ModalTrigger.Name("demo-terms")[Ui.Button["Terms"]],
Ui.Modal.Name("demo-terms").Scroll(Ui.ModalScroll.Body).Class("md:w-lg")[
Div.Class("space-y-6")[
Ui.Heading.Level(2).Lg["Terms of service"],
Enumerable.Range(1, 24)
.Select(n => Ui.Text.Key(n)[$"{n}. The whole layer scrolls, not a box inside it."])
.ToArray(),
Div.Class("flex")[Ui.Spacer, Ui.ModalClose[Ui.Button.Primary["Accept"]]]
]
]
];
}Confirmation
Ask before a dangerous action. Ui.ModalClose makes the button inside it close the modal: a close, not a dismissal, and the button's own handler still runs.
namespace Rask.Site.Features.UiDocs.Examples;
public sealed partial class ModalConfirmationExample : Component
{
private bool _deleted;
protected override Component? Render() =>
Div.Class("flex flex-wrap items-center gap-4")[
Ui.ModalTrigger.Name("delete-profile")[Ui.Button.Danger["Delete"]],
Ui.Modal.Name("delete-profile").Class("min-w-[22rem]")[
Div.Class("space-y-6")[
Div[
Ui.Heading.Level(2).Lg["Delete project?"],
Ui.Text.Class("mt-2")["You're about to delete this project.", Br, "This action cannot be reversed."]
],
Div.Class("flex gap-2")[
Ui.Spacer,
Ui.ModalClose.Key("cancel")[Ui.Button.Ghost["Cancel"]],
// A close, and the button's own handler still runs.
Ui.ModalClose.Key("delete")[
Ui.Button.Danger.OnClick(() => { _deleted = true; })["Delete project"]
]
]
]
],
Ui.Text[_deleted ? "You deleted the project." : "The project is still there."]
];
}The project is still there.
Flyout
Flyout anchors it to an edge at full height, for longer forms.
namespace Rask.Site.Features.UiDocs.Examples;
public sealed partial class ModalFlyoutExample : Component
{
protected override Component? Render() =>
Div[
Ui.ModalTrigger.Name("edit-profile-flyout")[Ui.Button["Edit profile"]],
Ui.Modal.Name("edit-profile-flyout").Flyout().Class("md:w-lg")[
Div.Class("space-y-6")[
Div[
Ui.Heading.Level(2).Lg["Update profile"],
Ui.Text.Class("mt-2")["Make changes to your personal details."]
],
Ui.Input.Of<string>().Label("Name").Placeholder("Your name"),
Ui.Input.Of<string>().Label("Date of birth").Type(InputType.Date),
Div.Class("flex")[Ui.Spacer, Ui.Button.Primary["Save changes"]]
]
]
];
}Flyout positioning
The right edge by default; Left or Bottom when asked.
namespace Rask.Site.Features.UiDocs.Examples;
public sealed partial class ModalFlyoutPositionExample : Component
{
protected override Component? Render() =>
Div.Class("flex flex-wrap gap-2")[
Ui.ModalTrigger.Name("flyout-left")[Ui.Button["From the left"]],
Ui.ModalTrigger.Name("flyout-bottom")[Ui.Button["From the bottom"]],
Ui.Modal.Name("flyout-left").Flyout().Left[
Div.Class("space-y-6")[Ui.Heading.Level(2).Lg["Navigation"], Ui.Text["A flyout from the left edge."]]
],
Ui.Modal.Name("flyout-bottom").Flyout().Bottom[
Div.Class("space-y-6")[Ui.Heading.Level(2).Lg["Share"], Ui.Text["A flyout from the bottom edge."]]
]
];
}Floating flyout
The Floating variant stands the flyout off the edges of the viewport, rounded and shadowed like a panel.
namespace Rask.Site.Features.UiDocs.Examples;
public sealed partial class ModalFloatingExample : Component
{
protected override Component? Render() =>
Div[
Ui.ModalTrigger.Name("edit-profile-floating")[Ui.Button["Edit profile"]],
Ui.Modal.Name("edit-profile-floating").Flyout().Floating.Class("md:w-lg")[
Div.Class("space-y-6")[
Div[
Ui.Heading.Level(2).Lg["Update profile"],
Ui.Text.Class("mt-2")["Make changes to your personal details."]
],
Ui.Input.Of<string>().Label("Name").Placeholder("Your name"),
Ui.Input.Of<string>().Label("Date of birth").Type(InputType.Date),
Div.Class("flex items-center justify-end gap-2")[
Ui.ModalClose[Ui.Button["Cancel"]],
Ui.Button.Primary["Save changes"]
]
]
]
];
}Behaviour
Who opens it
No handler runs. The trigger makes its button an HTML invoker (command="show-modal" commandfor), so the browser supplies the top layer, an inert page behind it so Tab cannot wander out, Escape, and focus handed back to the trigger on close.
What Flux adds in script, the dialog asks Rask's runtime for by attribute: data-rask-modal for a click outside and for which of the two dismissals are allowed, data-rask-lock="scroll" for the page held still behind it, and the invoker commands themselves in a browser that has none (before Chrome 135, Firefox 144 and Safari 26.2).
While a modal is open the page behind it does not scroll, and keeps its scrollbar's gutter where a scrollbar is showing, so it neither shifts sideways nor moves from where it was scrolled to.
Closing, and dismissing
A modal draws the panel and the close button in its corner; what it holds (a heading, fields, a row of buttons) is yours, as in Flux.
Step | What happens |
|---|---|
Dismissible(false) | A click outside does nothing. |
Escapable(false) | Escape does nothing. Alone, it still closes on a click outside: all four combinations of the two switches work. |
Closable(false) | No close button in the corner. |
OnClose | Hears every way it closed. |
OnCancel | Hears only a dismissal, Escape or a click outside, and runs before OnClose: a dialog holding a draft can throw it away when the reader backs out and keep it when they press a button that closes it. The close button and a Ui.ModalClose are not dismissals. |
Both are the dialog's own events: a dismissal raises cancel before close, in every browser.
Width, and the close button
A modal with no width of its own is as wide as what it holds, between Flux's two defaults: at least 20rem and at most 36rem (a side flyout: at least 25rem from md). A width handed to Class replaces them: md:w-96 for a form, and min-w-[22rem] for a confirmation, which is what Flux's own example writes; a short question over two buttons is a narrow box without it.
The close button is 32px, 16px in from the top and from the end edge, at every width. The heading keeps no room for it, in Flux as here: the panel's padding is 24px, so a first line that fills the panel (and a one-line question the modal hugs always does) ends 24px under the button.
Flux's examples avoid that the way the confirmation above does: a short heading, "Delete project?", with the sentence in a Ui.Text under it. Where the heading is the sentence, Ui.Heading.Class("pe-8") keeps it clear of the button.
When the page owns it
Where Flux controls a modal from PHP (Flux::modal('confirm')->show()), a Rask page owns the state instead: Ui.Modal.Open(_confirming).OnClose(() => _confirming = false)[…], which is Rask's wire:model.
It is the same modal: the dialog says data-rask-modal-open and the runtime shows and closes it to match, so it is in the top layer behind the same backdrop, Escape and a click outside run OnCancel then OnClose, and focus returns when it closes. OnClose is where the page hears that the reader closed it: set the field there, or the page goes on saying it is open.
A modal with neither a Name nor Open is open for as long as the page renders it. A modal without a Key beside keyed ones is fine: it keeps its instance by its order among the unkeyed modals of the page.
Shapes
Each is a step on the same modal. Ui.Drawer is gone: a panel that slides in from an edge is a flyout.
Step | What happens |
|---|---|
Flyout() | Against the right edge, at full height. |
Flyout().Left, Flyout().Bottom | Against another edge: Ui.ModalPosition is Right, Left or Bottom. |
Flyout().Floating | Standing off the edges, rounded. |
Variant(Ui.ModalVariant.Bare) | No panel and no close button: what it holds is all there is. |
Scroll(Ui.ModalScroll.Body) | The whole layer scrolls, not the dialog. |
Ui.ModalTrigger.Shortcut("mod+k") | Opens it from anywhere on the page: Flux's cmd.k. |
Reference
Ui.Modal
A real modal <dialog>: the top layer, an inert page behind it, Escape, and focus handed back.
Step | Takes | What it does |
|---|---|---|
.Name(…) | string | What a Ui.ModalTrigger opens it by. Unique on the page: it is the dialog's id. |
.Flyout() | bool | Anchors it to an edge of the viewport at full height, for longer forms. |
.Position(…) | Ui.ModalPositionRight Left Bottom Each is a step of its own too, unless an HTML tag has its name. | The edge a flyout opens from. Right when unset. |
.Variant(…) | Ui.ModalVariantDefault Floating Bare Flyout Each is a step of its own too, unless an HTML tag has its name. | How it is drawn: the panel, a floating flyout, or nothing around the content. |
.Scroll(…) | Ui.ModalScrollBody Each is a step of its own too, unless an HTML tag has its name. | What scrolls when it is taller than the viewport: the dialog, or the whole layer. |
.Dismissible() | bool | Whether a click outside closes it. On unless false. |
.Escapable() | bool | Whether Escape closes it. On unless false. |
.Closable() | bool | Whether it shows the close button in its corner. On unless false. |
.Open() | bool | Hands the open state to the page, Flux's wire:model. Unset, its triggers open it. |
.OnClose(…) | a handler, with no argument | Runs when it closes, by any means: where a page that set Open hears it. |
.OnCancel(…) | a handler, with no argument | Runs when it is dismissed, by Escape or a click outside, before OnClose. |
Ui.ModalTrigger
Makes the button inside it open the modal of that name, with no handler.
Step | Takes | What it does |
|---|---|---|
.Name(…) | string | The Name of the modal it opens. Required, and taken first. |
.Shortcut(…) | string | Keys that open it from anywhere on the page: "mod+k" is ⌘K on a Mac and Ctrl K elsewhere. |
Ui.ModalClose
Makes the button inside it close the modal it is in: a close, not a dismissal.
No steps of its own: what goes inside it is its content.