Tabs
Related task views with distinct focus and selection, retained drafts and explicit activation behavior.
Choose tabs for related views
Use tabs for a small set of related panels occupying one place. Labels should tell users what each view contains. Use real links for independently addressable destinations, toggle groups for setting values and accordions when several sections should remain visible.
Manual activation is the installed default: arrows move focus; Enter or Space selects. Automatic activation suits content ready without noticeable delay. Loading on every focus movement can make exploration costly and confusing.
- Name the tab list for its task. Keep labels short, distinct and meaningful; an icon still needs an accessible name.
- Explain unavailable views outside their hidden panel. If an active view becomes unavailable, choose a surviving selection and handle focus deliberately.
- A tab root label does not replace the tab-list name. Tabs supply no URL/history synchronization, persistence, authorization or data-loading policy.
Anatomy, dimensions and visual states
| Part | Actual contract |
|---|---|
| Tabs | Default div; horizontal column of list/panel. Vertical mode places list beside panel with 16px gap. |
| TabsList | Default div with tablist role; caller supplies name. A neutral outer scroller contains the horizontal list within available width; the inner list retains stable indicator coordinates. Size/selection changes keep the focused or selected tab visible after label metrics change. Vertical list occupies at most 45% and labels wrap. |
| TabsTrigger | Default button, minimum 44px height/width and 8px × 16px padding. Horizontal labels retain width within contained scrolling. |
| Selected / focused | Installed data-active marks selection: foreground/bold text and position indicator. Keyboard focus has an independent 2px inset outline. |
| Indicator | Automatically included by TabsList. Horizontal left/width and vertical top/height consume installed --active-tab-* variables; 2px edge follows selection. |
| TabsContent | Default div with tabpanel role; 16px top spacing, flexible width and wrapping. Inactive panels unmount by default; keepMounted retains hidden content. |
| Disabled / alternate modes | Disabled triggers expose unavailable state and remain arrow-focusable for discovery while rejecting selection. Reduced motion removes transitions; forced colors use system highlight/disabled text. Platform verification remains required. |
Material primary, secondary and scrollable tabs
Primary tabs identify peer content under an app bar; secondary tabs subdivide related content below them. They do not represent sequential required steps. The native variants differ mainly in indicator emphasis, while the installed Tabs has its own documented visual/API contract. Keep optional icons consistent across a set and preserve clear text labels.
| Reference decision | Local web contract and application choice |
|---|---|
| Native geometry | Reference label-only tabs are 48dp, icon/label 64dp with 24dp icons,3dp primary or 2dp secondary indicators,24dp minimum indicator length and 2dp inset. A52dp leading offset signals native scrollable content. Those are reference values; the local 44px targets, contained scroller and indicator variables are the implemented geometry. |
| Fixed versus scrollable | The source describes equal full-width fixed sections, then widest-tab/fluid-margin placement; apply that advice to the actual pane rather than one universal width rule. Prefer a small fixed set (native advice up to four); long or larger sets need contained horizontal scrolling, reachable endpoints and full labels. Local enlarged text preserves names instead of truncation. |
| Focus, selection and loading | The reference uses navigation/menu language; installed web tablist/tab/tabpanel relations remain authoritative. Manual activation is the pin’s default, separating focus from selected content; automatic activation is suitable only for ready content. A roving keyboard loop is different from an endlessly scrolling visual strip. |
| Badges and state | If a tab has an unread badge, own its count and clear/update it when the relevant content is actually viewed. Color alone is insufficient: retain an indicator and semantic selection, with independent focus. No automatic unread service or badge-reset behavior is implied by Tabs. |
| Scrolling and gesture scope | A pinned set should move consistently with its app bar and never disappear behind it. This lab uses a bounded in-flow tab set. Native content swiping, scroll-to-hide/reappear and container transitions are not installed; do not add competing swipe gestures over maps or independently scrollable panels. |
| Density and accessibility | The reference recommends 48 CSS px targets and no default density reduction. Local target sizes are specified separately; a consumer choosing denser presentation needs an accessible independent way to undo it. Check offscreen focus/selection, full labels at 200% text, both themes, RTL and actual panel lifecycle. |
Working views and retained recovery
Edit the local draft, switch to Preview and request a local check. Completion, failure and cancellation preserve text. Outcome controls return focus to their surviving preview panel before disappearing. Orientation, direction, activation and mounting changes retain parent-owned task state.
Selected local view: draft. Manual activation. Remote review is unavailable.
'use client';
import { useRef, useState, type CSSProperties } from 'react';
const previewActionStyle: CSSProperties = {maxWidth:'100%',whiteSpace:'normal',height:'auto',minHeight:44,paddingBlock:8};
import { Button, DirectionProvider, Input, Tabs, TabsContent, TabsList, TabsTrigger } from '@mobtranslate/ui';
export function NavigationTabsExample() {
const [value, setValue] = useState('draft');
const [draft, setDraft] = useState('English-only local draft');
const [automatic, setAutomatic] = useState(false);
const [vertical, setVertical] = useState(false);
const [rtl, setRtl] = useState(false);
const [keepMounted, setKeepMounted] = useState(false);
const [outcome, setOutcome] = useState<'idle' | 'pending' | 'failed' | 'complete'>('idle');
const preview = useRef<HTMLDivElement>(null);
function resolve(next: 'idle' | 'failed' | 'complete') { setOutcome(next); preview.current?.focus(); }
const direction = rtl ? 'rtl' : 'ltr';
return <div data-tabs-lab className="space-y-4">
<div className="flex flex-wrap gap-3">
<Button type="button" variant="outline" aria-pressed={automatic} onClick={() => setAutomatic(current => !current)}>Activate local tabs on focus</Button>
<Button type="button" variant="outline" aria-pressed={vertical} onClick={() => setVertical(current => !current)}>Use vertical local tabs</Button>
<Button type="button" variant="outline" aria-pressed={rtl} onClick={() => setRtl(current => !current)}>Use RTL local tabs</Button>
<Button type="button" variant="outline" aria-pressed={keepMounted} onClick={() => setKeepMounted(current => !current)}>Keep local panels mounted</Button>
</div>
<DirectionProvider direction={direction}><div dir={direction}>
<Tabs value={value} onValueChange={setValue} orientation={vertical ? 'vertical' : 'horizontal'} className={state => `local-tabs-${state.orientation}`}>
<TabsList aria-label="Local record views" activateOnFocus={automatic}>
<TabsTrigger value="draft" className={state => state.active ? 'local-tab-active' : undefined}>Draft</TabsTrigger>
<TabsTrigger value="preview">Preview</TabsTrigger>
<TabsTrigger value="remote" disabled>Remote review unavailable</TabsTrigger>
<TabsTrigger value="history">Local history</TabsTrigger>
</TabsList>
<TabsContent value="draft" keepMounted={keepMounted}>
<label htmlFor="navigation-local-draft" className="block mb-2">Local draft text</label>
<Input id="navigation-local-draft" value={draft} onChange={event => setDraft(event.target.value)} aria-describedby="navigation-draft-help" />
<p id="navigation-draft-help">The parent owns this value; switching panels does not discard it.</p>
</TabsContent>
<TabsContent value="preview" keepMounted={keepMounted} ref={preview}>
<p data-tabs-preview>{draft || 'The local draft is empty.'}</p>
<Button style={previewActionStyle} type="button" disabled={outcome === 'pending'} onClick={() => setOutcome('pending')}>Request local preview check</Button>
{outcome === 'pending' && <div className="flex flex-wrap gap-3 mt-3"><Button style={previewActionStyle} type="button" onClick={() => resolve('complete')}>Complete local preview check</Button><Button style={previewActionStyle} type="button" variant="outline" onClick={() => resolve('failed')}>Fail local preview check</Button><Button style={previewActionStyle} type="button" variant="outline" onClick={() => resolve('idle')}>Cancel local preview check</Button></div>}
<p role="status">{outcome === 'pending' ? 'Local check pending; the draft remains available.' : outcome === 'failed' ? 'Local check failed; retained text can be corrected and retried.' : outcome === 'complete' ? 'Local check complete. No remote review or publication occurred.' : 'No local check requested.'}</p>
</TabsContent>
<TabsContent value="history" keepMounted={keepMounted}><p>The current local draft has {draft.length} UTF-16 code units. There is no persisted revision history.</p></TabsContent>
</Tabs>
</div></DirectionProvider>
<p role="status">Selected local view: {value}. {automatic ? 'Automatic' : 'Manual'} activation. Remote review is unavailable.</p>
<Button type="button" variant="outline" onClick={() => { setDraft('English-only local draft'); setValue('draft'); setOutcome('idle'); }}>Restore local tab draft</Button>
</div>;
}
API reference for the installed version
Contracts describe @base-ui-components/react 1.0.0-rc.0 installed here. Current upstream documentation can describe newer APIs. Styled parts preserve state callback classes.
| Export / prop | Contract and default |
|---|---|
| Tabs value / defaultValue | Controlled/uncontrolled selection. Installed defaultValue is 0; explicitly match string trigger values. null means no active tab. |
| Tabs onValueChange | Selected value plus event details, including activationDirection. Application loading/saving remains caller-owned. |
| Tabs orientation | horizontal default; vertical alternative. DirectionProvider and DOM dir supply matching keyboard/layout direction. |
| TabsList activateOnFocus | false default; true selects on arrow focus. Verify readiness/latency. |
| TabsList loopFocus | true default; false bounds arrow focus at ends. |
| TabsTrigger value / disabled / nativeButton | Match panel value; installed button interaction/availability. Default element is button; custom render must preserve semantics. Ref type Element reflects possible custom nodes. |
| TabsContent value / keepMounted | Matched view; keepMounted false default. Retained hidden DOM does not establish persistence or stop effects. |
| className / style / render / native props | Installed state callbacks are preserved. Custom rendered elements must retain supplied roles, handlers, attributes and refs. Check actual DOM and keyboard behavior after composition. |
Keyboard, focus and announcements
- Tab enters the composite and leaves for the active panel/content. Arrows move among triggers according to orientation and direction; unavailable triggers remain discoverable but do not select. Check Home/End and first/last wrapping.
- Manual focus and selection can differ. Enter/Space selects the focused trigger; automatic arrow focus also selects. Do not add a second independent selection handler.
- The active tab has aria-selected; panels have aria-labelledby. Inspect tab/panel relations in the chosen mount strategy, including hidden/unmounted views.
- A panel without a meaningful first focusable control needs a reading stop. The installed panel supplies focusability; preserve it with custom rendering. Selecting a tab should not unexpectedly focus an input. Keep narrow panel actions wrapping within available width; the checked source explicitly supplies that responsive button composition.
- Selected view and requested task outcome are distinct. Give pending/error/result status its own announcement boundary; avoid making every keystroke an intrusive live region. Check spoken output with a named screen-reader/browser pair.
State ownership, loading and failure
| Situation | Decision |
|---|---|
| Unmounted draft | Own durable text above panels or in an appropriate store. An uncontrolled input inside an unmounted panel can lose its value. |
| keepMounted | Retains DOM and possibly effects/subscriptions. Hidden controls must stay outside active focus order. |
| Pending resource | Retain useful task context and supply loading, cancellation and retry without blocking unrelated views. This lab waits for explicit outcomes. |
| Failure / cancellation | Retain entered values; distinguish a rejected request from a retained draft. Restore focus when the outcome control disappears. |
| Dynamic removal / permission change | Select a valid surviving panel and move focus deliberately when removing a trigger/content. Business fallback and permission policy remain caller-owned. |
| Deep links / history | Use validated route state and back/forward tests when required; the primitive does not supply history synchronization. |
Composition and common failures
- Use data-active, not data-selected, for this installed tab primitive. A plausible unsupported selector can leave selection visually unchanged.
- Indicator CSS must use supplied geometry variables. An absolutely positioned empty span without width/position is not a visible selection cue.
- Do not claim all inactive content is always removed: keepMounted retains it. Verify hidden actions and visibility.
- Avoid wrapping tabs across rows while promising one-dimensional spatial navigation. Prefer contained scrolling, fewer categories or a different content structure.
- Keep actual list/trigger/panel primitives and matching values. Generic divs with roles alone do not implement the interaction.
Verification and release scope
- Check actual values/content, names, roles, relations, focus order and unavailable activation with pointer and keyboard. Change callbacks alone do not prove a working task.
- Test 320px reflow, long labels, both themes, text spacing/zoom, live orientation/RTL, reduced motion and forced colors. Hardware touch and spoken announcements need their own device/browser evidence.
- Run test:navigation-controls, source/token freshness, lint, typechecks, production build and route checks. Family tests are scoped evidence, not whole-library accessibility certification.
- English-only examples use local page state. They establish no persistent save, source authority, consent, cultural permission, language correctness or remote review.