Files
Htmx/docs/Components/Tabs.md
T
2026-05-04 19:57:48 +05:00

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:

  1. Guard _tabsInit prevents double-binding
  2. Reads all .tabs-trigger and .tabs-panel elements within the root
  3. Activates the first tab on init (removes hidden, applies active class)
  4. On trigger click:
    • Deactivate all panels (set hidden, downgrade trigger class to inactive)
    • Activate the clicked panel by matching data-tab attribute
    • Apply active class to the clicked trigger

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 Id of each tab tuple is used as the data-tab attribute — 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 Content is raw HTML — HTML-encode any user-supplied values.
  • For lazy-loaded tab content, place HTMX attributes in the Content string and use hx-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 click event on .tabs-trigger elements and update location.hash.
  • Tabs do not push to the URL hash by default. If you need deep-linkable tabs, listen to the click event on .tabs-trigger elements and update location.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");
    }
}