Composition — lists, toasts, drag & error boundaries
Windowed and reorderable lists, transient toast messages, drag-and-drop, and error boundaries.
‹ Back to Composition
Virtualize — windowed lists
Virtualize.Items<T> is headless: you render the scroll container and rows from a
VirtualizationState ctx, and it tells you which slice is visible. The first argument is
the body builder; pass exactly one of Items (positional or named) or ItemsProvider,
plus ItemSize (row height in px, required) and optional OverscanCount.
It is the one place on the surface that is a method call rather than a chain, and for a reason a
chain cannot work around: a chain infers its type argument from the step that opens it, and T here
comes from the render delegate, not from a leading step. Virtualize is a global alias for the class
that holds it, so no using is needed.
Virtualize.Items<Row>(
ctx => Div.Style("height:400px; overflow:auto;").OnScroll(ctx.OnScroll)[
Div.Style($"height:{ctx.OffsetBefore}px"), // top spacer
Table[Tbody[
ctx.VisibleItems.Select(item => Tr.Style($"height:{ctx.ItemSize}px;").Data(new() { ["rask-key"] = item.Index.ToString() })[ // key → reuse <tr> on scroll
Td[item.IsPlaceholder ? "—" : item.Value!.Name])
]],
Div.Style($"height:{ctx.OffsetAfter}px") // bottom spacer
],
_rows, // Items (in memory)
ItemSize: 32,
OverscanCount: 4)
For lazy / server-paged data pass ItemsProvider: instead of the items list. The
provider must propagate the CancellationToken it receives or it will leak in-flight
requests:
ItemsProvider: async req =>
{
var page = await _api.GetRows(req.StartIndex, req.Count, req.CancellationToken);
return new ItemsProviderResult<Row>(page.Items, page.TotalCount);
}
Provider mode caches by index, marks rows IsPlaceholder while a page is in flight, and
cancels + disposes superseded requests (and on unmount).
Pass InitialTotalCount: as well when the first paint matters. Until the provider has answered
there is no total, so there is no window and your body renders no rows at all — an empty box that
pops into a full list when the fetch resolves. That is a layout shift on every load, and on a
prerendered page it makes the markup's shape depend on when it was sampled. With an estimate the
window is drawn at full size immediately, every row flagged IsPlaceholder, so a body that already
renders a pending row needs no new branch:
ItemsProvider: FetchRowsAsync,
ItemSize: 32,
InitialClientHeight: 360,
InitialTotalCount: 500 // a guess; the provider's real total replaces it on the next paint
An estimate is all it needs — the real count wins as soon as the provider reports one, and only the
spacer heights change. It is the provider-mode sibling of InitialClientHeight, and for the same
reason: the first render happens before the thing that knows the answer has answered. Ignored in
items mode, where the count is never in doubt.
Items mode — a fixed in-memory list, windowed:
using System.Globalization;
using Rask.Core.Virtualization;
namespace Rask.Site.Features;
// In-memory virtualization: 10,000 rows in VirtualizeData.Rows, but only the visible window
// (plus a small overscan) ever reaches the DOM. The off-screen rows above and below are reserved
// by two spacer *rows* inside the tbody, so the single table is the scroller's only child.
public sealed partial class VirtualizeItemsDemo : Component
{
// The header stays put while scrolling because (a) the table is the scroller's only child and
// its total height is constant — the spacer rows just redistribute height inside tbody as the
// window moves — so the sticky header's containing block never resizes, and (b) sticky lives on
// the <th> cells (not <thead>), each painting an opaque background with an inset box-shadow
// divider. Earlier the spacers were divs *outside* the table: the table's own box was relaid out
// every scroll frame and the header unstuck (vanished mid-scroll, snapped back on stop).
private const string StickyHead =
"position:sticky; top:0; z-index:1; background:#f8f9fa; box-shadow:inset 0 -1px 0 #dee2e6; ";
protected override Component? Render() =>
Virtualize.Items<VirtualizeRow>(
ctx => Div
.Class("border rounded bg-white")
.Style("height:360px; overflow:auto;")
.Data("testid", "virtualize-scroller")
.OnScroll(ctx.OnScroll)[
Table
.Class("w-full text-left text-sm [&_td]:px-3 [&_td]:py-2 [&_th]:px-3 [&_th]:py-2")
.Style("table-layout:fixed; width:100%; border-collapse:separate; border-spacing:0;")[
Thead[
Tr[
Th.Style(StickyHead + "width:64px;")["#"],
Th.Style(StickyHead)["Name"],
Th.Style(StickyHead + "width:120px;")["City"],
Th.Style(StickyHead + "width:110px; text-align:right;")["Balance"]
]
],
Tbody[BodyRows(ctx)]
]
],
VirtualizeData.Rows,
ItemSize: 32,
OverscanCount: 4,
InitialClientHeight: 360);
// tbody = top spacer + the visible window + bottom spacer. Every child carries a stable
// data-rask-key so the whole tbody stays on the keyed reconciliation path (it's all-or-nothing):
// the spacers keep their identity and only their height attribute is patched as you scroll, while
// the real rows move by index — all trusted diff ops, so the header is never re-rendered.
private static IEnumerable<Component> BodyRows(VirtualizationContext<VirtualizeRow> ctx)
{
yield return Spacer(ctx.OffsetBefore, "spacer-before");
foreach (var item in ctx.VisibleItems)
{
yield return Tr
.Style($"height:{ctx.ItemSize}px;")
.Data(
("row-index", item.Index.ToString(CultureInfo.InvariantCulture)),
("rask-key", item.Index.ToString(CultureInfo.InvariantCulture)))[
Td[item.Value?.Index.ToString(CultureInfo.InvariantCulture) ?? ""],
Td[item.Value?.Name ?? ""],
Td[item.Value?.City ?? ""],
Td.Style("text-align:right;")[item.Value?.Balance.ToString("0.00", CultureInfo.InvariantCulture) ?? ""]
];
}
yield return Spacer(ctx.OffsetAfter, "spacer-after");
}
private static Component Spacer(int height, string key) =>
Tr
.Style($"height:{height}px;")
.Data("rask-key", key)[
Td.ColSpan(4)
];
}| # | Name | City | Balance |
|---|---|---|---|
| 1 | Ada Lovelace #00001 | Amsterdam | 140.90 |
| 2 | Grace Lovelace #00002 | New York | 522.76 |
| 3 | Linus Lovelace #00003 | New York | 262.59 |
| 4 | Margaret Lovelace #00004 | Oxford | 512.92 |
| 5 | Donald Lovelace #00005 | New York | 761.25 |
| 6 | Barbara Lovelace #00006 | Helsinki | 257.32 |
| 7 | Edsger Lovelace #00007 | Cambridge | 320.23 |
| 8 | Tony Lovelace #00008 | Boston | 260.24 |
| 9 | Alan Lovelace #00009 | Cambridge | 35.31 |
| 10 | John Lovelace #00010 | Manchester | 577.19 |
| 11 | Ada Hopper #00011 | Boston | 152.19 |
| 12 | Grace Hopper #00012 | London | 705.15 |
| 13 | Linus Hopper #00013 | Manchester | 539.97 |
| 14 | Margaret Hopper #00014 | London | 711.15 |
| 15 | Donald Hopper #00015 | New York | 906.21 |
| 16 | Barbara Hopper #00016 | Amsterdam | 516.41 |
Provider mode — rows fetched on demand as they scroll into view:
using System.Globalization;
using Rask.Core.Virtualization;
namespace Rask.Site.Features;
// The same component, now backed by an ItemsProvider that simulates a 350 ms API call per
// window. Visible rows show a "—" placeholder until the fetch resolves, then morph in.
// Navigating away mid-fetch cancels the in-flight call: VirtualizeModel cancels its
// CancellationTokenSource in Unmount (and supersedes it whenever a new viewport arrives),
// so honour req.CancellationToken in your own providers to let the cancellation propagate.
public sealed partial class VirtualizeProviderDemo : Component
{
// Sticky header on the <th> cells, kept rock-steady by the constant-height single table —
// see VirtualizeItemsDemo for the full why (spacer rows inside tbody, not divs outside it).
private const string StickyHead =
"position:sticky; top:0; z-index:1; background:#f8f9fa; box-shadow:inset 0 -1px 0 #dee2e6; ";
protected override Component? Render() =>
Virtualize.Items(
ctx => Div
.Class("border rounded bg-white")
.Style("height:360px; overflow:auto;")
.Data("testid", "virtualize-async-scroller")
.OnScroll(ctx.OnScroll)[
Table
.Class("w-full text-left text-sm [&_td]:px-3 [&_td]:py-2 [&_th]:px-3 [&_th]:py-2")
.Style("table-layout:fixed; width:100%; border-collapse:separate; border-spacing:0;")[
Thead[
Tr[
Th.Style(StickyHead + "width:64px;")["#"],
Th.Style(StickyHead)["Name"],
Th.Style(StickyHead + "width:120px;")["City"],
Th.Style(StickyHead + "width:110px; text-align:right;")["Balance"]
]
],
Tbody[BodyRows(ctx)]
]
],
ItemsProvider: FetchRowsAsync,
ItemSize: 32,
OverscanCount: 4,
InitialClientHeight: 360,
// Without this the provider has reported no total on the first render, so there is no
// window and the tbody holds nothing but its two spacer rows -- an empty box that pops
// into a full table 350ms later, when the fetch resolves. Every <tr> and <td> in the
// window would be a tag that ARRIVES ON A TIMER, which is what makes a markup snapshot of
// this demo a race against the wall clock.
//
// With it the window renders at full size immediately, every row a placeholder showing
// "—", and the fetch fills in the TEXT. An estimate is all this needs; the real count
// replaces it as soon as the provider answers, and only the spacer heights change.
InitialTotalCount: VirtualizeData.Rows.Count);
// tbody = top spacer + visible window + bottom spacer, every child keyed so the whole tbody
// stays on the trusted keyed-diff path (spacers patch only their height; rows move by index).
private static IEnumerable<Component> BodyRows(VirtualizationContext<VirtualizeRow> ctx)
{
yield return Spacer(ctx.OffsetBefore, "spacer-before");
foreach (var item in ctx.VisibleItems)
{
yield return Tr
.Style($"height:{ctx.ItemSize}px;")
.Data(
("row-index", item.Index.ToString(CultureInfo.InvariantCulture)),
("rask-key", item.Index.ToString(CultureInfo.InvariantCulture)),
("placeholder", item.IsPlaceholder ? "true" : null))[
Td[item.IsPlaceholder ? "—" : item.Value!.Index.ToString(CultureInfo.InvariantCulture)],
Td[item.IsPlaceholder ? "—" : item.Value!.Name],
Td[item.IsPlaceholder ? "—" : item.Value!.City],
Td.Style("text-align:right;")[item.IsPlaceholder ? "—" : item.Value!.Balance.ToString("0.00", CultureInfo.InvariantCulture)]
];
}
yield return Spacer(ctx.OffsetAfter, "spacer-after");
}
private static Component Spacer(int height, string key) =>
Tr
.Style($"height:{height}px;")
.Data("rask-key", key)[
Td.ColSpan(4)
];
private static async ValueTask<ItemsProviderResult<VirtualizeRow>> FetchRowsAsync(ItemsProviderRequest req)
{
// The token is honoured so navigating away from /virtualize while a fetch is in flight
// unwinds the Task.Delay promptly. Without the token check the delay would complete and
// the continuation would try to update the cache after the component was disposed.
await Task.Delay(350, req.CancellationToken).ConfigureAwait(false);
var rows = VirtualizeData.Rows;
var count = Math.Min(req.Count, rows.Count - req.StartIndex);
var slice = new VirtualizeRow[Math.Max(count, 0)];
for (var i = 0; i < slice.Length; i++)
{
slice[i] = rows[req.StartIndex + i];
}
return new ItemsProviderResult<VirtualizeRow>(slice, rows.Count);
}
}| # | Name | City | Balance |
|---|---|---|---|
| — | — | — | — |
| — | — | — | — |
| — | — | — | — |
| — | — | — | — |
| — | — | — | — |
| — | — | — | — |
| — | — | — | — |
| — | — | — | — |
| — | — | — | — |
| — | — | — | — |
| — | — | — | — |
| — | — | — | — |
| — | — | — | — |
| — | — | — | — |
| — | — | — | — |
| — | — | — | — |
Keyed lists
A .Key(…) on a list item gives it a stable identity across renders, so a reorder moves the live
DOM node (with its focus, caret, and uncommitted input) instead of detaching and re-creating it. This
is the same reconciliation identity the diff uses everywhere — not a reactive prop.
using System.Globalization;
namespace Rask.Site.Features;
// Keyed reconciliation in miniature. A stable Key: per row makes a reorder ship trusted Move ops, so
// each row's DOM node — and any uncommitted input value living only in the DOM — follows its logical row
// instead of being rewritten by position. Toggle Keys OFF to see positional reconciliation instead: the
// labels reorder but the inputs stay put, so typed text ends up next to the wrong fruit.
public sealed partial class KeyedListsReorderDemo : Component
{
private readonly List<Fruit> _items =
[
new(1, "Apple"),
new(2, "Banana"),
new(3, "Cherry"),
new(4, "Date"),
new(5, "Elderberry")
];
private int _nextId = 6;
private bool _useKeys = true;
protected override Component? Render() =>
Div[
Div.Class("flex gap-2 items-center flex-wrap mb-3")[
Ui.Button.Icon(Ui.IconName.Key)
.Variant(_useKeys ? Ui.ButtonVariant.Primary : Ui.ButtonVariant.Outline)
.Color(_useKeys ? Ui.Color.Green : null)
.Id("kl-toggle-keys")
.OnClick(() => _useKeys = !_useKeys)[_useKeys ? "Keys: ON" : "Keys: OFF"],
Span.Class("vr mx-1"),
Ui.Button.Icon(Ui.IconName.ArrowsUpDown).Id("kl-rotate").OnClick(Rotate)["Rotate"],
Ui.Button.Icon(Ui.IconName.ArrowPath).Id("kl-reverse").OnClick(Reverse)["Reverse"],
Ui.Button.Icon(Ui.IconName.Plus).Id("kl-add").OnClick(AddTop)["Add to top"],
Ui.Button.Red.Icon(Ui.IconName.Minus)
.Id("kl-remove")
.Disabled(_items.Count == 0)
.OnClick(RemoveTop)["Remove top"]
],
Ui.Table.Id("kl-list")[
Ui.TableColumns[Ui.TableColumn["#"], Ui.TableColumn["Fruit"], Ui.TableColumn["Note"]],
Ui.TableRows[BuildRows()]
]
];
private List<Component> BuildRows()
{
var rows = new List<Component>(_items.Count);
for (var i = 0; i < _items.Count; i++)
{
var f = _items[i];
// The keyless branch is deliberately unkeyed to demonstrate positional
// reconciliation; RASK022 would otherwise flag it.
#pragma warning disable RASK022
rows.Add(_useKeys
? Ui.TableRow.Key(f.Id)[Row(f, i)]
: Ui.TableRow[Row(f, i)]);
#pragma warning restore RASK022
}
return rows;
}
private static List<Component> Row(Fruit f, int index) =>
[
Ui.TableCell[Ui.Badge[(index + 1).ToString(CultureInfo.InvariantCulture)]],
Ui.TableCell.Variant(Ui.TableCellVariant.Strong).Class("kl-name")[f.Name],
// The column says "Note" to the eye; the label around the field says it to a screen reader.
Ui.TableCell[
Label[
Span.Class("sr-only")[$"Note for {f.Name}"],
Ui.Input.Of<string>()
.Type(InputType.Text)
.Placeholder("type here, then reorder…").Class("kl-note")
]
]
];
private void Rotate()
{
if (_items.Count < 2)
{
return;
}
var first = _items[0];
_items.RemoveAt(0);
_items.Add(first);
}
private void Reverse() => _items.Reverse();
private void AddTop()
{
_items.Insert(0, new Fruit(_nextId, $"Fruit {_nextId}"));
_nextId++;
}
private void RemoveTop()
{
if (_items.Count > 0)
{
_items.RemoveAt(0);
}
}
private sealed record Fruit(int Id, string Name);
}# | Fruit | Note |
|---|---|---|
1 | Apple | |
2 | Banana | |
3 | Cherry | |
4 | Date | |
5 | Elderberry |
A master-detail grid is the same identity trick at work: each order row carries a Key, and expanding
one inserts a keyed detail <tr> right after it. The diff reconciles that as an in-place keyed insert
(collapse → remove), so the other open rows keep their own independently-sorted inner grid across the change
— no wholesale re-render of the table:
using System.Globalization;
namespace Rask.Site.Features;
// Master-detail datagrid, embedded in docs/composition.md's "Keyed lists" section (its standalone
// /master-detail page folded in). Expand/collapse and both grids' sort live in plain component fields.
//
// Expanding a row inserts a second, keyed <tr> ("detail-{id}") right after the keyed main row ("{id}").
// Because every row carries a stable Key, the live diff treats expand as an in-place keyed Insert and
// collapse as a keyed Remove — sibling expanded rows keep their own inner sort across the reconcile. Each
// detail panel hosts its own plain <table> of line items with an independent sort, so the demo owns three
// pieces of state: the expanded set, the outer sort, and a per-order inner sort.
public sealed partial class MasterDetailDemo : Component
{
private static readonly Order[] _orders = BuildOrders();
// (id, header, sortable, width) — the expander column has no label and no sort. Every column but one has
// a width, so a sort moves the rows and never the columns; the one without takes the rest.
private static readonly (string Id, string Header, bool Sortable, string? Width)[] _orderColumns =
[
("expander", "", false, "w-14"),
("customer", "Customer", true, null),
("placed", "Placed", true, "w-32"),
("status", "Status", true, "w-36"),
("items", "Items", true, "w-24"),
("total", "Total", true, "w-32")
];
private static readonly (string Id, string Header, string? Width)[] _itemColumns =
[
("sku", "SKU", "w-32"),
("product", "Product", null),
("qty", "Qty", "w-20"),
("unit", "Unit price", "w-32"),
("line", "Line total", "w-32")
];
// Expanded order ids, the outer sort, and the inner sort per order — all local UI state.
// A sort is a (column id, ascending) pair; an empty column id means "unsorted".
private readonly HashSet<int> _expanded = new();
private readonly Dictionary<int, (string Col, bool Asc)> _itemSort = new();
private (string Col, bool Asc) _orderSort = ("", true);
protected override Component? Render()
{
var orders = SortOrders(_orders, _orderSort);
return Ui.Card[Ui.CardBleed[
Ui.Table.Id("md-orders").Class("w-full min-w-190 [&_tbody_tr:hover]:bg-zinc-50 dark:bg-zinc-900")[
Ui.TableColumns.Class("bg-zinc-50 dark:bg-zinc-900")[
_orderColumns.Select(c =>
c.Sortable
? SortHeader(c.Id, c.Header, c.Width, _orderSort, ToggleOrderSort)
: Ui.TableColumn.Key(c.Id).Class(c.Width))
],
Ui.TableRows[BuildOrderRows(orders)]
]
]];
}
private List<Component> BuildOrderRows(IReadOnlyList<Order> orders)
{
var rows = new List<Component>(orders.Count * 2);
foreach (var order in orders)
{
var open = _expanded.Contains(order.Id);
rows.Add(Ui.TableRow.Key(order.Id).Class("md-row")[
Ui.TableCell[
Ui.Button.Subtle.Icon(open ? Ui.IconName.ChevronDown : Ui.IconName.ChevronRight)
.AriaLabel(open ? $"Collapse order {order.Id}" : $"Expand order {order.Id}")
.Class("p-0 no-underline")
.Data("testid", $"expander-{order.Id}")
.OnClick(() => Toggle(order.Id))
],
Ui.TableCell.Class("truncate font-semibold").Title(order.Customer)[order.Customer],
Ui.TableCell.Class("text-zinc-500 dark:text-white/70 text-sm")[order.Placed.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)],
Ui.TableCell[Ui.Badge.Color(StatusColor(order.Status))[order.Status]],
Ui.TableCell.Class("text-zinc-500 dark:text-white/70")[order.Items.Count],
Ui.TableCell.Style("text-align:right; font-variant-numeric:tabular-nums;")[
"$" + order.Total.ToString("N2", CultureInfo.InvariantCulture)
]
]);
if (open)
{
rows.Add(Ui.TableRow.Key($"detail-{order.Id}").Class("md-detail")[
Ui.TableCell.Attributes(("colspan", _orderColumns.Length.ToString(CultureInfo.InvariantCulture))).Class("p-0 bg-zinc-50 dark:bg-zinc-900")[
Div
.Class("p-3")
.Data("testid", $"inner-{order.Id}")[
InnerGrid(order)
]
]
]);
}
}
return rows;
}
private Component InnerGrid(Order order)
{
var sort = _itemSort.GetValueOrDefault(order.Id, ("", true));
var items = SortItems(order.Items, sort);
return Ui.Table.Class("w-full min-w-168 [&_tbody_tr:nth-child(odd)]:bg-zinc-50 dark:bg-zinc-900 bg-white dark:bg-zinc-800")[
Ui.TableColumns[
_itemColumns.Select(c => SortHeader(c.Id, c.Header, c.Width, sort, col => ToggleItemSort(order.Id, col)))
],
Ui.TableRows[
items.Select(it =>
Ui.TableRow.Key(it.Id)[
Ui.TableCell[Code[it.Sku]],
Ui.TableCell.Class("truncate").Title(it.Product)[it.Product],
Ui.TableCell.Class("text-zinc-500 dark:text-white/70")[it.Qty],
Ui.TableCell.Style("text-align:right; font-variant-numeric:tabular-nums;")[
"$" + it.UnitPrice.ToString("N2", CultureInfo.InvariantCulture)
],
Ui.TableCell.Style("text-align:right; font-variant-numeric:tabular-nums;")[
"$" + it.LineTotal.ToString("N2", CultureInfo.InvariantCulture)
]
])
]
];
}
private void Toggle(int id)
{
if (!_expanded.Add(id))
{
_expanded.Remove(id);
}
StateHasChanged();
}
private void ToggleOrderSort(string col)
{
_orderSort = NextSort(_orderSort, col);
StateHasChanged();
}
private void ToggleItemSort(int orderId, string col)
{
_itemSort[orderId] = NextSort(_itemSort.GetValueOrDefault(orderId, ("", true)), col);
StateHasChanged();
}
// Cycle a column's sort: unsorted → asc → desc → unsorted.
private static (string Col, bool Asc) NextSort((string Col, bool Asc) current, string col) =>
current switch
{
_ when !string.Equals(current.Col, col, StringComparison.Ordinal) => (col, true),
{ Asc: true } => (col, false),
_ => ("", true),
};
private static IReadOnlyList<Order> SortOrders(IReadOnlyList<Order> source, (string Col, bool Asc) sort)
{
if (sort.Col.Length == 0)
{
return source;
}
var asc = sort.Asc;
IEnumerable<Order> view = source;
view = sort.Col switch
{
"customer" => asc ? view.OrderBy(o => o.Customer, StringComparer.Ordinal) : view.OrderByDescending(o => o.Customer, StringComparer.Ordinal),
"placed" => asc ? view.OrderBy(o => o.Placed) : view.OrderByDescending(o => o.Placed),
"status" => asc ? view.OrderBy(o => o.Status, StringComparer.Ordinal) : view.OrderByDescending(o => o.Status, StringComparer.Ordinal),
"items" => asc ? view.OrderBy(o => o.Items.Count) : view.OrderByDescending(o => o.Items.Count),
"total" => asc ? view.OrderBy(o => o.Total) : view.OrderByDescending(o => o.Total),
_ => view
};
return view.ToArray();
}
private static IReadOnlyList<LineItem> SortItems(IReadOnlyList<LineItem> source, (string Col, bool Asc) sort)
{
if (sort.Col.Length == 0)
{
return source;
}
var asc = sort.Asc;
IEnumerable<LineItem> view = source;
view = sort.Col switch
{
"sku" => asc ? view.OrderBy(i => i.Sku, StringComparer.Ordinal) : view.OrderByDescending(i => i.Sku, StringComparer.Ordinal),
"product" => asc ? view.OrderBy(i => i.Product, StringComparer.Ordinal) : view.OrderByDescending(i => i.Product, StringComparer.Ordinal),
"qty" => asc ? view.OrderBy(i => i.Qty) : view.OrderByDescending(i => i.Qty),
"unit" => asc ? view.OrderBy(i => i.UnitPrice) : view.OrderByDescending(i => i.UnitPrice),
"line" => asc ? view.OrderBy(i => i.LineTotal) : view.OrderByDescending(i => i.LineTotal),
_ => view
};
return view.ToArray();
}
// Shared sort-aware header: a link button that toggles the column's sort, with a chevron reflecting
// its current direction. Used by both the outer and the inner grid.
// The table draws the sort button and its chevron; the page keeps which column is sorted and which way.
private static Component SortHeader(string columnId, string header, string? width, (string Col, bool Asc) sort,
Action<string> toggle) =>
Ui.TableColumn
.Key(columnId)
.Class(width)
.Sortable()
.Sorted(string.Equals(sort.Col, columnId, StringComparison.Ordinal))
.Direction(sort.Asc ? Ui.TableColumnDirection.Asc : Ui.TableColumnDirection.Desc)
.OnSort(() => toggle(columnId))[header];
// A COLOUR, not a class: the call site names the hue and Ui.Badge decides the tint, the text and dark mode.
private static Ui.Color? StatusColor(string status) => status switch
{
"Shipped" => Ui.Color.Green,
"Processing" => Ui.Color.Blue,
"Pending" => Ui.Color.Yellow,
"Cancelled" => Ui.Color.Red,
_ => null
};
private static Order[] BuildOrders()
{
var customers = new[]
{
"Ada Lovelace", "Grace Hopper", "Linus Torvalds", "Margaret Hamilton", "Donald Knuth",
"Barbara Liskov", "Edsger Dijkstra", "Tony Hoare", "Alan Turing", "John Backus",
"Niklaus Wirth", "Bjarne Stroustrup", "Anders Hejlsberg", "James Gosling"
};
var statuses = new[] { "Shipped", "Processing", "Pending", "Cancelled" };
var products = new[]
{
("KBD-01", "Mechanical keyboard", 89m), ("MSE-02", "Wireless mouse", 39m),
("MON-27", "27\" monitor", 329m), ("USB-C", "USB-C hub", 59m),
("CBL-HD", "HDMI cable", 12m), ("STD-01", "Laptop stand", 45m),
("WBC-4K", "4K webcam", 129m), ("HPN-01", "Noise-cancelling headphones", 199m),
("DSK-MAT", "Desk mat", 24m), ("CHR-ERG", "Ergonomic chair", 449m)
};
#pragma warning disable S2245 // seeded on purpose: the same demo data on every visit, nothing secret
var rng = new Random(42);
#pragma warning restore S2245
var orders = new Order[customers.Length];
var nextItemId = 1;
for (var i = 0; i < customers.Length; i++)
{
var itemCount = 2 + rng.Next(0, 5);
var items = new LineItem[itemCount];
for (var j = 0; j < itemCount; j++)
{
var (sku, name, price) = products[rng.Next(products.Length)];
items[j] = new LineItem(nextItemId++, i + 1, sku, name, 1 + rng.Next(0, 5), price);
}
var placed = new DateOnly(2025, 1 + rng.Next(0, 12), 1 + rng.Next(0, 28));
orders[i] = new Order(i + 1, customers[i], placed, statuses[rng.Next(statuses.Length)], items);
}
return orders;
}
private sealed record LineItem(int Id, int OrderId, string Sku, string Product, int Qty, decimal UnitPrice)
{
public decimal LineTotal => Qty * UnitPrice;
}
private sealed record Order(
int Id,
string Customer,
DateOnly Placed,
string Status,
IReadOnlyList<LineItem> Items)
{
public decimal Total => Items.Sum(i => i.LineTotal);
}
}| Ada Lovelace | 2025-04-15 | Processing | 5 | $1,843.00 | |
| Grace Hopper | 2025-02-03 | Pending | 3 | $1,522.00 | |
| Linus Torvalds | 2025-01-16 | Cancelled | 6 | $2,488.00 | |
| Margaret Hamilton | 2025-08-05 | Shipped | 5 | $1,420.00 | |
| Donald Knuth | 2025-09-02 | Processing | 5 | $858.00 | |
| Barbara Liskov | 2025-01-24 | Shipped | 2 | $732.00 | |
| Edsger Dijkstra | 2025-12-13 | Processing | 2 | $801.00 | |
| Tony Hoare | 2025-09-01 | Cancelled | 5 | $2,263.00 | |
| Alan Turing | 2025-12-22 | Pending | 6 | $711.00 | |
| John Backus | 2025-01-07 | Pending | 5 | $4,068.00 | |
| Niklaus Wirth | 2025-01-02 | Cancelled | 4 | $1,597.00 | |
| Bjarne Stroustrup | 2025-12-28 | Cancelled | 6 | $2,509.00 | |
| Anders Hejlsberg | 2025-07-20 | Shipped | 2 | $1,011.00 | |
| James Gosling | 2025-10-04 | Shipped | 4 | $1,891.00 |
Toast messages
Toasts are built in. Say something to the person using the app from anywhere — an event handler, a save, a render — with nothing injected and nothing mounted:
private async Task Save()
{
await Product.Create(_model);
Toast.Success("Saved");
Routes.ProductsPage().Go(); // the toast shows on the page they land on
}
Toast.Info, Toast.Warning and Toast.Error say the rest. A toast can carry a heading, a button or a link,
and its own time on screen:
Toast.Success("Your order was placed").Heading("Order 42");
Toast.Info("Changes saved.").Action("Undo", UndoChanges); // runs it, then dismisses the toast
Toast.Success("Invoice created.").Link("View invoice", Routes.InvoicePage(invoice.Id));
Toast.Error("Payment failed").For(30.Seconds);
Toast.Error("Couldn't reach the server").UntilDismissed();
namespace Rask.Site.Features;
// Toasts are built in: nothing injected, nothing mounted — the host draws them in the kit's look.
public sealed partial class BuiltInToastDemo : Component
{
protected override Component? Render() =>
Div.Class("flex flex-wrap gap-2")[
Ui.Button.Primary.Id("toast-save").OnClick(() => Toast.Success("Saved"))["Save"],
Ui.Button.Id("toast-order").OnClick(() => Toast.Info("Your order was placed").Heading("Order 42"))["Place order"],
Ui.Button.Red.Id("toast-fail")
.OnClick(() => Toast.Error("Couldn't reach the server").UntilDismissed())["Fail"]
];
}A toast belongs to the session, not the page, so one raised just before navigating survives the navigation
and shows once on the destination. The host draws them — as the UI kit's Ui.Toast, or in a small
look of Rask's own when the kit is off — in the bottom end corner, one at a time, each gone after five seconds
unless it says otherwise. Where they appear and how long they stay is the app's to set, in Program.cs or
appsettings.json:
RaskApp.Create(args)
.Configure(c => c.Toasts.At(Ui.ToastPosition.TopEnd).For(8.Seconds))
.Run<App>();
{ "Rask": { "Toasts": { "Position": "TopEnd", "Duration": "00:00:08" } } }
For anything else — a stack, the inverted look — place the kit's toast in your layout and the built-in one steps aside:
Ui.ToastGroup[Ui.Toast] // toasts stack, and the deck opens under the pointer
Ui.Toast.TopEnd.Invert() // one at a time, dark on a light page
Your own look
Mount a ToastOutlet anywhere and the built-in one steps aside — your outlet gets every toast, and draws
them through Template, which receives the messages showing now and a dismiss(id) callback:
protected override Component? Render() =>
[
Router,
ToastOutlet.Template((messages, dismiss) =>
Div.Class("notices")[messages.Select(m => Div.Key(m.Id).Class("notice")[
m.Message,
Button.OnClick(() => dismiss(m.Id))["×"]])]),
];
Each message is delivered to exactly one outlet and never reappears on a later render. AutoDismissAfter sets
how long a toast stays in your outlet when it did not say (.For, .UntilDismissed()).
In a test
A toast is on screen, so a test that visits the page sees it: page.Shows("Saved"). A test that runs no page
records them instead:
using var toasts = Toast.Fake();
await Orders.Place(cart);
toasts.Shown("Order placed").Once();
toasts.Shown("Order placed").As(ToastLevel.Success);
toasts.Shown("Payment failed").Never();
Drag and drop
A headless drag-and-drop primitive lives in Rask.Core/DragAndDrop. It tracks the
dragged item and the drop target and raises a callback when an item is dropped; you own
the visuals — a sortable list and a kanban board built on the same primitive:
using Rask.Core.DragAndDrop;
namespace Rask.Site.Features;
// Single-list reorder. One drop zone ("list"); drop a fruit onto another to reorder.
public sealed partial class DragDropSortableDemo : Component
{
private readonly List<string> _fruits =
[
"Apple", "Banana", "Cherry", "Date", "Elderberry"
];
protected override Component? Render() => DragDrop.Body(SortableBody).OnDrop(ReorderFruit);
private Component SortableBody(DragDropContext ctx) =>
Ui.Table.Class("dd-list").Id("dd-fruit-list")[Ui.TableRows[_fruits.Select((fruit, index) => Ui.TableRow
.Key(fruit)
.Class(
"dd-item",
ctx.IsSource("list", index) ? "dd-dragging" : null,
ctx.IsDropTarget("list", index) ? "dd-drop-target" : null)
.Draggable()
.OnDragStart(ctx.DragStart("list", index))
.OnDragOver(ctx.DragOver("list", index))
.OnDrop(ctx.Drop("list", index))
.OnDragEnd(ctx.DragEnd)
.Data("testid", $"fruit-{index}")[
Ui.TableCell.Class("w-8")[Ui.Icon.Name(Ui.IconName.EllipsisVertical).Variant(Ui.IconVariant.Mini)],
Ui.TableCell.Variant(Ui.TableCellVariant.Strong)[fruit]
])]];
// Direction-aware: dragging down lands after the target, dragging up lands before it.
private void ReorderFruit(DragDropMove move) => move.ApplyTo(_fruits);
}| Apple | |
| Banana | |
| Cherry | |
| Date | |
| Elderberry |
using Rask.Core.DragAndDrop;
namespace Rask.Site.Features;
// Multi-column board. One drop zone per column; drag cards across columns or reorder within one.
public sealed partial class DragDropKanbanDemo : Component
{
private static readonly string[] _columns = ["todo", "doing", "done"];
private static readonly Dictionary<string, string> _columnLabels = new(StringComparer.Ordinal)
{
["todo"] = "To do",
["doing"] = "In progress",
["done"] = "Done"
};
private readonly Dictionary<string, List<Card>> _board = new(StringComparer.Ordinal)
{
["todo"] =
[new Card(1, "Sketch the API"), new Card(2, "Write the primitive"), new Card(3, "Add the events")],
["doing"] = [new Card(4, "Wire the client JS")],
["done"] = [new Card(5, "Read the codebase")]
};
protected override Component? Render() => DragDrop.Body(KanbanBody).OnDrop(MoveCard);
private Component KanbanBody(DragDropContext ctx) =>
Div.Class("grid grid-cols-12 gap-4 dd-board")[_columns.Select(zone => KanbanColumn(ctx, zone))];
private Component KanbanColumn(DragDropContext ctx, string zone)
{
var cards = _board[zone];
var cardChildren = new List<Component>(cards.Count);
for (var i = 0; i < cards.Count; i++)
{
cardChildren.Add(KanbanCard(ctx, zone, i, cards[i]));
}
// The whole column body is the drop-at-end zone, so a card can land in empty space, at
// the tail of a column, or into an empty column. Cards inside carry their own per-index
// drop handlers; the client's e.target.closest(...) resolves the innermost match, so
// hovering a card targets that card and hovering empty space targets the column end.
var dropAtEnd = cards.Count;
var bodyCls = "dd-column-body";
if (ctx.IsDropTarget(zone, dropAtEnd))
{
bodyCls += " dd-drop-target";
}
return Div.Key(zone).Class("col-span-12")[
Div.Class("dd-column h-full")[
Div.Class("dd-column-header flex justify-between items-center")[
Span.Class("font-semibold")[_columnLabels[zone]],
Ui.Badge[cards.Count]
],
Div
.Class(bodyCls)
.OnDragOver(ctx.DragOver(zone, dropAtEnd))
.OnDrop(ctx.Drop(zone, dropAtEnd))
.Data("testid", $"col-{zone}")[cardChildren]
]
];
}
private static Component KanbanCard(DragDropContext ctx, string zone, int index, Card card)
{
var cls = "card dd-card";
if (ctx.IsSource(zone, index))
{
cls += " dd-dragging";
}
if (ctx.IsDropTarget(zone, index))
{
cls += " dd-drop-target";
}
return Div
.Key(card.Id)
.Class(cls)
.Draggable()
.OnDragStart(ctx.DragStart(zone, index))
.OnDragOver(ctx.DragOver(zone, index))
.OnDrop(ctx.Drop(zone, index))
.OnDragEnd(ctx.DragEnd)
.Data("testid", $"card-{card.Id}")[
Div.Class("p-2 flex items-center gap-2")[
Ui.Icon.Name(Ui.IconName.EllipsisVertical).Class("size-5 text-zinc-500 dark:text-white/70"),
Span[card.Title]
]
];
}
private void MoveCard(DragDropMove move)
{
if (_board.TryGetValue(move.FromZone, out var from) && _board.TryGetValue(move.ToZone, out var to))
{
move.ApplyTo(from, to);
}
}
private sealed record Card(int Id, string Title);
}Error boundaries
An error boundary catches an exception thrown by a descendant — during an event handler or during
render — and shows a fallback instead of tearing down the whole app. The nearest boundary you wrote handles it;
everything outside it (the navbar, the rest of the page) keeps running, and Recover restores the
healthy subtree. Boundaries nest, so a local failure stays local. With no boundary of yours around the part, a
handler that throws leaves the page as it is and an error toast says so; only a render that throws reaches
the root error page (lifecycle).
The Fallback is handed the exception so it can choose its words, not so it can print them: an
exception's message or type name on a page can carry a path, a query or a user's data, and the boundary has
already logged it (what a visitor sees). Each
fallback below says one fixed sentence and offers the way back.
namespace Rask.Site.Features;
// The nearest ErrorBoundary the app wrote catches an exception thrown by a
// descendant's event handler and renders the Fallback in place of the subtree
// (with none around the part, the page stays and an error toast says so). The fallback receives a
// recover() callback that clears the boundary's error and re-renders the
// healthy subtree. The fallback is also handed the exception and prints none of
// it: a message can carry a path, a query or a user's data, and it is already in
// the log. It says what happened in the visitor's words and what they can do.
public sealed partial class BoomHandlerDemo : Component
{
protected override Component? Render() =>
ErrorBoundary
.Fallback((_, recover) => BoundaryFallback(recover))[
Div.Class("p-3 border rounded bg-white").Id("boom-handler-host")[
P.Class("text-zinc-500 dark:text-white/70 text-sm mb-2")["Healthy subtree — click to throw."],
Ui.Button.Danger.Icon(Ui.IconName.ExclamationTriangle).Id("boom-throw").OnClick(ThrowFromHandler)["Throw a handler exception"]
]
];
private static Component BoundaryFallback(Action recover) =>
Ui.Callout.Danger.Icon(Ui.IconName.ExclamationTriangle).Id("boom-fallback").Role("alert")
.Actions(Ui.Button.Icon(Ui.IconName.ArrowUturnLeft).Id("boom-recover").OnClick(recover)["Recover"])[
Ui.CalloutHeading["This part stopped working."],
Ui.CalloutText["The rest of the page is still running. Recover brings this part back."]
];
private static void ThrowFromHandler() =>
throw new InvalidOperationException("kaboom — handler boundary demo");
}Healthy subtree — click to throw.
A render-time throw is rewound cleanly (the serializer discards the partial output) and caught exactly once:
namespace Rask.Site.Features;
// The same ErrorBoundary catches synchronous exceptions thrown inside a
// descendant's Render(). Clicking the button flips a flag; the next render of
// the child throws and the fallback replaces it.
public sealed partial class BoomRenderDemo : Component
{
private bool _throwOnRender;
protected override Component? Render() =>
ErrorBoundary
.Fallback(// Recover for the render-throw demo must ALSO reset _throwOnRender —
// otherwise the boundary clears its error, re-walks its cached Children
// (still containing the RenderThrower built last frame), and trips
// again on the same exception. Two cooperating dirty-marks are needed:
// - recover() → clears boundary._error, marks boundary dirty
// - StateHasChanged() → marks THIS demo dirty so its Render re-
// executes with _throwOnRender=false and the
// boundary receives fresh Children without the
// RenderThrower.
// The handler-throw demo doesn't need this because the underlying
// state isn't stale across the trip.
(_, recover) => BoundaryFallback(() =>
{
// Order matters: dirty-mark this demo FIRST so its re-render calls
// the ErrorBoundary factory → boundary.SetProps with fresh Children
// that no longer include the RenderThrower. THEN clear the boundary's
// error. Calling recover() before that would synchronously re-render
// the boundary against the stale cached Children (still containing
// RenderThrower) — the boundary would trip again on the same
// exception and the recovery would appear to do nothing.
_throwOnRender = false;
StateHasChanged();
recover();
}))[
Div.Class("p-3 border rounded bg-white").Id("boom-render-host")[
P.Class("text-zinc-500 dark:text-white/70 text-sm mb-2")["Healthy. Click below to make my next render throw."],
Ui.Button.Filled.Amber.Icon(Ui.IconName.BugAnt).Id("boom-render-trigger").OnClick(() => _throwOnRender = true)["Throw on next render"],
#pragma warning disable RASK014
// Intentionally bypass the factory: RenderThrower is [SkipFactory] and
// exists only to demonstrate that a descendant whose Render() throws is
// caught by the enclosing ErrorBoundary.
_throwOnRender ? new RenderThrower() : Text.Value(string.Empty)
#pragma warning restore RASK014
]
];
// Nothing of the exception is printed: the fallback says what the visitor can do.
private static Component BoundaryFallback(Action recover) =>
Ui.Callout.Danger.Icon(Ui.IconName.ExclamationTriangle).Id("boom-fallback").Role("alert")
.Actions(Ui.Button.Icon(Ui.IconName.ArrowUturnLeft).Id("boom-recover").OnClick(recover)["Recover"])[
Ui.CalloutHeading["This part could not be shown."],
Ui.CalloutText["The rest of the page is still running. Recover draws it again."]
];
// Trivial component whose Render always throws — used to demonstrate render-time
// boundary capture. SkipFactory tells the source generator not to emit a public
// factory; we instantiate it directly from inside the boundary's Children.
[SkipFactory]
private sealed class RenderThrower : Component
{
protected override Component? Render() =>
throw new InvalidOperationException("kaboom — render-time boundary demo");
}
}Healthy. Click below to make my next render throw.
Nested boundaries — the innermost one catches, leaving its siblings untouched:
namespace Rask.Site.Features;
// Boundaries nest: the inner boundary catches first, so the outer healthy
// region (and its sibling paragraph) stays mounted. If the inner fallback
// itself throws, the outer boundary catches the escalation. Neither fallback
// prints the exception it is handed.
public sealed partial class BoomNestedDemo : Component
{
protected override Component? Render() =>
ErrorBoundary.Fallback((_, _) => OuterFallback())[
Div.Class("p-3 border rounded bg-white").Id("boom-nested-host")[
P
.Class("mb-2 text-sm text-zinc-500 dark:text-white/70")
.Id("boom-nested-outer-healthy")[
"Outer healthy region — stays mounted while the inner boundary trips."],
ErrorBoundary.Fallback((_, recover) => InnerFallback(recover))[
Div.Class("p-3 border rounded bg-zinc-50 dark:bg-zinc-900")[
P.Class("text-sm text-zinc-500 dark:text-white/70 mb-2")["Inner boundary subtree."],
Ui.Button.Danger.Icon(Ui.IconName.ExclamationTriangle)
.Id("boom-nested-throw")
.OnClick(ThrowFromInnerHandler)["Throw inside inner boundary"]
]
]
]
];
private static Component InnerFallback(Action recover) =>
Ui.Callout.Warning.Icon(Ui.IconName.ShieldExclamation).Id("boom-nested-inner-fallback").Role("alert")
.Actions(Ui.Button.Icon(Ui.IconName.ArrowUturnLeft)
.Id("boom-nested-inner-recover")
.OnClick(recover)["Recover inner"])[
Ui.CalloutHeading["The inner part stopped."],
Ui.CalloutText["Everything around it is still running. Recover inner brings it back."]
];
private static UiCallout OuterFallback() =>
Ui.Callout.Danger.Id("boom-nested-outer-fallback").Role("alert")
.Heading("This example stopped.").Text("Reload the page to bring it back.");
private static void ThrowFromInnerHandler() =>
throw new InvalidOperationException("kaboom — inner boundary demo");
}Outer healthy region — stays mounted while the inner boundary trips.
Inner boundary subtree.
A handler that refuses
Not every exception is a fault. When the app's own code says no in a sentence written for the reader — an
exception that carries field failures (IFieldFailures, which RaskValidationException is) or a
System.ComponentModel.DataAnnotations.ValidationException — a handler that lets it through is answered
with that sentence as an error toast. No boundary is tripped, nothing is logged as an error, the page keeps
whatever the handler changed before it threw, and the next click is answered:
// The confirm button of a list's bin. The store refuses: "The trailer cannot be deleted: a waybill refers to it."
async Task Delete(Guid id)
{
await Trailer.Delete(id); // throws RaskValidationException — nothing to catch
_asking = false; // not reached, so the dialog stays open, as the handler left it
}
The sentence is the refusal's own, the same in Development and in Production. Several failures in one
exception are one toast, each different sentence once, in order. A failure that names fields is said in the
toast as well: outside a form there is no field to put it under (in a form
it goes there). The exception is recognised inside an AggregateException or a TargetInvocationException
too; a class of your own derived from ValidationException is not a refusal. Every other exception trips
the boundary you wrote around it, or is a handler that fails. A refusal in a
hand-wired host whose layout draws no toasts goes to the boundary too — there would be nowhere to read it.
A handler that fails
A handler that throws anything else — the database is down, a null where a row should be — with no
ErrorBoundary of yours around it does not replace the page. The page stays as the handler left it, the
reader gets one error toast, and the next click is answered:
That did not work. Please try again. The problem has been recorded. If it keeps happening, quote this reference: K7WQ2MZP
// No try/catch, no boundary: the list, its filter text and the open dialog are all still there.
async Task Delete(Guid id)
{
_attempts++; // rendered: it happened before the throw
await Trailer.Delete(id); // throws SqlException
_asking = false; // not reached
}
Nothing of the exception is in the toast or in the frame that carries it outside Development; it is logged
once at Error on Rask.Errors with the reference the toast shows
(what a visitor sees). In Development the toast
adds the exception's type and message, and the overlay shows the rest. The sentence is
RaskString.HandlerFailed (localization).
Which of the two a fault gets is decided by where the handler was written:
| The handler | What the reader gets |
|---|---|
| is written in a component with no boundary of yours around it | the toast; the page stays |
is written in a component inside an ErrorBoundary you wrote |
that boundary's fallback, as above — a boundary is a request to take that part over |
is the page's, handed to a child or a kit control (Ui.Button.OnClick(Delete), Ui.Pagination.OnPage, a select's OnChange) |
decided by the page that wrote it, not by where the control sits |
throws after an await |
the same as before one |
| is a form's submit | the form's own sentence, The save failed. Please try again. |
throws OperationCanceledException |
nothing: the work was called off |
| runs where no toast can be drawn (a hand-wired host with no outlet) | the boundary, as before — a fault is never swallowed |
A Render or a lifecycle hook that throws is never a toast: there is no page left to keep, so the nearest
boundary — yours, or the app's own error page — takes it. To say something of your own for one failure,
catch it in the handler and raise the toast you want.