Tabs and navigation
Choose local panels or route navigation, preserve drafts deliberately, and keep every destination reachable.
6 min read
Use Tabs to switch panels within a screen, and LocaleTabs to edit one value per language. Use TabNavigation for destinations that should support a URL, reload, and browser history. Similar visual treatments do not make these controls interchangeable.
import { Tabs } from '@tale/ui/tabs';
import { LocaleTabs } from '@tale/ui/locale-tabs';
import { TabNavigation } from '@tale/ui/tab-navigation';Switch local panels
What this agent does and which model it runs on.
Choose Skills or Runs. The visible panel changes without a URL change. Focus a tab and use the arrow keys to move through the strip. The Radix-based component supplies tab roles and panel associations.
Each item has a stable value, a label, and optional content. Use defaultValue for local state, or value and onValueChange when the host controls the selection. Supply an initial selection explicitly; do not assume the first item becomes selected automatically.
The underline variant suits a section strip. The default pill variant suits a smaller switch inside a panel:
Requests still waiting for a decision.
Preserve panel state intentionally
Inactive panel content unmounts by default. Returning to a panel can therefore reset its local form or expanded state. Set keepMounted to retain the mounted panels and hide inactive ones instead.
Mounted hidden content can still run hooks and subscriptions. Use this option when state should survive, and make loading or polling rules explicit in the panel. If the draft must survive route changes or reloads too, keep it in a host-owned store or persistence layer; keepMounted only addresses tab-panel mounting.
Tabs prop | Default and purpose |
|---|---|
items | Required TabItem[]: value, label, optional content, disabled, ariaLabel. |
variant | pill; alternatively underline. |
value, defaultValue, onValueChange | Controlled state or initial local selection. |
listAriaLabel | Accessible name for the tab list. |
equalWidth | false; distributes items across the available list width. |
actions, toolbar | Trailing controls or a separate row beneath the strip. |
overflowMenu | false; folds tabs that no longer fit into a menu when enabled. |
overflowMenuLabel | Defaults to More; pass a localized label in translated screens. |
keepMounted | false; retains hidden panel content when enabled. |
className, listClassName, triggerClassName | Targeted layout customization. |
Edit text per language
Use LocaleTabs when a value exists once per language, such as a notice members read or an agent's description. It builds on the underline Tabs: the default locale's tab comes first and is marked (default), and a translation tab without text of its own shows an untranslated pill.
0 / 280
0 / 280
0 / 280
Choose Français. Its tab carries the pill, and the empty field shows the English text as a placeholder. Type a translation: the pill disappears, and your text is still there after you visit another tab, because every locale's editor renders in its own panel and the panels stay mounted.
The component stores no text and applies no fallback. Your host keeps the values, answers hasTranslation for each translation locale, and decides what an empty language falls back to; the demo previews that choice as a placeholder. Tabs are named in each language's own words, so a reader finds their language whatever the interface language is.
LocaleTabs prop | Default and purpose |
|---|---|
defaultLocale | Required. The source locale, shown first and marked default. |
locales | The shipped locales (en, de, fr); pass a list to choose and order them. |
editingLocale, onEditingLocaleChange | Required controlled selection. |
hasTranslation | Required. (locale) => boolean; false shows the untranslated pill. Never called for the default locale. |
hasError | Optional. (locale) => boolean; true puts an error mark on that tab, named for screen readers. |
renderPanel | Required. (locale) => ReactNode, rendered inside that locale's tab panel. |
onAutoTranslate, isTranslating | Optional action beside the strip on translation tabs; your host performs the translation. |
subtitle | Optional hint beneath the strip. |
listAriaLabel | Accessible name for the tab list. |
Give each editor an accessible name that includes its language; a panel's association with its tab does not label the field inside it. A validation message in a hidden panel is invisible, and a disabled Save button cannot say which language to fix, so answer hasError from your form state: the tab of every language with a problem shows the mark while the reader works in another one.
Navigate between routes
TabNavigation requires TanStack Router context. Its items carry label and href, and the component reads the current location. Supply resolved URLs: unlike LinkButton, an item has no separate params prop to fill a $id placeholder.
This composition excerpt assumes your application defines the listed routes and supplies projectId:
<TabNavigation
ariaLabel="Project sections"
items={[
{ label: 'Overview', href: `/projects/${projectId}`, matchMode: 'exact' },
{ label: 'Files', href: `/projects/${projectId}/files` },
{ label: 'Tasks', href: `/projects/${projectId}/tasks` },
]}
/>The default matchMode is startsWith; choose exact for an overview route that would otherwise also match its children. additionalActivePaths keeps an item active for related sibling routes. isActive overrides path matching, useful when several views share a pathname and differ by a search parameter.
TabNavigation option | Behavior |
|---|---|
search on an item | Search parameters passed with that destination. |
disabled on an item | Keeps the label visible without navigation. |
trailing on an item | Optional badge or other supporting content. |
prefetch | Defaults to true; preloads on intent. |
standalone | Defaults to true; set false inside an existing StickyHeader. |
overflow | Defaults to scroll; menu folds the trailing destinations into a menu. |
dirtyKeys | A ReadonlySet<string> from the editor, intersected with each item's dirtyKeys array to show an unsaved-change dot. |
Check navigation on a narrow screen
Do not assume a route strip always becomes a dropdown on mobile: horizontal scrolling is the default. Select overflow="menu" when a growing list of destinations needs a menu. Trailing route-strip actions move to the mobile floating-action area below md; leave room for that area in the content layout.
Name local tab lists with listAriaLabel and route landmarks with ariaLabel. Name icon-only local tabs with the item's ariaLabel. The active route uses aria-current, while a local selected tab uses aria-selected. Route tabs can use the host accent context; local Tabs uses its own theme classes.
Use a settings-page editor to protect drafts when leaving a route. A dirty dot communicates state but does not block navigation by itself.
Place primary navigation on a phone
BottomTabBar from @tale/ui/bottom-tab-bar places icons and labels inside a floating capsule. Supply items with stable key, label, icon, active, and onSelect values, and name the navigation landmark with ariaLabel. Use badgeLabel to describe a badge in the reader’s language. The component has no router or permission logic.
Place the bar in a positioned, viewport-height .mobile-nav-shell. It is absolutely positioned at the bottom and hidden from md upward. Its 60px expanded height and external safe-area gap are shared with BottomTabBarPlaceholder; pass the placeholder the expected tabs count while loading.
The platform keeps clearance on scrolling content rather than shrinking the whole page. ContentArea consumes --mobile-nav-content-pad; .mobile-nav-clearance resets that contribution for descendants so nested frames do not count it twice. Custom bounded panes use .mobile-nav-clearance.mobile-nav-inset, and scroll containers can use .mobile-nav-scroll for focus clearance. Keep page actions above the capsule through MobileFloatingActions.
The platform uses useMobileKeyboard to hide navigation and release its clearance when editable focus accompanies a software-keyboard viewport contraction. The shared bar does not detect keyboards itself. Keyboard focus alone does not hide navigation. Reduced-transparency and increased-contrast preferences use an opaque surface.
Pass compact to show a 52px-high, at most 280px-wide capsule, 4px lower than its expanded position. Icons stay 20px and targets remain at least 44px square. Expanded labels are 11px; compact labels remain available to assistive technology. Badges and every destination stay available.
The platform uses useScrollCompact from @tale/ui/use-scroll-compact with the pathname as its reset key and keyboard visibility as its disabled flag. It observes nested scroll containers inside .mobile-nav-shell, minimizes after downward travel, and expands on upward travel or near the top. Wire keyboard focus and selection to the returned expand callback. Keep expanded content clearance in both states so resizing navigation does not move the page. Geometry transitions respect reduced motion.
Use trailing for a compact navigation control that must remain at the right of the tab strip on every screen size. The children action slot moves into a floating dock on mobile; trailing stays in the strip.