Rask
Rask
v0.23.1-alpha.0.440

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.

ModalExample.cs
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")}."]
        ];
}
Live result

Update profile

Make changes to your personal details.

More

Opening this does not close the dialog.

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.

ModalOpenExample.cs
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}."]
        ];
}
Live result

Delete order

This cannot be undone.

Last action: nothing yet.

Disable click outside

Dismissible(false) keeps a stray click outside from losing what is being edited.

ModalDismissibleExample.cs
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"]]]
                ]
            ]
        ];
}
Live result

Filters

Only the close button, Escape, or Apply closes this one.

Disable escape

Escapable(false) takes Escape away; Closable(false) takes the corner button.

ModalEscapableExample.cs
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"]]]
                ]
            ]
        ];
}
Live result

Session expiring

Escape does not close this one; a click outside, the corner button or Stay does.

Scrolling long content

Scroll Body lets a long modal run past the bottom of the screen, so it is plain there is more.

ModalScrollExample.cs
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"]]]
                ]
            ]
        ];
}
Live result

Terms of service

1. The whole layer scrolls, not a box inside it.

2. The whole layer scrolls, not a box inside it.

3. The whole layer scrolls, not a box inside it.

4. The whole layer scrolls, not a box inside it.

5. The whole layer scrolls, not a box inside it.

6. The whole layer scrolls, not a box inside it.

7. The whole layer scrolls, not a box inside it.

8. The whole layer scrolls, not a box inside it.

9. The whole layer scrolls, not a box inside it.

10. The whole layer scrolls, not a box inside it.

11. The whole layer scrolls, not a box inside it.

12. The whole layer scrolls, not a box inside it.

13. The whole layer scrolls, not a box inside it.

14. The whole layer scrolls, not a box inside it.

15. The whole layer scrolls, not a box inside it.

16. The whole layer scrolls, not a box inside it.

17. The whole layer scrolls, not a box inside it.

18. The whole layer scrolls, not a box inside it.

19. The whole layer scrolls, not a box inside it.

20. The whole layer scrolls, not a box inside it.

21. The whole layer scrolls, not a box inside it.

22. The whole layer scrolls, not a box inside it.

23. The whole layer scrolls, not a box inside it.

24. The whole layer scrolls, not a box inside it.

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.

ModalConfirmationExample.cs
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."]
        ];
}
Live result

Delete project?

You're about to delete this project.
This action cannot be reversed.

The project is still there.

Flyout

Flyout anchors it to an edge at full height, for longer forms.

ModalFlyoutExample.cs
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"]]
                ]
            ]
        ];
}
Live result

Update profile

Make changes to your personal details.

Flyout positioning

The right edge by default; Left or Bottom when asked.

ModalFlyoutPositionExample.cs
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."]]
            ]
        ];
}
Live result

Navigation

A flyout from the left edge.

Share

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.

ModalFloatingExample.cs
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"]
                    ]
                ]
            ]
        ];
}
Live result

Update profile

Make changes to your personal details.

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.
OnCloseHears every way it closed.
OnCancelHears 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().BottomAgainst another edge: Ui.ModalPosition is Right, Left or Bottom.
Flyout().FloatingStanding 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

flux:modal

A real modal <dialog>: the top layer, an inert page behind it, Escape, and focus handed back.

Step
Takes
What it does
.Name(…)stringWhat a Ui.ModalTrigger opens it by. Unique on the page: it is the dialog's id.
.Flyout()boolAnchors it to an edge of the viewport at full height, for longer forms.
.Position(…)
Ui.ModalPosition
Right
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.ModalVariant
Default
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.ModalScroll
Body

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()boolWhether a click outside closes it. On unless false.
.Escapable()boolWhether Escape closes it. On unless false.
.Closable()boolWhether it shows the close button in its corner. On unless false.
.Open()boolHands the open state to the page, Flux's wire:model. Unset, its triggers open it.
.OnClose(…)a handler, with no argumentRuns when it closes, by any means: where a page that set Open hears it.
.OnCancel(…)a handler, with no argumentRuns when it is dismissed, by Escape or a click outside, before OnClose.

Ui.ModalTrigger

flux:modal.trigger

Makes the button inside it open the modal of that name, with no handler.

Step
Takes
What it does
.Name(…)stringThe Name of the modal it opens. Required, and taken first.
.Shortcut(…)stringKeys that open it from anywhere on the page: "mod+k" is ⌘K on a Mac and Ctrl K elsewhere.

Ui.ModalClose

flux:modal.close

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.