ee8797c142
Co-authored-by: Copilot <copilot@github.com>
6.2 KiB
6.2 KiB
Tabs
A tabbed interface. One tab panel is visible at a time. The active tab has a highlighted style; all others are hidden. Client-side JS switches panels without a server round-trip.
HTML structure
div[id].tabs-root
div.tabs-list.flex.gap-1.border-b.mb-4 ← tab button strip
button.tabs-trigger[data-tab={tabId}] ← one per tab; ACTIVE/INACTIVE variant
{label}
div.tabs-panel[data-tab={tabId}] ← one per tab; hidden or visible
{content}
CSS mechanics
| Class | Effect |
|---|---|
tabs-trigger |
px-4 py-2 text-sm font-medium rounded-t-md -mb-px |
| Active trigger | bg-background border border-b-0 border-border text-foreground |
| Inactive trigger | text-muted-foreground hover:text-foreground hover:bg-muted/40 |
tabs-panel[hidden] |
display: none via the HTML hidden attribute |
JavaScript (initTabs in components.js)
Runs on DOMContentLoaded and htmx:afterSwap.
Per-instance initialization:
- Guard
_tabsInitprevents double-binding - Reads all
.tabs-triggerand.tabs-panelelements within the root - Activates the first tab on init (removes
hidden, applies active class) - On trigger click:
- Deactivate all panels (set
hidden, downgrade trigger class to inactive) - Activate the clicked panel by matching
data-tabattribute - Apply active class to the clicked trigger
- Deactivate all panels (set
Constructor signature
public Tabs(
string id,
IEnumerable<(string Id, string Label, string Content)> tabs)
| Parameter | Description |
|---|---|
id |
Root element id — must be unique per page if multiple Tabs are rendered |
tabs |
List of (Id, Label, Content) tuples; Id must be unique within this instance |
Usage examples
Simple tabbed content
new Tabs(
id: "settings-tabs",
tabs: new[]
{
("general", "General", "<p>General settings content here.</p>"),
("security", "Security", "<p>Security settings content here.</p>"),
("billing", "Billing", "<p>Billing details here.</p>"),
})
HTML-rich content in a tab
new Tabs(
id: "code-tabs",
tabs: new[]
{
("csharp", "C#", "<pre><code>var x = 42;</code></pre>"),
("fsharp", "F#", "<pre><code>let x = 42</code></pre>"),
("vb", "VB.NET", "<pre><code>Dim x As Integer = 42</code></pre>"),
})
Embedding a full component in a tab
// Pre-render the inner component to HTML string
var buf = new System.Buffers.ArrayBufferWriter<byte>();
new Table(headers: cols, rows: data).Render(new HtmxRenderContext(buf));
var tableHtml = System.Text.Encoding.UTF8.GetString(buf.WrittenSpan);
new Tabs(
id: "report",
tabs: new[]
{
("summary", "Summary", "<p>High level numbers.</p>"),
("detail", "Detail", tableHtml),
})
Multiple independent tab groups
new Tabs(id: "tabs-a", tabs: setA)
new Tabs(id: "tabs-b", tabs: setB)
The id scopes JS initialization — each Tabs instance is independent.
Tips and tricks
- The
Idof each tab tuple is used as thedata-tabattribute — keep it URL-safe and unique within the instance. - The first tab is always activated on page load regardless of which tab was active before navigation.
- Tab
Contentis raw HTML — HTML-encode any user-supplied values. - For lazy-loaded tab content, place HTMX attributes in the
Contentstring and usehx-trigger="revealed"to load content when the panel becomes visible. - Tabs do not push to the URL hash by default. If you need deep-linkable tabs, listen to the
clickevent on.tabs-triggerelements and updatelocation.hash. - Tabs do not push to the URL hash by default. If you need deep-linkable tabs, listen to the
clickevent on.tabs-triggerelements and updatelocation.hash.
Complete page example
Templates/ProfileSettingsPage.htmx
<div class="max-w-2xl mx-auto py-10">
<h1 class="text-2xl font-bold mb-6">Profile settings</h1>
$$SettingsTabs$$
</div>
Templates/ProfileSettingsPage.htmx.cs
namespace Htmx.ApiDemo.Templates;
public sealed class ProfileSettingsPage : ProfileSettingsPageBase
{
private readonly IHtmxComponent _tabs;
public ProfileSettingsPage(ApplicationUser user, IAntiforgery af, HttpContext ctx)
{
var tokens = af.GetAndStoreTokens(ctx);
var afHtml = $"""<input type="hidden" name="{tokens.FormFieldName}" value="{tokens.RequestToken}">""";
// Build each tab's content as raw HTML strings rendered into the Tabs component
var generalContent = $"""
<form method="post" action="/profile/general">
{afHtml}
<label class="block mb-1 text-sm">Display name</label>
<input name="displayName" value="{System.Net.WebUtility.HtmlEncode(user.DisplayName ?? "")}"
class="input mb-4 w-full">
<button class="btn" type="submit">Save</button>
</form>
""";
var securityContent = $"""
<form method="post" action="/profile/password">
{afHtml}
<label class="block mb-1 text-sm">New password</label>
<input name="newPassword" type="password" class="input mb-4 w-full">
<button class="btn" type="submit">Change password</button>
</form>
""";
_tabs = new Components.Tabs(
defaultValue: "general",
tabs: new[]
{
new TabItem(Value: "general", Label: "General", Content: generalContent),
new TabItem(Value: "security", Label: "Security", Content: securityContent),
});
}
protected override void RenderSettingsTabs(HtmxRenderContext ctx) => _tabs.Render(ctx.Next());
}
GET handler
[Handler]
[MapGet("/profile")]
public static partial class GetProfileSettingsHandler
{
public record Query();
private static async Task<IResult> HandleAsync(
Query _, HttpContext ctx, MongoDbService db, IAntiforgery af, CancellationToken ct)
{
var user = await db.GetCurrentUserAsync(ctx, ct);
return await ctx.WriteHtmxPage(
new ProfileSettingsPage(user, af, ctx), title: "Profile settings");
}
}