N
data-display datatable@2.0.0

Datatable

Rich data table with search, sort, paginate, row actions, expandable rows — `items` or `api-url` binding.

Install

bash
ndui add datatable

Source files land in Components/Datatable/, namespace-rewritten to your project's root namespace. The component CSS is wired into wwwroot/css/app.css automatically.

Dependencies

NuGet packages
  • Noundry.UI

See it in action

Every registered variant, state, and interaction pattern is demonstrated at the live showcase:

Deep dive — a wired products page in ~25 lines of Razor

DataTable is Studio's flagship recipe — a facade over Noundry.UI's TagHelper plus the opinionated server-side pattern (Post-Redirect-Get, TempData toasts, modal create/edit) the starter already demonstrates. The rest of this page is that complete working page: the Product entity, a ProductService over Tuxedo, the PageModel handlers, and the single .cshtml that ties them together.

#The data layer

A small Tuxedo-backed service over IDbConnection — sort-column whitelist, cancellation-aware. (Use whatever data layer you prefer; Studio doesn't require Tuxedo.)

csharp
// Features/Products/Services/ProductService.cs
public sealed record Product(int Id, string Name, decimal Price, int Stock,
                              bool InStock, bool Featured, ProductCategory Category,
                              DateOnly? ReleaseDate, DateTimeOffset CreatedAt);

public sealed class ProductService(IDbConnection db)
{
    public async Task<(IReadOnlyList<Product> Items, int Total)> ListAsync(
        string? search, string sort, string dir, int page, int pageSize,
        CancellationToken ct = default)
    {
        // …parameterized SELECT + COUNT, sort-column whitelist enforced…
    }

    public async Task<bool> DeleteAsync(int id, CancellationToken ct = default) =>
        await db.ExecuteAsync("DELETE FROM products WHERE id = @id", new { id }, ct) == 1;
}

See the full shipping source in the starter at Features/Products/Services/ProductService.cs — it's 200 lines and handles search, whitelist sort, pagination, SKU-exists checks, and stats roll-ups.

#The PageModel

csharp
// Features/Products/Pages/Index.cshtml.cs
public sealed class IndexModel(ProductService products) : PageModel
{
    public IReadOnlyList<Product> Items { get; private set; } = [];

    [BindProperty]
    public ProductEditForm NewProduct { get; set; } = new();

    public async Task<IActionResult> OnGetAsync(CancellationToken ct)
    {
        (Items, _) = await products.ListAsync(null, "name", "asc", 1, 1000, ct);
        return Page();
    }

    public async Task<IActionResult> OnPostCreateAsync(CancellationToken ct)
    {
        if (!ModelState.IsValid) { /* re-render; keep modal open */ return Page(); }
        await products.CreateAsync(NewProduct, ct);
        this.AddToast($"Created {NewProduct.Name}.", ToastLevel.Success);
        return RedirectToPage();   // Post-Redirect-Get; toast survives via TempData
    }

    public async Task<IActionResult> OnPostDeleteAsync(int id, CancellationToken ct)
    {
        await products.DeleteAsync(id, ct);
        this.AddToast("Product deleted.", ToastLevel.Success);
        return RedirectToPage();
    }
}

#The view — one does it all

Noundry.UI's data-table handles search, sort, and pagination client-side once you've shipped it a collection. The server's job is just to hand it Model.Items; the TagHelper takes care of the rest.

cshtml
@page "/products"
@model __ROOT_NAMESPACE__.Pages.Products.IndexModel

<header class="flex items-end justify-between mb-6">
    <h1 class="text-3xl font-semibold">Products</h1>
    <button @@click="$dispatch('open-modal', 'product_new')"
            class="rounded-xl bg-indigo-600 text-white px-4 py-2.5 font-semibold">
        New product
    </button>
</header>

<noundry-data-table items="Model.Items"
                    per-page="10"
                    show-search="true"
                    search-placeholder="Search name or SKU…"
                    show-pagination="true"
                    striped="true" hoverable="true">
    <noundry-data-table-column key="Sku"      label="SKU"      sortable="true" href="/products/{Id}" />
    <noundry-data-table-column key="Name"     label="Name"     sortable="true" href="/products/{Id}" />
    <noundry-data-table-column key="Category" label="Category" sortable="true" />
    <noundry-data-table-column key="Price"    label="Price"    type="currency" sortable="true" align="right" />
    <noundry-data-table-column key="Stock"    label="Stock"    sortable="true" align="right" />
    <noundry-data-table-column key="InStock"  label="Status"   type="badge"   badge-colors="True=green,False=red" />
</noundry-data-table>

<noundry-modal id="product_new" title="New product" size="lg">
    <form method="post" asp-page-handler="Create">
        <partial name="_ProductForm" model="Model.NewProduct" />
    </form>
</noundry-modal>

That's it. 25 lines for a catalog with search, sort, paginate, row-links, inline status badges, and a create modal. The TagHelper writes the Alpine state, the Tailwind classes, and the pagination buttons for you.

#How the flow works

Noundry.UI's data-table is client-side everything — it filters, sorts, and paginates the array you handed it without round-tripping. You ship all rows on first load (or use api-url= for an API-driven grid when your dataset doesn't fit in a single request).

For mutations (Create / Delete), the pattern is Post-Redirect-Get:

  1. User submits the create form inside the modal → POST /products?handler=Create
  2. Server validates, saves, calls this.AddToast("Created.", ToastLevel.Success)
  3. Server returns RedirectToPage() → 303 See Other → GET /products
  4. Browser reloads the page; surfaces the buffered toast via its global window.toast.success() API
  5. The modal is gone because it's a fresh page render

No fragment swap, no server-dispatched close event — just a clean HTTP redirect. The modal's open-modal event is window-scoped so any button dispatching $dispatch('open-modal', 'product_new') opens it.

#Column types

Noundry's data-table-column has a type attribute that changes how values render:

typeRenders
text (default)Plain text
badgeColored pill; use badge-colors="Active=green,Inactive=red"
boolean✓ / ✗ icons
currencyLocale-formatted; use currency="USD" for a different ISO code
dateShort date
actionsRow actions via actions="edit,delete" + action-event="product"

#Variants

#Server-side data (API-driven grid)

For datasets too large to ship up front, use the modern instead — it fetches from an endpoint:

cshtml
<noundry-data-grid api-url="/api/products"
                   page-size="25"
                   default-sort-column="name"
                   enable-search="true"
                   enable-refresh="true">
    <noundry-data-grid-col key="name"  label="Name"  sortable="true" />
    <noundry-data-grid-col key="price" label="Price" type="currency" sortable="true" />
</noundry-data-grid>

The grid expects JSON { "data": [...], "total": N } from the endpoint (or a plain array with X-Total-Count header).

#Expandable rows

child expands a row to show detail pulled from a per-row API call. See the Noundry.UI component docs for the full attribute reference.

  • Chart — the visual cousin for dashboards.
  • Dialog — the modal create/edit flow used above (Noundry.UI calls it ; Studio kept the dialog name for catalog symmetry).
  • Inline validation pattern — how ModelState.IsValid keeps the modal open with errors.
  • Noundry.UI reference — authoritative attribute list.

Related

Browse all data display · CLI reference · reactivity patterns.