Tabs
Tabs provide top-level navigation for switching between related views or sections within the same context. They use a minimal, low-chrome visual treatment defined primarily by a selection indicator beneath the active tab.
import {Tab, Tabs} from "@qualcomm-ui/react/tabs"Examples
Horizontal
The default orientation is horizontal. The left and right arrow keys can be used to navigate between tabs.
import type {ReactElement} from "react"
import {Tab, Tabs} from "@qualcomm-ui/react/tabs"
export function TabsHorizontalDemo(): ReactElement {
return (
<Tabs.Root defaultValue="documents">
<Tabs.List>
<Tabs.Indicator />
<Tab.Root value="documents">
<Tab.Button>Documents</Tab.Button>
</Tab.Root>
<Tab.Root value="products">
<Tab.Button>Products</Tab.Button>
</Tab.Root>
<Tab.Root value="software">
<Tab.Button>Software</Tab.Button>
</Tab.Root>
<Tab.Root value="hardware">
<Tab.Button>Hardware</Tab.Button>
</Tab.Root>
</Tabs.List>
<Tabs.Panel value="documents">Documents</Tabs.Panel>
<Tabs.Panel value="products">Products</Tabs.Panel>
<Tabs.Panel value="software">Software</Tabs.Panel>
<Tabs.Panel value="hardware">Hardware</Tabs.Panel>
</Tabs.Root>
)
}Vertical
In vertical orientation, the up and down arrow keys are used instead.
import type {ReactElement} from "react"
import {Tab, Tabs} from "@qualcomm-ui/react/tabs"
export function TabsVerticalDemo(): ReactElement {
return (
<Tabs.Root
className="w-full"
defaultValue="documents"
orientation="vertical"
>
<Tabs.List>
<Tabs.Indicator />
<Tab.Root value="documents">
<Tab.Button>Documents</Tab.Button>
</Tab.Root>
<Tab.Root value="products">
<Tab.Button>Products</Tab.Button>
</Tab.Root>
<Tab.Root value="software">
<Tab.Button>Software</Tab.Button>
</Tab.Root>
<Tab.Root value="hardware">
<Tab.Button>Hardware</Tab.Button>
</Tab.Root>
</Tabs.List>
<Tabs.Panel value="documents">Documents</Tabs.Panel>
<Tabs.Panel value="products">Products</Tabs.Panel>
<Tabs.Panel value="software">Software</Tabs.Panel>
<Tabs.Panel value="hardware">Hardware</Tabs.Panel>
</Tabs.Root>
)
}Icons and variants
Tabs support start and end icons. Customize the variant with the iconVariant prop. Use the variant prop to change the appearance of the tab.
<Tabs.Root>
<TabContent />
</Tabs.Root>
<Tabs.Root iconVariant="filled">
<TabContent />
</Tabs.Root>
<Tabs.Root variant="contained">
<TabContent />
</Tabs.Root>
<Tabs.Root iconVariant="filled" variant="contained">
<TabContent />
</Tabs.Root>
Controlled Value
The tab's active value can be controlled via the value, onValueChange, and defaultValue properties. These props follow our controlled state pattern.
import {type ReactElement, useState} from "react"
import {ChevronLeft, ChevronRight} from "lucide-react"
import {Button} from "@qualcomm-ui/react/button"
import {Tab, Tabs} from "@qualcomm-ui/react/tabs"
export function TabsControlledValueDemo(): ReactElement {
const [value, setValue] = useState<string>("software")
const goToSoftwareTab = () => setValue("software")
const goToHardwareTab = () => setValue("hardware")
return (
<Tabs.Root onValueChange={setValue} value={value}>
<Tabs.List>
<Tabs.Indicator />
<Tab.Root value="software">
<Tab.Button>Software</Tab.Button>
</Tab.Root>
<Tab.Root value="hardware">
<Tab.Button>Hardware</Tab.Button>
</Tab.Root>
</Tabs.List>
<Tabs.Panel value="software">
<div className="flex flex-col gap-4">
<div>Software Panel</div>
<Button
emphasis="primary"
endIcon={ChevronRight}
onClick={goToHardwareTab}
size="sm"
>
View Hardware
</Button>
</div>
</Tabs.Panel>
<Tabs.Panel value="hardware">
<div className="flex flex-col gap-4">
<div>Hardware Panel</div>
<Button
emphasis="primary"
onClick={goToSoftwareTab}
size="sm"
startIcon={ChevronLeft}
>
View Software
</Button>
</div>
</Tabs.Panel>
</Tabs.Root>
)
}Sizes
Line
The line variant supports four sizes: sm, md, lg, and xl
import type {ReactElement} from "react"
import {Code, Cpu, FileText, Smartphone} from "lucide-react"
import type {QdsTabsSize} from "@qualcomm-ui/qds-core/tabs"
import {Tab, Tabs} from "@qualcomm-ui/react/tabs"
const sizes: QdsTabsSize[] = ["sm", "md", "lg", "xl"]
export function TabsLineSizesDemo(): ReactElement {
return (
<div className="flex flex-col gap-4">
{sizes.map((size) => (
<div key={size} className="flex items-center gap-4">
<div className="font-heading-xs text-neutral-primary w-16">
{size}
</div>
<Tabs.Root defaultValue="documents" size={size}>
<Tabs.List>
<Tabs.Indicator />
<Tab.Root value="documents">
<Tab.Button endIcon={FileText}>Documents</Tab.Button>
</Tab.Root>
<Tab.Root value="products">
<Tab.Button endIcon={Smartphone}>Products</Tab.Button>
</Tab.Root>
<Tab.Root value="software">
<Tab.Button endIcon={Code}>Software</Tab.Button>
</Tab.Root>
<Tab.Root value="hardware">
<Tab.Button endIcon={Cpu}>Hardware</Tab.Button>
</Tab.Root>
</Tabs.List>
</Tabs.Root>
</div>
))}
</div>
)
}Contained
The contained variant supports only two sizes: sm and md
import type {ReactElement} from "react"
import {Code, Cpu, FileText, Smartphone} from "lucide-react"
import type {QdsTabsSize} from "@qualcomm-ui/qds-core/tabs"
import {Tab, Tabs} from "@qualcomm-ui/react/tabs"
const sizes: QdsTabsSize[] = ["sm", "md"]
export function TabsContainedSizesDemo(): ReactElement {
return (
<div className="flex flex-col gap-6">
{sizes.map((size) => (
<Tabs.Root
key={size}
defaultValue="documents"
size={size}
variant="contained"
>
<Tabs.List>
<Tab.Root value="documents">
<Tab.Button endIcon={FileText}>Documents</Tab.Button>
</Tab.Root>
<Tab.Root value="products">
<Tab.Button endIcon={Smartphone}>Products</Tab.Button>
</Tab.Root>
<Tab.Root value="software">
<Tab.Button endIcon={Code}>Software</Tab.Button>
</Tab.Root>
<Tab.Root value="hardware">
<Tab.Button endIcon={Cpu}>Hardware</Tab.Button>
</Tab.Root>
</Tabs.List>
</Tabs.Root>
))}
</div>
)
}Lazy Mounted
Use the lazyMount and unmountOnExit props to control render behavior for inactive tabs.
import {type ReactElement, useEffect, useState} from "react"
import {Tab, Tabs} from "@qualcomm-ui/react/tabs"
export function TabsLazyMountedDemo(): ReactElement {
return (
<Tabs.Root className="w-80" defaultValue="tab-1" lazyMount unmountOnExit>
<Tabs.List>
<Tabs.Indicator />
<Tab.Root value="tab-1">
<Tab.Button>Tab 1</Tab.Button>
</Tab.Root>
<Tab.Root value="tab-2">
<Tab.Button>Tab 2</Tab.Button>
</Tab.Root>
<Tab.Root value="tab-3">
<Tab.Button>Tab 3</Tab.Button>
</Tab.Root>
</Tabs.List>
<Tabs.Panel value="tab-1">
<TabContent />
</Tabs.Panel>
<Tabs.Panel value="tab-2">
<TabContent />
</Tabs.Panel>
<Tabs.Panel value="tab-3">
<TabContent />
</Tabs.Panel>
</Tabs.Root>
)
}
function TabContent() {
const [count, setCount] = useState(0)
useEffect(() => {
const interval = setInterval(() => {
setCount((prevCount) => prevCount + 1)
}, 1000)
return () => {
clearInterval(interval)
}
}, [])
return (
<div className="font-body-sm text-neutral-primary">
<span>This component has been alive for</span>
<span className="font-body-sm-bold text-brand-primary">{` ${count} `}</span>
<span>seconds</span>
</div>
)
}Disabled
Disable specific tabs with the disabled prop on the <Tab.Root> component.
<Tab.Root disabled value="products">
<Tab.Button endIcon={Smartphone}>Products</Tab.Button>
</Tab.Root>
URL Search Parameters
The following example demonstrates how to render tabs that sync with the URL search parameters using react-router.
import {type ReactElement, useEffect, useState} from "react"
import {useSearchParams} from "react-router"
import {Tab, Tabs} from "@qualcomm-ui/react/tabs"
const tabValues = ["documents", "products", "software", "hardware"]
export function TabsLinksDemo(): ReactElement {
const [searchParams, setSearchParams] = useSearchParams()
const [value, setValue] = useState<string>(
searchParams.get("tab") || "documents",
)
useEffect(() => {
// handle back/forward navigation
const tab = searchParams.get("tab")
if (tab && tabValues.includes(tab)) {
setValue(tab)
}
}, [searchParams])
const handleTabChange = (newValue: string) => {
setValue(newValue)
setSearchParams({tab: newValue}, {preventScrollReset: true})
}
return (
<Tabs.Root onValueChange={handleTabChange} value={value}>
<Tabs.List>
<Tabs.Indicator />
<Tab.Root value="documents">
<Tab.Button>Documents</Tab.Button>
</Tab.Root>
<Tab.Root value="products">
<Tab.Button>Products</Tab.Button>
</Tab.Root>
<Tab.Root value="software">
<Tab.Button>Software</Tab.Button>
</Tab.Root>
<Tab.Root value="hardware">
<Tab.Button>Hardware</Tab.Button>
</Tab.Root>
</Tabs.List>
<Tabs.Panel value="documents">Documents</Tabs.Panel>
<Tabs.Panel value="products">Products</Tabs.Panel>
<Tabs.Panel value="software">Software</Tabs.Panel>
<Tabs.Panel value="hardware">Hardware</Tabs.Panel>
</Tabs.Root>
)
}Context
Use the <Tabs.Context> component to access the tabs API from JSX.
<Tabs.Context>
{(context) => (
<>
<Tabs.Panel value="tab-1">
<Button
endIcon={ChevronRight}
onClick={() => context.setValue("tab-2")}
size="sm"
variant="outline"
>
Go to next tab
</Button>
</Tabs.Panel>
<Tabs.Panel className="flex items-center gap-2" value="tab-2">
<Button
onClick={() => context.setValue("tab-1")}
size="sm"
startIcon={ChevronLeft}
variant="outline"
>
Go to prev tab
</Button>
<Button
endIcon={ChevronRight}
onClick={() => context.setValue("tab-3")}
size="sm"
variant="outline"
>
Go to next tab
</Button>
</Tabs.Panel>
<Tabs.Panel value="tab-3">
<Button
onClick={() => context.setValue("tab-2")}
size="sm"
startIcon={ChevronLeft}
variant="outline"
>
Go to previous tab
</Button>
</Tabs.Panel>
</>
)}
</Tabs.Context>
Dismissable Tabs
Use <Tab.DismissButton> to make tabs removable. Combine this with React state to create a dynamic tab interface where users can add and close tabs.
import {type ReactElement, type ReactNode, useState} from "react"
import {Plus} from "lucide-react"
import {Button} from "@qualcomm-ui/react/button"
import {Tab, Tabs} from "@qualcomm-ui/react/tabs"
interface Item {
content: ReactNode
id: string
title: string
}
const items: Item[] = [
{content: "Tab Content", id: "1", title: "Tab"},
{content: "Tab Content", id: "2", title: "Tab"},
{content: "Tab Content", id: "3", title: "Tab"},
{content: "Tab Content", id: "4", title: "Tab"},
]
export function TabsAddRemoveDemo(): ReactElement {
const [tabs, setTabs] = useState<Item[]>(items)
const [selectedTab, setSelectedTab] = useState<string | null>(items[0].id)
const addTab = () => {
const newTabs = [...tabs]
newTabs.push({
content: `Tab Content`,
id: `${parseInt(tabs[newTabs.length - 1]?.id ?? "0") + 1}`,
title: `Tab`,
})
setTabs(newTabs)
setSelectedTab(newTabs[newTabs.length - 1].id)
}
const removeTab = (id: string) => {
if (tabs.length > 1) {
const newTabs = [...tabs].filter((tab) => tab.id !== id)
setTabs(newTabs)
if (selectedTab === id) {
setSelectedTab(newTabs[0].id)
}
}
}
return (
<Tabs.Root
className="w-full"
onValueChange={setSelectedTab}
size="sm"
value={selectedTab}
>
<Tabs.List>
<Tabs.Indicator />
{tabs.map((item) => (
<Tab.Root key={item.id} value={item.id}>
<Tab.Button>
{item.title} {item.id}
</Tab.Button>
<Tab.DismissButton onClick={() => removeTab(item.id)} />
</Tab.Root>
))}
<Button onClick={addTab} size="sm" startIcon={Plus} variant="ghost">
Add Tab
</Button>
</Tabs.List>
{tabs.map((item) => (
<Tabs.Panel key={item.id} value={item.id}>
<div className="font-heading-xs text-neutral-primary my-6">
{item.content} {item.id}
</div>
</Tabs.Panel>
))}
</Tabs.Root>
)
}Dismissable Tabs (Contained)
When using the contained tab variant, the dismiss icon only shows on hover.
import {type ReactElement, type ReactNode, useState} from "react"
import {Plus} from "lucide-react"
import {Button} from "@qualcomm-ui/react/button"
import {Tab, Tabs} from "@qualcomm-ui/react/tabs"
interface Item {
content: ReactNode
id: string
title: string
}
const items: Item[] = [
{content: "Tab Content", id: "1", title: "Tab label"},
{content: "Tab Content", id: "2", title: "Tab label"},
{content: "Tab Content", id: "3", title: "Tab label"},
{content: "Tab Content", id: "4", title: "Tab label"},
]
export function TabsAddRemoveContainedDemo(): ReactElement {
const [tabs, setTabs] = useState<Item[]>(items)
const [selectedTab, setSelectedTab] = useState<string | null>(items[0].id)
const addTab = () => {
const newTabs = [...tabs]
newTabs.push({
content: `Tab Content`,
id: `${parseInt(tabs[newTabs.length - 1]?.id ?? "0") + 1}`,
title: `Tab`,
})
setTabs(newTabs)
setSelectedTab(newTabs[newTabs.length - 1].id)
}
const removeTab = (id: string) => {
if (tabs.length > 1) {
const newTabs = [...tabs].filter((tab) => tab.id !== id)
setTabs(newTabs)
if (selectedTab === id) {
setSelectedTab(newTabs[0].id)
}
}
}
return (
<Tabs.Root
className="w-full"
onValueChange={setSelectedTab}
size="sm"
value={selectedTab}
variant="contained"
>
<Tabs.List>
<Tabs.Indicator />
{tabs.map((item) => (
<Tab.Root key={item.id} value={item.id}>
<Tab.Button>
{item.title} {item.id}
</Tab.Button>
<Tab.DismissButton onClick={() => removeTab(item.id)} />
</Tab.Root>
))}
<Button onClick={addTab} size="sm" startIcon={Plus} variant="ghost">
Add Tab
</Button>
</Tabs.List>
{tabs.map((item) => (
<Tabs.Panel key={item.id} value={item.id}>
<div className="font-heading-xs text-neutral-primary my-6">
{item.content} {item.id}
</div>
</Tabs.Panel>
))}
</Tabs.Root>
)
}Explorer
Component Anatomy
Hover to highlight, click to view API
API
Tabs
<Tabs.Root>
| 'automatic'
| 'manual'
booleanline variant.booleanstringboolean'ltr' | 'rtl'
() =>
| Node
| ShadowRoot
| Document
| 'ghost'
| 'filled'
booleanboolean(
value: string,
) => void
(
value: string,
) => void
| 'horizontal'
| 'vertical'
horizontal or vertical| ReactElement
| ((
props: object,
) => ReactElement)
| 'sm'
| 'md'
| 'lg'
| 'xl'
lg
and xl are not supported by the contained variant.{
listLabel?: string
}
booleanstring| 'line'
| 'contained'
className'qui-tabs__root'data-focusdata-orientation| 'horizontal'
| 'vertical'
data-size| 'sm'
| 'md'
| 'lg'
| 'xl'
data-tabs-part'root'<Tabs.List>
<div> element by default.string| ReactElement
| ((
props: object,
) => ReactElement)
className'qui-tabs__list'data-focusdata-orientation| 'horizontal'
| 'vertical'
data-size| 'sm'
| 'md'
| 'lg'
| 'xl'
data-tabs-part'list'data-variant| 'line'
| 'contained'
<Tabs.Indicator>
<div> element by
default. You only need to render a single <Tabs.Indicator> component per tab
list.<Tabs.Root>
<Tabs.List>
<Tabs.Indicator />
<Tab.Root value="tab-1">
<Tab.Button>Tab 1</Tab.Button>
</Tab.Root>
</Tabs.List>
</Tabs.Root>
string| ReactElement
| ((
props: object,
) => ReactElement)
className'qui-tabs__indicator'data-animatedata-focusdata-focus-visibledata-orientation| 'horizontal'
| 'vertical'
data-size| 'sm'
| 'md'
| 'lg'
| 'xl'
data-tabs-part'indicator'data-variant| 'line'
| 'contained'
hiddenbooleancontained variant.style<Tabs.Panel>
<div> element by default.stringstring| ReactElement
| ((
props: object,
) => ReactElement)
className'qui-tabs__panel'data-orientation| 'horizontal'
| 'vertical'
data-ownedbystringdata-selecteddata-tabs-part'panel'hiddenbooleanstyle{
display: 'none'
}
tabIndex-1 | 0
Tab
<Tab.Root>
<div> element by default.stringboolean| ReactElement
| ((
props: object,
) => ReactElement)
className'qui-tab__root'data-orientation| 'horizontal'
| 'vertical'
data-size| 'sm'
| 'md'
| 'lg'
| 'xl'
data-tabs-part'tab'data-variant| 'line'
| 'contained'
<Tab.Button>
<button> element by
default.neverdisabled prop on the parent <Tab.Root> component instead.| LucideIcon
| ReactNode
LucideIcon, the size will automatically match the size prop.
Supply as a ReactElement for additional customization.string| ReactElement
| ((
props: object,
) => ReactElement)
className'qui-tab__button'data-disableddata-focusdata-focus-visibledata-indicator-rendereddata-orientation| 'horizontal'
| 'vertical'
data-ownedbystringdata-selecteddata-size| 'sm'
| 'md'
| 'lg'
| 'xl'
data-tabs-part'tab-button'data-valuestringdata-variant| 'line'
| 'contained'
tabIndex-1 | 0
<Tab.DismissButton>
<button> element by default.| ReactElement
| ((
props: object,
) => ReactElement)
className'qui-tab__dismiss-button'data-size| 'sm'
| 'md'
| 'lg'
| 'xl'
data-tabs-part'tab-dismiss-button'tabIndex-1
<Tabs.Context>
TabsApi
() => void
() => void
string(props: {
disabled?: boolean
value: string
}) => {
disabled: boolean
focused: boolean
selected: boolean
}
(
value: string,
) => void
(
value: string,
) => void
() => void
string
<div>element by default.