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.GetRowsAsync(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).
Items mode — a fixed in-memory list, windowed:
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(new Dictionary<string, string?> { ["testid"] = "virtualize-scroller" })
.OnScroll(ctx.OnScroll)[
Table
.Class($"{Tw.Table} text-sm mb-0")
.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(new Dictionary<string, string?>
{
["row-index"] = item.Index.ToString(),
["rask-key"] = item.Index.ToString()
})[
Td[item.Value?.Index.ToString() ?? ""],
Td[item.Value?.Name ?? ""],
Td[item.Value?.City ?? ""],
Td.Style("text-align:right;")[item.Value?.Balance.ToString("0.00") ?? ""]
];
}
yield return Spacer(ctx.OffsetAfter, "spacer-after");
}
private static Component Spacer(int height, string key) =>
Tr
.Style($"height:{height}px;")
.Data(new Dictionary<string, string?> { ["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 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 OnUnmount (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(new Dictionary<string, string?> { ["testid"] = "virtualize-async-scroller" })
.OnScroll(ctx.OnScroll)[
Table
.Class($"{Tw.Table} text-sm mb-0")
.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);
// 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(new Dictionary<string, string?>
{
["row-index"] = item.Index.ToString(),
["rask-key"] = item.Index.ToString(),
["placeholder"] = item.IsPlaceholder ? "true" : null
})[
Td[item.IsPlaceholder ? "—" : item.Value!.Index.ToString()],
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")]
];
}
yield return Spacer(ctx.OffsetAfter, "spacer-after");
}
private static Component Spacer(int height, string key) =>
Tr
.Style($"height:{height}px;")
.Data(new Dictionary<string, string?> { ["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.Length - 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.Length);
}
}
| # | 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.
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")[
Button
.Class(_useKeys ? $"{Tw.BtnSuccess}" : $"{Tw.BtnOutlineSecondary}")
.Id("kl-toggle-keys")
.OnClick(() => _useKeys = !_useKeys)[
UiIcon.Name(_useKeys ? UiIconName.Key : UiIconName.Key).Class("me-1"),
_useKeys ? "Keys: ON" : "Keys: OFF"
],
Span.Class("vr mx-1"),
Button.Type("button").Class(Tw.BtnOutlinePrimary).Id("kl-rotate").OnClick(Rotate)[
UiIcon.Name(UiIconName.ArrowsUpDown).Class("me-1"), "Rotate"
],
Button.Type("button").Class(Tw.BtnOutlinePrimary).Id("kl-reverse").OnClick(Reverse)[
UiIcon.Name(UiIconName.Retry).Class("me-1"), "Reverse"
],
Button.Type("button").Class(Tw.BtnOutlinePrimary).Id("kl-add").OnClick(AddTop)[
UiIcon.Name(UiIconName.Plus).Class("me-1"), "Add to top"
],
Button.Type("button").Class(Tw.BtnOutlineDanger)
.Id("kl-remove")
.Disabled(_items.Count == 0)
.OnClick(RemoveTop)[
UiIcon.Name(UiIconName.Minus).Class("me-1"), "Remove top"
]
],
Ul.Class(Tw.ListGroup).Id("kl-list")[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
? Li.Key(f.Id).Class($"{Tw.ListGroupItem} flex items-center gap-3")[Row(f, i)]
: Li.Class($"{Tw.ListGroupItem} flex items-center gap-3")[Row(f, i)]);
#pragma warning restore RASK022
}
return rows;
}
private static List<Component> Row(Fruit f, int index) =>
[
Span.Class(Tw.BadgeSecondary)[index + 1],
Span.Class("font-semibold").Style("min-width: 7rem;")[f.Name],
Input.Value<string>(null)
.Type(InputType.Text)
.Class($"{Tw.Input} kl-note")
.Placeholder("type here, then reorder…")
];
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);
}
- 1Apple
- 2Banana
- 3Cherry
- 4Date
- 5Elderberry
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) — the expander column has no label and no sort.
private static readonly (string Id, string Header, bool Sortable)[] _orderColumns =
[
("expander", "", false),
("customer", "Customer", true),
("placed", "Placed", true),
("status", "Status", true),
("items", "Items", true),
("total", "Total", true)
];
private static readonly (string Id, string Header)[] _itemColumns =
[
("sku", "SKU"),
("product", "Product"),
("qty", "Qty"),
("unit", "Unit price"),
("line", "Line total")
];
// 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 Div.Class($"{Tw.Card} shadow-sm border-0")[
Div.Class("overflow-x-auto")[
Table.Id("md-orders").Class($"{Tw.Table} [&_tbody_tr:hover]:bg-ui-well align-middle mb-0")[
Thead.Class("bg-ui-well")[
Tr[_orderColumns.Select(c =>
c.Sortable
? SortHeader(c.Id, c.Header, _orderSort, ToggleOrderSort)
: Th.Scope("col").Key(c.Id))]
],
Tbody[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(Tr.Key(order.Id).Class("md-row")[
Td.Style("width:44px;")[
Button
.Class($"{Tw.BtnLink} p-0 no-underline")
.Data(new Dictionary<string, string?> { ["testid"] = $"expander-{order.Id}" })
.OnClick(() => Toggle(order.Id))[
UiIcon.Name(open ? UiIconName.ChevronDown : UiIconName.ChevronRight)
]
],
Td.Class("font-semibold")[order.Customer],
Td.Class("text-ui-muted text-sm")[order.Placed.ToString("yyyy-MM-dd")],
Td[Span.Class(StatusBadge(order.Status))[order.Status]],
Td.Class("text-ui-muted")[order.Items.Count],
Td.Style("text-align:right; font-variant-numeric:tabular-nums;")[
"$" + order.Total.ToString("N2", CultureInfo.InvariantCulture)
]
]);
if (open)
{
rows.Add(Tr.Key($"detail-{order.Id}").Class("md-detail")[
Td.Colspan(_orderColumns.Length).Class("p-0 bg-ui-well")[
Div
.Class("p-3")
.Data(new Dictionary<string, string?> { ["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 Table.Class($"{Tw.Table} text-sm [&_tbody_tr:nth-child(odd)]:bg-ui-well align-middle mb-0 bg-white")[
Thead[
Tr[_itemColumns.Select(c =>
SortHeader(c.Id, c.Header, sort, col => ToggleItemSort(order.Id, col)))]
],
Tbody[
items.Select(it =>
Tr.Key(it.Id)[
Td[Code[it.Sku]],
Td[it.Product],
Td.Class("text-ui-muted")[it.Qty],
Td.Style("text-align:right; font-variant-numeric:tabular-nums;")[
"$" + it.UnitPrice.ToString("N2", CultureInfo.InvariantCulture)
],
Td.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.Col != col ? (col, true)
: current.Asc ? (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) : view.OrderByDescending(o => o.Customer),
"placed" => asc ? view.OrderBy(o => o.Placed) : view.OrderByDescending(o => o.Placed),
"status" => asc ? view.OrderBy(o => o.Status) : view.OrderByDescending(o => o.Status),
"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) : view.OrderByDescending(i => i.Sku),
"product" => asc ? view.OrderBy(i => i.Product) : view.OrderByDescending(i => i.Product),
"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.
private static Component SortHeader(string columnId, string header, (string Col, bool Asc) sort,
Action<string> toggle)
{
var sorted = sort.Col == columnId;
var icon = sorted
? sort.Asc ? UiIconName.ChevronUp : UiIconName.ChevronDown
: UiIconName.ArrowsUpDown;
return Th.Scope("col").Key(columnId)[
Button
.Type("button")
.Class($"{Tw.BtnLink} p-0 no-underline text-ui-ink font-semibold inline-flex" +
"items-center gap-1")
.OnClick(() => toggle(columnId))[
Span[header],
UiIcon.Name(icon).Class(sorted ? "text-xs" : "text-xs opacity-50")
]
];
}
private static string StatusBadge(string status) => status switch
{
"Shipped" => Tw.BadgeSuccess,
"Processing" => Tw.BadgePrimary,
"Pending" => Tw.BadgeWarning,
"Cancelled" => Tw.BadgeDanger,
_ => Tw.BadgeSecondary
};
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)
};
var rng = new Random(42);
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
IToaster is Rask's take on flash messages — transient, consumed-once user messages that survive a
client-side navigation. Inject it and queue a message; a single ToastOutlet shows it once.
public sealed partial class SavePage(IToaster toast, Navigator nav) : Component
{
private void Save()
{
// ... persist ...
toast.Success("Your changes were saved."); // Info / Warning / Error / Add(level, …) too
nav.NavigateTo(Routes.ListPage()); // the message survives the navigation
}
// ...
}
Why it survives the navigation: IToaster is registered scoped per session (a Server WebSocket
session or a WASM app instance), and a client-side NavigateTo does not recreate the session — so a
message queued before navigating is still in the queue when the destination mounts.
Show them by mounting one outlet in your app layout. The headless ToastOutlet ships no markup —
you own it through Template, which receives the messages plus a dismiss(id) callback:
ToastOutlet.Template((messages, dismiss) =>
Div[messages.Select(m => (Component)Div.Class("notice").Key(m.Id.ToString())[
m.Message,
Button.OnClick(() => dismiss(m.Id))["×"]])])
ToastOutlet calls Consume() (which drains the queue) on mount and whenever IToaster.Changed fires,
so each message is delivered to exactly one outlet and never reappears on a later render. Set
AutoDismissAfter to have each message clear itself after a delay — a one-shot timer per message that
runs the same dismiss path, so any Template auto-dismisses even when its element has no timer of its own.
A toast outlet is a fixed container of messages that auto-hide after
5 s by default (set AutoHideMs: null to keep them sticky); mount a single BsToaster() in your layout
instead of writing a Template. Queue one, show once (this demo auto-dismisses after 5 s):
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)
{
var rows = new List<Component>(_fruits.Count);
for (var i = 0; i < _fruits.Count; i++)
{
var fruit = _fruits[i];
var index = i;
var cls = "list-group-item d-flex align-items-center gap-2 dd-item";
if (ctx.IsSource("list", index))
{
cls += " dd-dragging";
}
if (ctx.IsDropTarget("list", index))
{
cls += " dd-drop-target";
}
rows.Add(Li
.Key(fruit)
.Class(cls)
.Draggable(true)
.OnDragStart(ctx.DragStart("list", index))
.OnDragOver(ctx.DragOver("list", index))
.OnDropAsync(ctx.Drop("list", index))
.OnDragEnd(ctx.DragEnd)
.Data(new Dictionary<string, string?> { ["testid"] = $"fruit-{index}" })[
UiIcon.Name(UiIconName.Grip).Class("text-ui-muted"),
Span.Class("font-semibold")[fruit]
]);
}
return Ul.Class($"{Tw.ListGroup} dd-list").Id("dd-fruit-list")[rows];
}
// 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()
{
["todo"] = "To do",
["doing"] = "In progress",
["done"] = "Done"
};
private readonly Dictionary<string, List<Card>> _board = new()
{
["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)
{
var cols = new List<Component>(_columns.Length);
foreach (var zone in _columns)
{
var cards = _board[zone];
var cardChildren = new List<Component>(cards.Count);
for (var i = 0; i < cards.Count; i++)
{
var card = cards[i];
var index = i;
var cls = "card dd-card";
if (ctx.IsSource(zone, index))
{
cls += " dd-dragging";
}
if (ctx.IsDropTarget(zone, index))
{
cls += " dd-drop-target";
}
cardChildren.Add(Div
.Key(card.Id)
.Class(cls)
.Draggable(true)
.OnDragStart(ctx.DragStart(zone, index))
.OnDragOver(ctx.DragOver(zone, index))
.OnDropAsync(ctx.Drop(zone, index))
.OnDragEnd(ctx.DragEnd)
.Data(new Dictionary<string, string?> { ["testid"] = $"card-{card.Id}" })[
Div.Class($"{Tw.CardBody} p-2 flex items-center gap-2")[
UiIcon.Name(UiIconName.Grip).Class("text-ui-muted"),
Span[card.Title]
]
]);
}
// 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";
}
cols.Add(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]],
Span.Class(Tw.BadgeSecondary)[cards.Count.ToString()]
],
Div
.Class(bodyCls)
.OnDragOver(ctx.DragOver(zone, dropAtEnd))
.OnDropAsync(ctx.Drop(zone, dropAtEnd))
.Data(new Dictionary<string, string?> { ["testid"] = $"col-{zone}" })[cardChildren]
]
]);
}
return Div.Class("grid grid-cols-12 gap-4 dd-board")[cols];
}
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 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.
namespace Rask.Site.Features;
// ErrorBoundary catches exceptions thrown by a descendant's event handler and
// renders the Fallback in place of the subtree. The fallback receives a
// recover() callback that clears the boundary's error and re-renders the
// healthy subtree.
public sealed partial class BoomHandlerDemo : Component
{
protected override Component? Render() =>
ErrorBoundary
.Fallback(BoundaryFallback)[
Div.Class("p-3 border rounded bg-white").Id("boom-handler-host")[
P.Class("text-ui-muted text-sm mb-2")["Healthy subtree — click to throw."],
Button.Type("button").Class(Tw.BtnDanger).Id("boom-throw").OnClick(ThrowFromHandler)[UiIcon.Name(UiIconName.Warning).Class("me-2"),
"Throw a handler exception"]
]
];
private static Component BoundaryFallback(Exception ex, Action recover) =>
Div.Class($"{Tw.AlertDanger} flex items-start").Id("boom-fallback")[
UiIcon.Name(UiIconName.Warning).Class("me-3 size-6"),
Div[
Strong["Boundary caught: "],
Code.Class("ms-1")[ex.GetType().Name],
P.Class("mb-2 mt-1 text-sm")[ex.Message],
Button.Type("button").Class(Tw.BtnOutlineSecondary).Id("boom-recover").OnClick(recover)[UiIcon.Name(UiIconName.Undo).Class("me-1"), "Recover"]
]
];
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.
(ex, recover) => BoundaryFallback(ex, () =>
{
// 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-ui-muted text-sm mb-2")["Healthy. Click below to make my next render throw."],
Button.Type("button").Class(Tw.BtnWarning).Id("boom-render-trigger").OnClick(() => _throwOnRender = true)[UiIcon.Name(UiIconName.Bug).Class("me-2"), "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
]
];
private static Component BoundaryFallback(Exception ex, Action recover) =>
Div.Class($"{Tw.AlertDanger} flex items-start").Id("boom-fallback")[
UiIcon.Name(UiIconName.Warning).Class("me-3 size-6"),
Div[
Strong["Boundary caught: "],
Code.Class("ms-1")[ex.GetType().Name],
P.Class("mb-2 mt-1 text-sm")[ex.Message],
Button.Type("button").Class(Tw.BtnOutlineSecondary).Id("boom-recover").OnClick(recover)[UiIcon.Name(UiIconName.Undo).Class("me-1"), "Recover"]
]
];
// 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.
public sealed partial class BoomNestedDemo : Component
{
protected override Component? Render() =>
ErrorBoundary.Fallback((ex, _) => OuterFallback(ex))[
Div.Class("p-3 border rounded bg-white").Id("boom-nested-host")[
P
.Class("mb-2 text-sm text-ui-muted")
.Id("boom-nested-outer-healthy")[
"Outer healthy region — stays mounted while the inner boundary trips."],
ErrorBoundary.Fallback((ex, recover) => InnerFallback(ex, recover))[
Div.Class("p-3 border rounded bg-ui-well")[
P.Class("text-sm text-ui-muted mb-2")["Inner boundary subtree."],
Button.Type("button").Class(Tw.BtnDanger)
.Id("boom-nested-throw")
.OnClick(ThrowFromInnerHandler)[UiIcon.Name(UiIconName.Warning).Class("me-2"),
"Throw inside inner boundary"]
]
]
]
];
private static Component InnerFallback(Exception ex, Action recover) =>
Div.Class($"{Tw.AlertWarning} flex items-start")
.Id("boom-nested-inner-fallback")[
UiIcon.Name(UiIconName.ShieldWarning).Class("me-3 size-6"),
Div[
Strong["Inner boundary caught: "],
Code.Class("ms-1")[ex.GetType().Name],
P.Class("mb-2 mt-1 text-sm")[ex.Message],
Button.Type("button").Class(Tw.BtnOutlineSecondary)
.Id("boom-nested-inner-recover")
.OnClick(recover)[UiIcon.Name(UiIconName.Undo).Class("me-1"), "Recover inner"]
]
];
private static Component OuterFallback(Exception ex) =>
Div.Class(Tw.AlertDanger).Id("boom-nested-outer-fallback")[
Strong["Outer boundary caught: "], ex.Message
];
private static void ThrowFromInnerHandler() =>
throw new InvalidOperationException("kaboom — inner boundary demo");
}
Outer healthy region — stays mounted while the inner boundary trips.
Inner boundary subtree.