Tabs with lazy content
Each tab's body loads on first reveal, not when the page renders. Cuts initial HTML size and skips queries the user may never look at.
#When to use
Tab panels with heavy content — activity feeds, audit logs, analytics — where loading everything up front is wasteful.
#With + HTMX
Use the bundled component for the tab UI, and put a hx-trigger="revealed once" element inside each lazy panel:
<noundry-tabs style="default">
<noundry-tab-item title="Overview" active="true">
@* Always loaded with the page. *@
<partial name="_Overview" model="Model" />
</noundry-tab-item>
<noundry-tab-item title="Activity">
<div hx-get="/projects/@Model.Id/activity"
hx-trigger="revealed once"
hx-swap="outerHTML">
<div class="animate-pulse text-slate-400">Loading activity…</div>
</div>
</noundry-tab-item>
<noundry-tab-item title="Audit log">
<div hx-get="/projects/@Model.Id/audit"
hx-trigger="revealed once"
hx-swap="outerHTML">
<div class="animate-pulse text-slate-400">Loading audit log…</div>
</div>
</noundry-tab-item>
</noundry-tabs>
hx-trigger="revealed once" fires when the element scrolls into view AND only the first time. Once the panel is loaded it stays in the DOM, so subsequent tab switches don't refetch.
#Handler
Expose the partial as its own minimal route:
public class ActivityModel : PageModel
{
private readonly IActivityService _activity;
public ActivityModel(IActivityService activity) => _activity = activity;
public IReadOnlyList<ActivityEntry> Items { get; private set; } = [];
public async Task<IActionResult> OnGetAsync(int id, CancellationToken ct)
{
Items = await _activity.RecentAsync(id, ct);
return Partial("_ActivityList", Items);
}
}
Or with Minimal API:
app.MapGet("/projects/{id:int}/activity", async (int id, IActivityService svc, CancellationToken ct) =>
{
var items = await svc.RecentAsync(id, ct);
return Results.Extensions.RazorComponent("_ActivityList", items);
});
#Why revealed not intersect
hx-trigger="intersect once" fires the moment any pixel of the element enters the viewport, even with display: none. revealed waits for it to actually become visible (CSS-displayed) — which is what you want when tabs flip via Alpine's x-show.
#Related
- Polling for progress — for tabs that need ongoing refresh once loaded.
- Infinite scroll — same
revealed/intersecttrigger family.