Components
Popover
Extra content in a panel beside a button, for what is not a menu. Ui.Popover is the second child of the Ui.Dropdown that holds its trigger, and where it opens is the dropdown's. The browser opens it and closes it on Escape and on a press anywhere else.
Drawn and behaving as Flux UI's popover does, measured against its live docs.
Popover
Focus stays on the trigger as it opens, and Tab walks from there into the panel. While it is up the page behind neither scrolls nor takes the pointer.
namespace Rask.Site.Features.UiDocs.Examples;
public sealed partial class PopoverExample : Component
{
private string _sort = "posted";
private string _view = "list";
protected override Component? Render() =>
Div.Class("flex flex-wrap items-center gap-4")[
Ui.Dropdown[
Ui.Button.Icon(Ui.IconName.AdjustmentsHorizontal).IconTrailing(Ui.IconName.ChevronDown)["Options"],
Ui.Popover.Class("flex flex-col gap-4")[
Ui.RadioGroup.Value(_sort).Id("popover-sort").Label("Sort by").OnChange(v => { _sort = v; })[
Ui.Radio.Value("active").Label("Recently active"),
Ui.Radio.Value("posted").Label("Date posted")
],
Ui.Separator.Key("s1").Subtle,
Ui.RadioGroup.Value(_view).Id("popover-view").Label("View as").OnChange(v => { _view = v; })[
Ui.Radio.Value("list").Label("List"),
Ui.Radio.Value("gallery").Label("Gallery")
],
Ui.Separator.Key("s2").Subtle,
Ui.Button.Subtle.Sm.Class("justify-start -m-2 px-2!")
.OnClick(() => { _sort = "posted"; _view = "list"; })["Reset to default"]
]
],
Ui.Text.Data("testid", "ui-popover-says")[$"Sorted by {_sort}, viewed as {_view}."]
];
}Sorted by posted, viewed as list.
Hover trigger
Hover() on the dropdown: the pointer opens it and leaving closes it. A press and Enter open it too, and it locks nothing.
namespace Rask.Site.Features.UiDocs.Examples;
public sealed partial class PopoverHoverExample : Component
{
protected override Component? Render() =>
Ui.Dropdown.Hover().Bottom.Start.Offset(-16).Gap(10)[
Button.Type(ButtonType.Button).Class("flex items-center gap-3")[
Ui.Avatar.Sm.Name("Mara Lind"),
Ui.Heading["Mara Lind"]
],
Ui.Popover.Class("flex flex-col gap-3 rounded-xl shadow-xl")[
Ui.Avatar.Xl.Name("Mara Lind"),
Div[
Ui.Heading.Lg["Mara Lind"],
Div.Class("flex items-center gap-2")[Ui.Text.Lg["@maralind"], Ui.Badge["Follows you"]]
],
Div.Class("flex items-center gap-4")[
Div.Class("flex gap-1")[Ui.Heading["775"], Ui.Text["following"]],
Div.Class("flex gap-1")[Ui.Heading["50.2k"], Ui.Text["followers"]]
],
Div.Class("flex gap-2")[
Ui.Button.Sm.Icon(Ui.IconName.Check).Class("flex-1")["Following"],
Ui.Button.Primary.Sm.Icon(Ui.IconName.ChatBubbleLeftRight).Class("flex-1")["Message"]
]
]
];
}@maralind
following
followers
Position
The side the panel opens on, and where along it.
namespace Rask.Site.Features.UiDocs.Examples;
public sealed partial class PopoverPositionExample : Component
{
protected override Component? Render() =>
Div.Class("grid max-w-xs grid-cols-2 gap-4")[
Ui.Dropdown.Key("ts").Top.Start[
Ui.Button.Filled.Sm.Class("w-full")["Top Start"],
Ui.Popover[Ui.Text["Above, at the start of its button."]]
],
Ui.Dropdown.Key("rc").Right.Center[
Ui.Button.Filled.Sm.Class("w-full")["Right Center"],
Ui.Popover[Ui.Text["To the right, centred on its button."]]
],
Ui.Dropdown.Key("lc").Left.Center[
Ui.Button.Filled.Sm.Class("w-full")["Left Center"],
Ui.Popover[Ui.Text["To the left, centred on its button."]]
],
Ui.Dropdown.Key("be").Bottom.End[
Ui.Button.Filled.Sm.Class("w-full")["Bottom End"],
Ui.Popover[Ui.Text["Below, at the end of its button."]]
]
];
}Above, at the start of its button.
To the right, centred on its button.
To the left, centred on its button.
Below, at the end of its button.
Gap & offset
Gap is the distance from the trigger; Offset shifts the panel along its edge.
namespace Rask.Site.Features.UiDocs.Examples;
public sealed partial class PopoverGapExample : Component
{
protected override Component? Render() =>
Div.Class("grid max-w-sm grid-cols-2 gap-4")[
Ui.Dropdown.Key("gap").Gap(16)[
Ui.Button.Filled.Sm.Class("w-full")["Gap: 16px"],
Ui.Popover[Ui.Text["16px from its button."]]
],
Ui.Dropdown.Key("offset").Offset(32)[
Ui.Button.Filled.Sm.Class("w-full")["Offset: 32px"],
Ui.Popover[Ui.Text["Shifted 32px along its button."]]
]
];
}16px from its button.
Shifted 32px along its button.
Examples
Category picker
A list of selectable categories, bound to the page. Picking keeps the panel open.
using System.Globalization;
namespace Rask.Site.Features.UiDocs.Examples;
public sealed partial class PopoverCategoriesExample : Component
{
private static readonly (string Value, string Label)[] Categories =
[
("fantasy", "Fantasy"), ("science-fiction", "Science fiction"), ("horror", "Horror"), ("mystery", "Mystery"),
("romance", "Romance"), ("autobiography", "Autobiography"), ("thriller", "Thriller"), ("poetry", "Poetry"),
("children", "Children"),
];
private List<string> _picked = ["fantasy", "science-fiction", "thriller"];
protected override Component? Render() =>
Ui.Dropdown[
Ui.Button.Icon(Ui.IconName.Tag).IconTrailing(
Ui.Badge.Sm.Class("-mr-1 tabular-nums").Data("testid", "ui-popover-count")[
_picked.Count.ToString(CultureInfo.InvariantCulture)
])["Categories"],
Ui.Popover.Class("max-w-[18rem] flex flex-col gap-4")[
Ui.CheckboxGroup.Values(_picked).Id("popover-categories").Variant(Ui.CheckboxGroupVariant.Pills)
.OnChange(v => { _picked = [.. v]; })[
Categories.Select(category => Ui.Checkbox.Key(category.Value).Value(category.Value).Label(category.Label))
],
Ui.Separator.Subtle,
Ui.Button.Subtle.Sm.Class("justify-start -m-2 px-2!").OnClick(() => { _picked = []; })["Clear all"]
]
];
}Feedback
A form in a panel. The page owns Open, so the form's own buttons close it, and OnToggle is how the page hears Escape and a press outside.
namespace Rask.Site.Features.UiDocs.Examples;
public sealed partial class PopoverFeedbackExample : Component
{
private bool _open;
private string _kind = "bug";
private string _text = "";
private string _sent = "nothing yet";
protected override Component? Render() =>
Div.Class("flex flex-wrap items-center gap-4")[
// The page owns Open, so the form's own buttons can close it.
Ui.Dropdown.Open(_open).OnToggle(open => { _open = open; })[
Ui.Button.Icon(Ui.IconName.ChatBubbleOvalLeft)["Feedback"],
Ui.Popover.Class("flex w-[min(30rem,calc(100vw-2rem))] flex-col gap-4")[
Ui.RadioGroup.Value(_kind).Id("popover-kind").Buttons.Class("max-sm:flex-wrap").OnChange(v => { _kind = v; })[
Ui.Radio.Value("bug").Icon(Ui.IconName.BugAnt).Class("flex-1")["Bug report"],
Ui.Radio.Value("suggestion").Icon(Ui.IconName.LightBulb).Class("flex-1")["Suggestion"],
Ui.Radio.Value("question").Icon(Ui.IconName.QuestionMarkCircle).Class("flex-1")["Question"]
],
Ui.Textarea.Value(_text).Id("popover-feedback").Rows(8).Class("dark:bg-transparent!")
.Placeholder("Please include reproduction steps. You may attach images or video files.")
.OnInput(v => { _text = v; }),
Div.Class("flex justify-end gap-2")[
Ui.Button.Filled.Sm.Kbd("esc").Class("w-28").OnClick(() => { _open = false; })["Cancel"],
Ui.Button.Sm.Kbd("⏎").Class("w-28").OnClick(Send)["Submit"]
]
]
],
Ui.Text.Data("testid", "ui-popover-sent")[$"Last feedback: {_sent}."]
];
private void Send()
{
_sent = _text.Length == 0 ? $"an empty {_kind}" : $"a {_kind}, “{_text}”";
_text = "";
_open = false;
}
}Last feedback: nothing yet.
Event details
Detailed information about an event, in a panel with no padding of its own.
namespace Rask.Site.Features.UiDocs.Examples;
public sealed partial class PopoverEventExample : Component
{
private string _answer = "going";
protected override Component? Render() =>
// Centred on a phone: a panel centred under a trigger at the page's edge would hang off it.
Div.Class("flex max-sm:justify-center")[
Ui.Dropdown.Bottom.Center[
Button.Type(ButtonType.Button)
.Class("flex w-54 items-center gap-2 rounded-lg bg-zinc-100 p-2 text-start hover:bg-zinc-200 "
+ "dark:bg-zinc-700 dark:hover:bg-zinc-600")[
Div.Class("w-0.5 self-stretch rounded-full bg-zinc-800 dark:bg-zinc-200"),
Div[Ui.Heading["Team sync"], Ui.Text.Class("mt-1")["10:00 AM"]]
],
// data-open:flex: an open panel says so, so its layout applies only while it is up.
Ui.Popover.Class("w-80 p-0 data-open:flex flex-col")[
Div.Class("p-4")[
Ui.Heading["Team sync"],
Ui.Text.Class("mt-2")["Let's review the progress made last week and define the priorities for the next"],
Ui.RadioGroup.Value(_answer).Id("popover-answer").Segmented.Class("mt-3").OnChange(v => { _answer = v; })[
Ui.Radio.Value("going").Label("Going"),
Ui.Radio.Value("not-going").Label("Not going"),
Ui.Radio.Value("maybe").Label("Maybe")
]
],
Ui.Separator.Key("s1"),
Detail(Ui.IconName.Users, Ui.Heading["Guests"], "Invite",
Div.Class("mt-2 flex items-center gap-3")[
Ui.AvatarGroup.Class("dark:*:ring-zinc-700")[
Ui.Avatar.Xs.Circle().Name("Mara Lind"),
Ui.Avatar.Xs.Circle().Name("Theo Brandt"),
Ui.Avatar.Xs.Circle().Name("Nia Okafor")
],
Ui.Text["1 maybe, 1 awaiting"]
]),
Ui.Separator.Key("s2"),
Detail(Ui.IconName.Clock,
Div.Class("flex gap-2")[
Ui.Heading["10:00 AM"],
Ui.Icon.Name(Ui.IconName.ArrowRight).Micro.Class("text-zinc-400"),
Ui.Heading["11:00 AM"]
],
"Edit",
Div.Class("mt-2 flex gap-3")[
Ui.Text["May 29 2025"],
Ui.Text["·"],
Div.Class("flex items-center gap-1.5")[
Ui.Icon.Name(Ui.IconName.ArrowPathRoundedSquare).Micro.Class("text-zinc-400"),
Ui.Text["Every weekday"]
]
]),
Ui.Separator.Key("s3"),
Detail(Ui.IconName.MapPin, Ui.Heading["Location"], "Edit",
Ui.Text.Class("mt-2")["Paris HQ, 13 rue de la Prairie 75018"])
]
]
];
// One block of the event: an icon, and beside it a heading with a quiet link at its far edge, over a detail.
private static Component Detail(Ui.IconName icon, Component heading, string link, Component detail) =>
Div.Class("flex gap-2 p-4")[
Ui.Icon.Name(icon).Micro.Class("text-zinc-400"),
Div.Class("flex-1")[
Div.Class("flex items-center justify-between")[
heading,
Ui.Text[Ui.Link.As(Ui.LinkAs.Button).Variant(Ui.LinkVariant.Subtle)[link]]
],
detail
]
];
}Let's review the progress made last week and define the priorities for the next
1 maybe, 1 awaiting
May 29 2025
·
Every weekday
Paris HQ, 13 rue de la Prairie 75018
Behaviour
Opening and closing
For what is not a menu: a form, a set of filters, a card about a person. It is the second child of the Ui.Dropdown that holds its trigger, exactly as flux:popover sits in flux:dropdown, and everything about where it opens and what opens it is the dropdown's, its viewport placement too.
The panel is a [popover] the trigger names with popovertarget, so the browser opens it, lifts it into the top layer and closes it on Escape and on a press anywhere else. It is no menu and says no role.
Focus
Focus stays on the trigger as it opens: the panel takes none, and Tab walks from there into the panel. Shift+Tab off the trigger, or Tab past the panel's last control, closes it.
The page behind
While one a press opened is up, the page behind neither scrolls nor takes the pointer (data-rask-lock), so a press on anything else only closes it. One the pointer opened, Hover(), locks nothing; a press and Enter still open it too.
Layout while open
An open panel carries data-open from the moment it opens. The runtime writes it in the browser (data-rask-open-mark), so no round trip stands between the press and the layout, and it is what Flux's own data-open:flex styles by. A closed panel is never displayed, whatever layout its Class gives it.
From Flux
Flux's attributes, as steps:
Flux | What happens |
|---|---|
flux:dropdown position="top" align="end" | Ui.Dropdown.Top.End |
flux:dropdown gap="16" offset="32" | Ui.Dropdown.Gap(16).Offset(32) |
flux:dropdown hover | Ui.Dropdown.Hover(): the pointer opens it; a press and Enter still do. |
flux:dropdown wire:model="open" | Ui.Dropdown.Open(_open).OnToggle(open => _open = open) |
flux:popover class="w-80 p-0" | Ui.Popover.Class("w-80 p-0"): its own width, padding, radius and shadow weigh nothing, so yours win. |
Reference
Ui.Popover
The panel a Ui.Dropdown opens beside its trigger, for what is not a menu. Where it opens is the dropdown's.
No steps of its own: what goes inside it is its content.