Tabs
Layered sections of content, shown one at a time.
- v1.0.0
- WAI-ARIA: Tabs
- SSR
- Zoneless
- 3.1 kB gzip
- +1.7 kB Angular CDK, shared
Account
Make changes to your account here. Click save when you're done.
Password
Change your password here. After saving, you'll be logged out.
import { Component } from '@angular/core';
import { Button } from '@/ui/button/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/ui/card/card';
import { Input } from '@/ui/input/input';
import { Label } from '@/ui/label/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/ui/tabs/tabs';
/**
* Account settings in two tabs, each a card with a form. The arrow keys
* move between the tabs; Tab moves on into the shown panel.
*/
@Component({
selector: 'example-tabs-demo',
imports: [
Button,
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
Input,
Label,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
],
template: `
<div ndTabs defaultValue="account" class="w-sm max-w-full">
<div ndTabsList aria-label="Settings">
<button ndTabsTrigger value="account">Account</button>
<button ndTabsTrigger value="password">Password</button>
</div>
<div ndTabsContent value="account">
<div ndCard>
<div ndCardHeader>
<h2 ndCardTitle>Account</h2>
<p ndCardDescription>
Make changes to your account here. Click save when you're done.
</p>
</div>
<div ndCardContent class="grid gap-6">
<div class="grid gap-3">
<label ndLabel for="tabs-demo-name">Name</label>
<input ndInput id="tabs-demo-name" value="Pedro Duarte" />
</div>
<div class="grid gap-3">
<label ndLabel for="tabs-demo-username">Username</label>
<input ndInput id="tabs-demo-username" value="@peduarte" />
</div>
</div>
<div ndCardFooter>
<button ndButton>Save changes</button>
</div>
</div>
</div>
<div ndTabsContent value="password">
<div ndCard>
<div ndCardHeader>
<h2 ndCardTitle>Password</h2>
<p ndCardDescription>
Change your password here. After saving, you'll be logged out.
</p>
</div>
<div ndCardContent class="grid gap-6">
<div class="grid gap-3">
<label ndLabel for="tabs-demo-current">Current password</label>
<input ndInput id="tabs-demo-current" type="password" />
</div>
<div class="grid gap-3">
<label ndLabel for="tabs-demo-new">New password</label>
<input ndInput id="tabs-demo-new" type="password" />
</div>
</div>
<div ndCardFooter>
<button ndButton>Save password</button>
</div>
</div>
</div>
</div>
`,
})
export class TabsDemoExample {}
Tabs split related content into sections shown one at a time, such as
the panes of a settings page. Only the selected tab is in the tab order;
the arrow keys move between tabs and show each one's panel, and Tab moves
on into the panel. line underlines the selected tab instead of raising
it, and orientation="vertical" stacks the tabs beside their panels.
Installation
npx @ndop/cli add tabspnpm dlx @ndop/cli add tabsyarn dlx @ndop/cli add tabsbunx @ndop/cli add tabsInstall the dependencies
npm install @ndop/primitives @ndop/corepnpm add @ndop/primitives @ndop/coreyarn add @ndop/primitives @ndop/corebun add @ndop/primitives @ndop/coreCopy the code into your project
At the paths your
ndop.jsonaliases point to; these use the defaults.src/app/ui/tabs/tabs.tsimport { computed, Directive, input } from '@angular/core'; import { NdTabs, NdTabsContent, NdTabsList, NdTabsTrigger, } from '@ndop/primitives/tabs'; import { cn, variants, type VariantOf } from '@/lib/utils'; /** * Tabs (`@ndop/primitives/tabs`): layered sections of content, shown one * at a time. Its list of tabs and its panels go inside it. * * @example * ```html * <div ndTabs defaultValue="account"> * <div ndTabsList aria-label="Settings"> * <button ndTabsTrigger value="account">Account</button> * <button ndTabsTrigger value="password">Password</button> * </div> * <div ndTabsContent value="account">…</div> * <div ndTabsContent value="password">…</div> * </div> * ``` */ @Directive({ selector: '[ndTabs]', hostDirectives: [ { directive: NdTabs, inputs: ['value', 'defaultValue', 'orientation', 'activationMode'], outputs: ['valueChange'], }, ], host: { 'data-slot': 'tabs', '[class]': 'classes()' }, }) export class Tabs { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'group/tabs flex gap-2 data-[orientation=horizontal]:flex-col', this.class(), ), ); } /** Class table for {@link TabsList}. */ export const tabsListVariants = variants({ base: 'group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none', variants: { variant: { default: 'bg-muted', line: 'gap-1 bg-transparent', }, }, defaults: { variant: 'default' }, }); /** Visual style of a {@link TabsList}. */ export type TabsListVariant = VariantOf<typeof tabsListVariants, 'variant'>; /** * The row (or column) of tabs. `line` underlines the selected tab instead * of raising it. Name it with `aria-label`. * * @example * ```html * <div ndTabsList aria-label="Settings" variant="line">…</div> * ``` */ @Directive({ selector: '[ndTabsList]', hostDirectives: [NdTabsList], host: { 'data-slot': 'tabs-list', '[attr.data-variant]': 'variant()', '[class]': 'classes()', }, }) export class TabsList { /** Visual style. */ readonly variant = input<TabsListVariant>('default'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn(tabsListVariants({ variant: this.variant() }), this.class()), ); } /** * One tab, on a `button`: it shows the panel with the same `value`. * * @example * ```html * <button ndTabsTrigger value="account">Account</button> * ``` */ @Directive({ selector: 'button[ndTabsTrigger]', hostDirectives: [{ directive: NdTabsTrigger, inputs: ['value', 'disabled'] }], host: { 'data-slot': 'tabs-trigger', '[class]': 'classes()' }, }) export class TabsTrigger { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( "relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", 'group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent', 'data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground', 'after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-end-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100', this.class(), ), ); } /** * The panel of the tab with the same `value`, hidden while that tab isn't * selected. It can take focus, so it shows a focus ring. * * @example * ```html * <div ndTabsContent value="account">…</div> * ``` */ @Directive({ selector: '[ndTabsContent]', hostDirectives: [{ directive: NdTabsContent, inputs: ['value'] }], host: { 'data-slot': 'tabs-content', '[class]': 'classes()' }, }) export class TabsContent { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex-1 outline-none', 'rounded-md focus-visible:ring-[3px] focus-visible:ring-ring/50', this.class(), ), ); }src/app/lib/utils.ts/** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core';Update the import paths to match your project
The files import each other through the
@/uiand@/libaliases.
Usage
import { Button } from '@/ui/button/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/ui/card/card';
import { Input } from '@/ui/input/input';
import { Label } from '@/ui/label/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/ui/tabs/tabs';<div ndTabs defaultValue="account" class="w-sm max-w-full">
<div ndTabsList aria-label="Settings">
<button ndTabsTrigger value="account">Account</button>
<button ndTabsTrigger value="password">Password</button>
</div>
<div ndTabsContent value="account">
<div ndCard>
<div ndCardHeader>
<h2 ndCardTitle>Account</h2>
<p ndCardDescription>
Make changes to your account here. Click save when you're done.
</p>
</div>
<div ndCardContent class="grid gap-6">
<div class="grid gap-3">
<label ndLabel for="tabs-demo-name">Name</label>
<input ndInput id="tabs-demo-name" value="Pedro Duarte" />
</div>
<div class="grid gap-3">
<label ndLabel for="tabs-demo-username">Username</label>
<input ndInput id="tabs-demo-username" value="@peduarte" />
</div>
</div>
<div ndCardFooter>
<button ndButton>Save changes</button>
</div>
</div>
</div>
<div ndTabsContent value="password">
<div ndCard>
<div ndCardHeader>
<h2 ndCardTitle>Password</h2>
<p ndCardDescription>
Change your password here. After saving, you'll be logged out.
</p>
</div>
<div ndCardContent class="grid gap-6">
<div class="grid gap-3">
<label ndLabel for="tabs-demo-current">Current password</label>
<input ndInput id="tabs-demo-current" type="password" />
</div>
<div class="grid gap-3">
<label ndLabel for="tabs-demo-new">New password</label>
<input ndInput id="tabs-demo-new" type="password" />
</div>
</div>
<div ndCardFooter>
<button ndButton>Save password</button>
</div>
</div>
</div>
</div>Examples
Tabs line
Tabs underlined instead of raised, with the line variant.
import { Component } from '@angular/core';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/ui/tabs/tabs';
/** Tabs underlined instead of raised, with the line variant. */
@Component({
selector: 'example-tabs-line',
imports: [Tabs, TabsContent, TabsList, TabsTrigger],
template: `
<div ndTabs defaultValue="overview" class="w-sm max-w-full">
<div ndTabsList variant="line" aria-label="Project">
<button ndTabsTrigger value="overview">Overview</button>
<button ndTabsTrigger value="analytics">Analytics</button>
<button ndTabsTrigger value="reports" disabled>Reports</button>
</div>
<div ndTabsContent value="overview" class="text-sm text-muted-foreground">
Your project at a glance.
</div>
<div
ndTabsContent
value="analytics"
class="text-sm text-muted-foreground"
>
Visitors and conversions over time.
</div>
<div ndTabsContent value="reports" class="text-sm text-muted-foreground">
Reports are on the paid plan.
</div>
</div>
`,
})
export class TabsLineExample {}
Tabs vertical
Tabs stacked beside their panels, selected with Enter or Space.
import { Component } from '@angular/core';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/ui/tabs/tabs';
/**
* Tabs stacked beside their panels. With manual activation, the arrow
* keys only move focus; Enter or Space shows the focused tab's panel.
*/
@Component({
selector: 'example-tabs-vertical',
imports: [Tabs, TabsContent, TabsList, TabsTrigger],
template: `
<div
ndTabs
defaultValue="general"
orientation="vertical"
activationMode="manual"
class="w-md max-w-full"
>
<div ndTabsList aria-label="Preferences">
<button ndTabsTrigger value="general">General</button>
<button ndTabsTrigger value="notifications">Notifications</button>
<button ndTabsTrigger value="privacy">Privacy</button>
</div>
<div ndTabsContent value="general" class="text-sm">
Language, time zone and start page.
</div>
<div ndTabsContent value="notifications" class="text-sm">
Email and push notifications.
</div>
<div ndTabsContent value="privacy" class="text-sm">
Who can see your profile and activity.
</div>
</div>
`,
})
export class TabsVerticalExample {}
Accessibility
Implements the Tabs pattern of the WAI-ARIA Authoring Practices.
Keyboard
Keys the component handles.
| Key | Action |
|---|---|
| Tab | Moves focus into the tab list, to the selected tab; then to the active panel. |
| ArrowRightorArrowLeft | In a horizontal list, moves focus to the next or previous enabled tab, wrapping; selects it if automatic. |
| ArrowDownorArrowUp | In a vertical list, moves focus to the next or previous enabled tab, wrapping; selects it if automatic. |
| Home | Moves focus to the first enabled tab; selects it if automatic. |
| End | Moves focus to the last enabled tab; selects it if automatic. |
| EnterorSpace | Selects the focused tab. |
Focus
- The selected tab is the list's only tab stop; arrow keys move focus without leaving the list, and disabled tabs are skipped.
- Clicking a tab focuses and selects it.
Right-to-left
In a horizontal list in a right-to-left layout, ArrowLeft moves to the next tab and ArrowRight to the previous one. Direction comes from the nearest dir attribute.
API reference
Tabs
[ndTabs]Tabs (@ndop/primitives/tabs): layered sections of content, shown one
at a time. Its list of tabs and its panels go inside it.
| Tabs binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
[defaultValue]Initial value when it isn't bound. | string | null | null |
[orientation]The tab list's layout: which arrow keys move between tabs. | 'horizontal' | 'vertical' | 'horizontal' |
[activationMode]Whether moving focus to a tab selects it. | 'automatic' | 'manual' | 'automatic' |
[(value)]The selected tab's value, or null before one is selected. Two-way
bindable with [(value)]. | string | null | null |
| Data attribute | Values |
|---|---|
data-slot | tabs |
data-orientation | — |
TabsList
[ndTabsList]role tablistThe row (or column) of tabs. line underlines the selected tab instead
of raising it. Name it with aria-label.
Manages aria-orientation.
| TabsList binding | Type | Default |
|---|---|---|
[variant]Visual style. | 'default' | 'line' | 'default' |
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | tabs-list |
data-variant | — |
data-orientation | — |
TabsTrigger
button[ndTabsTrigger]role tabOne tab, on a button: it shows the panel with the same value.
Manages aria-selected, aria-controls, id, tabindex.
| TabsTrigger binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
[value]requiredThe value of the panel it shows. | string | — |
[disabled]Disables it; arrow keys skip it. | boolean | false |
| Data attribute | Values |
|---|---|
data-slot | tabs-trigger |
data-state | — |
data-disabled | — |
data-orientation | — |
TabsContent
[ndTabsContent]role tabpanelThe panel of the tab with the same value, hidden while that tab isn't
selected. It can take focus, so it shows a focus ring.
Manages aria-labelledby, id, tabindex, hidden.
| TabsContent binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
[value]requiredThe value of the tab that shows it. | string | — |
| Data attribute | Values |
|---|---|
data-slot | tabs-content |
data-state | — |
data-orientation | — |
Agent notes
Do
- Keep tab labels short, and the content of each panel independent.
- Name the list with
aria-labelwhen nothing on the page names it. - Use
activationMode="manual"when showing a panel is slow, so moving through the tabs doesn't load each one.
Don't
- Don't use tabs to step through a process in order; use a form with steps.
- Don't use them for site navigation between pages; use links, or Navigation Menu.