N
data-display datatable

Data Table

Sortable, filterable, paginated table — the flagship.

$ ndui add datatable
Component docs

Sort · search · paginate · delete

18 results
ID Name Price Stock
#118 Wool Slippers $46.00 Out
#112 Wool Rug $219.00 Low · 7
#115 Walnut Cutting Board $74.00 33
#110 Sourdough Starter $12.00 400
#114 Pour-over Kit $42.00 96
#104 Oak Bookshelf $349.00 Low · 3
#107 Linen Throw Blanket $64.00 45
#103 Handcrafted Chair $129.00 Low · 8
Showing 1–8 of 18
This demo is fully wired — typing searches live, column headers sort, pagination pages, and Delete removes rows. Every interaction is a GET/POST that returns the _ProductsTable partial, morphed in by Alpine AJAX. State is in-memory for the demo; in a real app this would hit Tuxedo over SQLite.

Razor source — the view

<DataTable TItem="Product"
           Items="@Model.PageItems" TotalCount="@Model.Total"
           Page="@Model.Page" PageSize="@DatatableModel.PageSize"
           SortColumn="@Model.Sort" SortDirection="@Model.Dir"
           SearchQuery="@Model.Search"
           TargetId="products_table"
           FormAction="/dashboard/products">
    <Columns>
        <Column TItem="Product" Field="Id"    Header="#"   Width="4rem" />
        <Column TItem="Product" Field="Name"  Header="Name"  Sortable="true" />
        <Column TItem="Product" Field="Price" Header="Price" Sortable="true" Width="8rem">
            <CellTemplate>@string.Format("{0:C}", context.Price)</CellTemplate>
        </Column>
        <Column TItem="Product" Field="Stock" Header="Stock" Sortable="true" Width="6rem">
            <CellTemplate>
                @if (context.Stock == 0) { <Badge Variant="BadgeVariant.Destructive">Out</Badge> }
                else if (context.Stock < 10) { <Badge Variant="BadgeVariant.Warning">Low</Badge> }
                else { <span>@context.Stock</span> }
            </CellTemplate>
        </Column>
    </Columns>
</DataTable>

C# source — the PageModel

public sealed class IndexModel(ProductService products) : PageModel
{
    public IReadOnlyList<Product> Items { get; private set; } = [];
    public int Total { get; private set; }

    [BindProperty(SupportsGet = true, Name = "q")]    public string? Search { get; set; }
    [BindProperty(SupportsGet = true, Name = "sort")] public string  Sort   { get; set; } = "name";
    [BindProperty(SupportsGet = true, Name = "dir")]  public string  Dir    { get; set; } = "asc";
    [BindProperty(SupportsGet = true, Name = "page")] public int     Page   { get; set; } = 1;

    public async Task<IActionResult> OnGetAsync(CancellationToken ct)
    {
        (Items, Total) = await products.ListAsync(Search, Sort, Dir, Page, PageSize: 20, ct);
        return this.PageOrPartial("_ProductsTable", this);
    }

    public async Task<IActionResult> OnPostDeleteAsync(int id, CancellationToken ct)
    {
        if (!await products.DeleteAsync(id, ct)) return NotFound();
        this.AddToast("Product deleted.", ToastLevel.Success);
        (Items, Total) = await products.ListAsync(Search, Sort, Dir, Page, 20, ct);
        return this.PageOrPartial("_ProductsTable", this);
    }
}

Three pieces coordinate: [BindProperty(SupportsGet = true)] rehydrates query params into typed properties; PageOrPartial chooses full page vs. fragment based on the X-Alpine-Request header; AddToast plus the registered sync block show the delete confirmation with no per-form wiring.

Full walkthrough: Docs → DataTable deep-dive.