Tabs
JavaScript off: what the HTML and CSS do on their own.
Usage
<Tabs Label="Account">
<Tab Label="Profile" Icon="Lucide.User">...</Tab>
<Tab Label="Billing" Badge="2">...</Tab>
</Tabs>
@* Links to pages of their own. *@
<Tabs Label="Settings">
<TabLink Href="settings/profile" Label="Profile" />
<TabLink Href="settings/billing" Label="Billing" />
</Tabs>Picking a tab
Use Selected for the tab that opens first, and @bind-Selected to follow it from C# on interactive pages. A tab's value is its Value, or its label in lower case with dashes (billing-history).
Preview
Profile
Your name, photo and the email people see.
Billing
Your plan, payment method and invoices.
Security
Your password, passkeys and sessions.
C# has the tab as profile
Code
@* The same Tabs in each mode: radios and CSS with no script, ARIA tabs with IzzyBlazor's script,
and C# told of the change on an interactive page. *@
<Stack Gap="3" Grow>
<Tabs @bind-Selected="_tab" Label="Account">
<Tab Label="Profile" Icon="Lucide.User">
<TextBlock Color="Color.Muted">Your name, photo and the email people see.</TextBlock>
</Tab>
<Tab Label="Billing" Icon="Lucide.CreditCard">
<TextBlock Color="Color.Muted">Your plan, payment method and invoices.</TextBlock>
</Tab>
<Tab Label="Security" Icon="Lucide.Shield">
<TextBlock Color="Color.Muted">Your password, passkeys and sessions.</TextBlock>
</Tab>
</Tabs>
<TextBlock Size="TextSize.Sm" Color="Color.Muted">C# has the tab as <Code>(_tab ?? "profile")</Code></TextBlock>
</Stack>
{
private string? _tab;
}Off: Radio buttons and CSS switch the panels; arrow keys work. C# isn't told.
Every panel is rendered with the page, and picking a tab only shows another, so it's instant and keeps what's typed. Content that's slow to build or rarely opened is better on a page of its own, behind a TabLink.
Variants, icons and badges
Use Variant for an underline (the default), soft tabs or a segmented control. Each Tab can have an Icon and a Badge, or be Disabled.
Preview
Line
Overview
Builds, traffic and errors at a glance.
Activity
Three new deploys since yesterday.
Billing
Available on paid plans.Settings
Domains, environment variables and members.
Soft
Overview
Builds, traffic and errors at a glance.
Activity
Three new deploys since yesterday.
Billing
Available on paid plans.Settings
Domains, environment variables and members.
Segmented
Overview
Builds, traffic and errors at a glance.
Activity
Three new deploys since yesterday.
Billing
Available on paid plans.Settings
Domains, environment variables and members.
Code
<Stack Gap="6" Grow>
foreach (var variant in Enum.GetValues<TabsVariant>())
{
<Stack Gap="2">
<TextBlock Size="TextSize.Sm" Color="Color.Muted">variant</TextBlock>
<Tabs Variant="variant" Label="($"Project ({variant})")">
<Tab Label="Overview" Icon="Lucide.LayoutDashboard">
<TextBlock>Builds, traffic and errors at a glance.</TextBlock>
</Tab>
<Tab Label="Activity" Badge="3">
<TextBlock>Three new deploys since yesterday.</TextBlock>
</Tab>
<Tab Label="Billing" Disabled>Available on paid plans.</Tab>
<Tab Label="Settings" Icon="Lucide.Settings">
<TextBlock>Domains, environment variables and members.</TextBlock>
</Tab>
</Tabs>
</Stack>
}
</Stack>Sizes and full width
Use Size to match the buttons and fields around the tabs, and FullWidth to share the row between them.
Preview
Day
412 visits today.Week
2,980 visits this week.Month
12,406 visits this month.Day
412 visits today.Week
2,980 visits this week.Month
12,406 visits this month.Day
412 visits today.Week
2,980 visits this week.Month
12,406 visits this month.Code
<Stack Gap="5" Grow>
<Tabs Size="ControlSize.Sm" Variant="TabsVariant.Segmented" FullWidth Label="Range (small)">
<Tab Label="Day">412 visits today.</Tab>
<Tab Label="Week">2,980 visits this week.</Tab>
<Tab Label="Month">12,406 visits this month.</Tab>
</Tabs>
<Tabs Variant="TabsVariant.Segmented" FullWidth Label="Range">
<Tab Label="Day">412 visits today.</Tab>
<Tab Label="Week">2,980 visits this week.</Tab>
<Tab Label="Month">12,406 visits this month.</Tab>
</Tabs>
<Tabs Size="ControlSize.Lg" Variant="TabsVariant.Segmented" FullWidth Label="Range (large)">
<Tab Label="Day">412 visits today.</Tab>
<Tab Label="Week">2,980 visits this week.</Tab>
<Tab Label="Month">12,406 visits this month.</Tab>
</Tabs>
</Stack>Vertical
Use Orientation="Orientation.Vertical" to put the tabs in a column beside the panels from 640px up; on phones they stay a row above, which scrolls when it doesn't fit.
Preview
General
General
Name, description and visibility.
Members
Members
Four people have access to this project.
Integrations
Integrations
GitHub and Slack are connected.
Danger zone
Danger zone
Transfer or delete the project.
Code
<Tabs Orientation="Orientation.Vertical" Label="Project settings" Grow>
<Tab Label="General" Icon="Lucide.Settings">
<TextBlock Weight="FontWeight.Medium">General</TextBlock>
<TextBlock Size="TextSize.Sm" Color="Color.Muted">Name, description and visibility.</TextBlock>
</Tab>
<Tab Label="Members" Icon="Lucide.UsersRound" Badge="4">
<TextBlock Weight="FontWeight.Medium">Members</TextBlock>
<TextBlock Size="TextSize.Sm" Color="Color.Muted">Four people have access to this project.</TextBlock>
</Tab>
<Tab Label="Integrations" Icon="Lucide.Plug">
<TextBlock Weight="FontWeight.Medium">Integrations</TextBlock>
<TextBlock Size="TextSize.Sm" Color="Color.Muted">GitHub and Slack are connected.</TextBlock>
</Tab>
<Tab Label="Danger zone" Icon="Lucide.TriangleAlert">
<TextBlock Weight="FontWeight.Medium">Danger zone</TextBlock>
<TextBlock Size="TextSize.Sm" Color="Color.Muted">Transfer or delete the project.</TextBlock>
</Tab>
</Tabs>In the address
Use QueryKey to keep the selected tab in the query string, so a link to ?tab=billing opens Billing and a copied address keeps the tab. The address wins over Selected.
Preview
Profile
Your name, photo and the email people see.
Billing
Your plan, payment method and invoices.
Security
Your password, passkeys and sessions.
Code
NavigationManager Navigation
<Stack Gap="3" Grow>
<Tabs QueryKey="tab" Label="Account">
<Tab Label="Profile" Icon="Lucide.User">
<TextBlock Color="Color.Muted">Your name, photo and the email people see.</TextBlock>
</Tab>
<Tab Label="Billing" Icon="Lucide.CreditCard">
<TextBlock Color="Color.Muted">Your plan, payment method and invoices.</TextBlock>
</Tab>
<Tab Label="Security" Icon="Lucide.ShieldCheck">
<TextBlock Color="Color.Muted">Your password, passkeys and sessions.</TextBlock>
</Tab>
</Tabs>
<Row>
<Button Href="Navigation.GetUriWithQueryParameter("tab", "billing")" Size="ControlSize.Sm"
Variant="ButtonVariant.Outline" Accent="Accent.Neutral" StartIcon="Lucide.Link">Link to Billing</Button>
</Row>
</Stack>Off: The link opens Billing, but picking a tab doesn't change the address.
In a form
Put Tabs inside a Form to split a long form: every panel is part of it, so every field posts. When the server finds errors, as here with NativeValidation="false", the first tab with errors opens, unless the open one has some, and each tab shows how many it has.
Preview
Code
System.ComponentModel.DataAnnotations
@* Every panel is inside the form, so every field posts. NativeValidation="false" lets the
server check, so Save opens the first tab with errors and each tab counts its own. *@
<Form Model="Model" FormName="tabs-account-form" OnValidSubmit="Save" NativeValidation="false" MaxWidth="36.Rem">
<Heading Level="2" Size="TextSize.Xl">Account details</Heading>
<FormErrors />
<Tabs Label="Account details">
<Tab Label="Profile" Icon="Lucide.User">
<Stack Gap="4">
<TextField @bind-Value="Model!.Name" AutoComplete="name" />
<TextField @bind-Value="Model!.Email" AutoComplete="email" />
</Stack>
</Tab>
<Tab Label="Address" Icon="Lucide.MapPin">
<Stack Gap="4">
<TextField @bind-Value="Model!.Street" AutoComplete="street-address" />
<Row Gap="3">
<TextField @bind-Value="Model!.City" AutoComplete="address-level2" Grow />
<TextField @bind-Value="Model!.PostalCode" AutoComplete="postal-code" MaxWidth="9.Rem" />
</Row>
</Stack>
</Tab>
</Tabs>
<FormActions><Button Submit>Save</Button></FormActions>
if (_saved)
{
<Alert Status="Status.Success">Saved: Model!.Name, Model.City.</Alert>
}
</Form>
{
[SupplyParameterFromForm(FormName = "tabs-account-form")]
private Account? Model { get; set; }
private bool _saved;
// The profile is filled in and the address isn't, so Save opens the Address tab.
protected override void OnInitialized() => Model ??= new() { Name = "Ada Lovelace", Email = "ada@example.com" };
private void Save() => _saved = true;
public sealed class Account
{
[Required(ErrorMessage = "Enter your name")]
public string? Name { get; set; }
[Required(ErrorMessage = "Enter your email"), EmailAddress(ErrorMessage = "Enter an email like name@example.com")]
public string? Email { get; set; }
[Required(ErrorMessage = "Enter a street address")]
public string? Street { get; set; }
[Required(ErrorMessage = "Enter a city")]
public string? City { get; set; }
[Required(ErrorMessage = "Enter a postal code"), Display(Name = "Postal code")]
public string? PostalCode { get; set; }
}
}Off: There are no tabs: both sections show, one under the other. Save posts, and the page loads again with the server's messages.
With the browser's own checks on (the default), the post waits until they pass, and the script opens the tab of the first field the browser stops at, so its message shows.
Panel padding
Use ContentPadding to pad every panel on all sides, and a Tab's own ContentPadding="0" to run a table edge to edge.
Preview
Summary
Three deploys today, all healthy. The last one finished 12 minutes ago.
History
| Commit | Author | Time |
|---|---|---|
| a1f9c3e | Ada Lovelace | 14:02 |
| 7be20d4 | Grace Hopper | 11:47 |
| c03e8a1 | Alan Turing | 09:15 |
Code
@* ContentPadding pads every panel on all sides (the tab list keeps its place), so the text sits
clear of the border; the History tab sets its own 0, so the table, without its own border,
runs edge to edge. *@
<Box Border="1" Radius="Radius.Lg" Grow MaxWidth="34.Rem">
<Tabs Label="Deploys" ContentPadding="4">
<Tab Label="Summary" Icon="Lucide.Activity">
<TextBlock>Three deploys today, all healthy. The last one finished 12 minutes ago.</TextBlock>
</Tab>
<Tab Label="History" Icon="Lucide.Clock" ContentPadding="0">
<Table Items="Deploys" Caption="Deploys today" Bordered="false">
<PropertyColumn Property="d => d.Commit" />
<PropertyColumn Property="d => d.Author" />
<PropertyColumn Property="d => d.Time" />
</Table>
</Tab>
</Tabs>
</Box>Tabs as links
Use TabLink in place of Tab for sections that are pages of their own: the tabs are a nav of plain links, and the one for the current address is marked on the server.
Preview
The team page: four people have access.
Code
NavigationManager Navigation
@* Each TabLink is a link, here to this page with ?view= (in an app, usually its own route), and
the one for the current address is marked on the server. Profile's link has no view: it's
current until a link with one matches. *@
<Stack Gap="2" Grow>
<Tabs Label="Settings">
<TabLink Href="To(null)" Label="Profile" Icon="Lucide.User" />
<TabLink Href="To("billing")" Label="Billing" Icon="Lucide.CreditCard" />
<TabLink Href="To("audit")" Label="Audit" Disabled />
<TabLink Href="To("team")" Label="Team" Icon="Lucide.UsersRound" Badge="4" />
</Tabs>
<TextBlock Color="Color.Muted">
(View switch
{
"billing" => "The billing page: your plan, payment method and invoices.",
"team" => "The team page: four people have access.",
_ => "The profile page: your name, photo and email.",
})
</TextBlock>
</Stack>
{
[SupplyParameterFromQuery(Name = "view")]
private string? View { get; set; }
private string To(string? view) => Navigation.GetUriWithQueryParameter("view", view);
}A tab stays current on the pages below its link, so a settings tab is current on settings/billing too; Match="NavLinkMatch.All" stops that. Tabs for one page told apart by the query, as here, mark one at a time.
Parts
Written inside Tabs. The API page lists their parameters.
Tab: one tab of a Tabs: its label goes in the tab list and its content in a panel.TabLink: a tab that's a link to its own page, for sections split across routes (/settings/profile, /settings/billing).
Mode support
| Feature | Off | Static | Live |
|---|---|---|---|
| Switch panels, keeping what's typed | Yes | Yes | Yes |
| Heard as tabs, with Home and End | No | Yes | Yes |
| Open a tab from a ?tab= link | Yes | Yes | Yes |
| The address follows the picked tab | No | Yes | Yes |
| In a form: error counts, the tab with errors opens | No | Yes | Yes |
| Selected bound to C# | No | No | Yes |
| Tabs as links (TabLink) | Yes | Yes | Yes |
Off: JavaScript disabled. Static: a static SSR page with IzzyBlazor's script. Live: an interactive page (Server, WebAssembly or Auto).
Accessibility
- With script, the list is ARIA tabs (
tablist,tab,tabpanel,aria-selected) with only the selected tab in the tab order; with JavaScript off it's heard as a radio group. - Arrow keys select the next or previous tab, skipping disabled ones; with script, Home and End go to the first and last, and vertical tabs laid out as a column use Up and Down.
- In a form with JavaScript off, there are no tabs: every panel shows in order, each after its tab's label as plain text, so every field can be filled in.
- With script, each panel is named by its tab and takes focus, so Tab moves from the tab to the panel.
Labelnames the tab list (or thenavof TabLinks). It's optional, as in ARIA and other libraries, with no default; set it when the tabs' purpose isn't clear from the heading before them, and give eachnavof TabLinks its own when a page has more than one.- In a form, with script, a tab whose fields have errors shows a count, read as part of its name ("Billing (1 error)").
- TabLinks render a
navof plain links that Tab moves between (no arrow keys); the current page's link hasaria-current, and a disabled one is a link with nohref, announced as disabled (aria-disabled).
Parameters
| Prop | Type | Default |
|---|---|---|
@bind-SelectedThe selected tab's Value. | string? | computed |
LabelThe tab list's (or the TabLinks' nav's) name for screen readers, such as "Account settings". | string? | |
QueryKeyKeeps the selected tab in the URL under this query-string key, such as tab: a link to ?tab=billing opens that tab, and with JavaScript switching tabs updates the URL. | string? | |
VariantHow the tabs look. | TabsVariant | TabsVariant.Line |
SizeTab height and text size, shared with buttons and fields. | ControlSize | ControlSize.Md |
FullWidthStretches the tabs to share the full width (or, vertical, the list's width). | bool | false |
ContentPaddingPadding on every side of each panel, in spacing steps, in place of the default space above and below (the default has none at the sides). | Responsive<int> | |
OrientationA row above the panels (the default), or a column beside them from 640px up. | Orientation | Orientation.Horizontal |
IdThe element id; tab and panel ids build on it. | string? | |
ChildContentThe Tabs. | RenderFragment? |
Plus 16 item props and 4 common props.
Full APISee also
- SegmentedControl: one choice from a few, as joined buttons.
- NavMenu: a list of links that marks the current page.
- Accordion: sections that open and close with no script.
- Demos: Tabs in a form.