Tabs

Use in pages to manage sub-pages

yarnnpmbunpnpm
import React from 'react'
import type { TabsContentProps } from 'tamagui'
import { Button, H5, Separator, SizableText, Tabs, XStack, YStack, isWeb } from 'tamagui'
const demos = ['horizontal', 'vertical'] as const
const demosTitle: Record<(typeof demos)[number], string> = {
horizontal: 'Horizontal',
vertical: 'Vertical',
}
export function TabsDemo() {
const [demoIndex, setDemoIndex] = React.useState(0)
const demo = demos[demoIndex]
return (
// web only fix for position relative
<YStack paddingHorizontal="$4" {...(isWeb && { position: 'unset' as any, })} >
{demo === 'horizontal' ? <HorizontalTabs /> : <VerticalTabs />}
<XStack alignItems="center" space position="absolute" bottom="$3" left="$4" $xxs={{ display: 'none' }} >
<Button size="$2" onPress={() => setDemoIndex((x) => (x + 1) % demos.length)}>
{demosTitle[demo]}
</Button>
</XStack>
</YStack>
)
}
const HorizontalTabs = () => {
return (
<Tabs defaultValue="tab1" orientation="horizontal" flexDirection="column" width={400} height={150} borderRadius="$4" borderWidth="$0.25" overflow="hidden" borderColor="$borderColor" >
<Tabs.List separator={<Separator vertical />} disablePassBorderRadius="bottom" aria-label="Manage your account" >
<Tabs.Tab flex={1} value="tab1">
<SizableText fontFamily="$body">Profile</SizableText>
</Tabs.Tab>
<Tabs.Tab flex={1} value="tab2">
<SizableText fontFamily="$body">Connections</SizableText>
</Tabs.Tab>
<Tabs.Tab flex={1} value="tab3">
<SizableText fontFamily="$body">Notifications</SizableText>
</Tabs.Tab>
</Tabs.List>
<Separator />
<TabsContent value="tab1">
<H5>Profile</H5>
</TabsContent>
<TabsContent value="tab2">
<H5>Connections</H5>
</TabsContent>
<TabsContent value="tab3">
<H5>Notifications</H5>
</TabsContent>
</Tabs>
)
}
const VerticalTabs = () => {
return (
<Tabs defaultValue="tab1" flexDirection="row" orientation="vertical" width={400} borderRadius="$4" borderWidth="$0.25" overflow="hidden" borderColor="$borderColor" >
<Tabs.List disablePassBorderRadius="end" aria-label="Manage your account" separator={<Separator />} >
<Tabs.Tab value="tab1">
<SizableText>Profile</SizableText>
</Tabs.Tab>
<Tabs.Tab value="tab2">
<SizableText>Connections</SizableText>
</Tabs.Tab>
<Tabs.Tab value="tab3">
<SizableText>Notifications</SizableText>
</Tabs.Tab>
</Tabs.List>
<Separator vertical />
<TabsContent value="tab1">
<H5 textAlign="center">Profile</H5>
</TabsContent>
<TabsContent value="tab2">
<H5 textAlign="center">Connections</H5>
</TabsContent>
<TabsContent value="tab3">
<H5 textAlign="center">Notifications</H5>
</TabsContent>
</Tabs>
)
}
const TabsContent = (props: TabsContentProps) => {
return (
<Tabs.Content backgroundColor="$background" key="tab3" padding="$2" alignItems="center" justifyContent="center" flex={1} borderColor="$background" borderRadius="$2" borderTopLeftRadius={0} borderTopRightRadius={0} borderWidth="$2" {...props} >
{props.children}
</Tabs.Content>
)
}

Features

  • Accessible, easy to compose, customize and animate

  • Sizable & works controlled or uncontrolled

  • Supports automatic and manual activation modes for web

  • Full keyboard navigation

Note: Tabs have landed on v1.7 and not fully ready for runtime. Send us your feedback and we'll address it. We're marking it Beta a such as there may be hopefully minimal breaking changes as we get feedback on the API.

Usage

import { SizableText, Tabs } from 'tamagui'
export default () => (
<Tabs defaultValue="tab1" width={400}>
<Tabs.List space>
<Tabs.Tab value="tab1">
<SizableText>Tab 1</SizableText>
</Tabs.Tab>
<Tabs.Tab value="tab2">
<SizableText>Tab 2</SizableText>
</Tabs.Tab>
</Tabs.List>
<Tabs.Content value="tab1">
<H5>Tab 1</H5>
</Tabs.Content>
<Tabs.Content value="tab2">
<H5>Tab 2</H5>
</Tabs.Content>
</Tabs>
)

API Reference

Tabs

Root tabs component. Extends Stack. Passing the size prop to this component will have effect on the descendants.

Props

  • value

    string

    The value for the selected tab, if controlled

  • defaultValue

    string

    The value of the tab to select by default, if uncontrolled

  • onValueChange

    (value: string) => void

    A function called when a new tab is selected

  • orientation

    "horizontal" | "vertical"

    Default: 

    horizontal

    The orientation the tabs are layed out

  • dir

    "ltr" | "rtl"

    The direction of navigation between toolbar items

  • activationMode

    "manual" | "automatic"

    Default: 

    automatic

    Whether or not a tab is activated automatically or manually. Not applicable on mobile

  • Tabs.List

    Container for the trigger buttons. Supports scrolling by extending Group. You can disable passing border radius to children by passing disablePassBorderRadius.

    Props

  • loop

    boolean

    Default: 

    true

    Whether or not to loop over after reaching the end or start of the items. Used mainly for managing keyboard navigation

  • Tabs.Trigger

    Extends Button, adding:

    Props

  • value

    string

    The value for the tabs state to be changed to after activation of the trigger

  • onInteraction

    (type: InteractionType, layout: TabTriggerLayout | null) => void

    Used for making custom indicators when trigger interacted with

  • unstyled

    boolean

    When true, remove all default tamagui styling

  • Tabs.Content

    Where each tab's content will be shown. Extends ThemeableStack, adding:

    Props

  • value

    string

    Will show the content when the value matches the state of Tabs root

  • forceMount

    boolean

    Default: 

    false

    Used to force mounting when more control is needed. Useful when controlling animation with Tamagui animations

  • Examples

    Animations

    Here is a demo with more advanced animations using AnimatePresence and Trigger's onInteraction prop.

    import React from 'react'
    import type { StackProps, TabLayout, TabsTabProps } from 'tamagui'
    import {
    AnimatePresence,
    Button,
    H5,
    SizableText,
    Tabs,
    XStack,
    YStack,
    styled,
    } from 'tamagui'
    const demos = ['background', 'underline'] as const
    const demosTitle: Record<(typeof demos)[number], string> = {
    background: 'Background Indicator',
    underline: 'Underline Indicator',
    }
    export const TabsAdvancedDemo = () => {
    const [demoIndex, setDemoIndex] = React.useState(0)
    const demo = demos[demoIndex]
    return (
    <>
    {demo === 'underline' ? <TabsAdvancedUnderline /> : <TabsAdvancedBackground />}
    <XStack alignItems="center" gap="$4" position="absolute" bottom="$3" left="$4" $xxs={{ display: 'none' }} >
    <Button size="$2" onPress={() => setDemoIndex((x) => (x + 1) % demos.length)}>
    {demosTitle[demo]}
    </Button>
    </XStack>
    </>
    )
    }
    const TabsAdvancedBackground = () => {
    const [tabState, setTabState] = React.useState<{ currentTab: string /** * Layout of the Tab user might intend to select (hovering / focusing) */ intentAt: TabLayout | null /** * Layout of the Tab user selected */ activeAt: TabLayout | null /** * Used to get the direction of activation for animating the active indicator */ prevActiveAt: TabLayout | null }>({
    activeAt: null,
    currentTab: 'tab1',
    intentAt: null,
    prevActiveAt: null,
    })
    const setCurrentTab = (currentTab: string) => setTabState({ ...tabState, currentTab })
    const setIntentIndicator = (intentAt) => setTabState({ ...tabState, intentAt })
    const setActiveIndicator = (activeAt) =>
    setTabState({ ...tabState, prevActiveAt: tabState.activeAt, activeAt })
    const { activeAt, intentAt, prevActiveAt, currentTab } = tabState
    // 1 = right, 0 = nowhere, -1 = left
    const direction = (() => {
    if (!activeAt || !prevActiveAt || activeAt.x === prevActiveAt.x) {
    return 0
    }
    return activeAt.x > prevActiveAt.x ? -1 : 1
    })()
    const handleOnInteraction: TabsTabProps['onInteraction'] = (type, layout) => {
    if (type === 'select') {
    setActiveIndicator(layout)
    } else {
    setIntentIndicator(layout)
    }
    }
    return (
    <Tabs value={currentTab} onValueChange={setCurrentTab} orientation="horizontal" size="$4" padding="$2" height={150} flexDirection="column" activationMode="manual" backgroundColor="$background" borderRadius="$4" position="relative" >
    <YStack>
    <AnimatePresence>
    {intentAt && (
    <TabsRovingIndicator borderRadius="$4" width={intentAt.width} height={intentAt.height} x={intentAt.x} y={intentAt.y} />
    )}
    </AnimatePresence>
    <AnimatePresence>
    {activeAt && (
    <TabsRovingIndicator borderRadius="$4" theme="active" width={activeAt.width} height={activeAt.height} x={activeAt.x} y={activeAt.y} />
    )}
    </AnimatePresence>
    <Tabs.List disablePassBorderRadius loop={false} aria-label="Manage your account" gap="$2" backgroundColor="transparent" >
    <Tabs.Tab unstyled paddingVertical="$2" paddingHorizontal="$3" value="tab1" onInteraction={handleOnInteraction} >
    <SizableText>Profile</SizableText>
    </Tabs.Tab>
    <Tabs.Tab unstyled paddingVertical="$2" paddingHorizontal="$3" value="tab2" onInteraction={handleOnInteraction} >
    <SizableText>Connections</SizableText>
    </Tabs.Tab>
    <Tabs.Tab unstyled paddingVertical="$2" paddingHorizontal="$3" value="tab3" onInteraction={handleOnInteraction} >
    <SizableText>Notifications</SizableText>
    </Tabs.Tab>
    </Tabs.List>
    </YStack>
    <AnimatePresence exitBeforeEnter custom={{ direction }} initial={false}>
    <AnimatedYStack key={currentTab}>
    <Tabs.Content value={currentTab} forceMount flex={1} justifyContent="center">
    <H5 textAlign="center">{currentTab}</H5>
    </Tabs.Content>
    </AnimatedYStack>
    </AnimatePresence>
    </Tabs>
    )
    }
    const TabsAdvancedUnderline = () => {
    const [tabState, setTabState] = React.useState<{ currentTab: string /** * Layout of the Tab user might intend to select (hovering / focusing) */ intentAt: TabLayout | null /** * Layout of the Tab user selected */ activeAt: TabLayout | null /** * Used to get the direction of activation for animating the active indicator */ prevActiveAt: TabLayout | null }>({
    activeAt: null,
    currentTab: 'tab1',
    intentAt: null,
    prevActiveAt: null,
    })
    const setCurrentTab = (currentTab: string) => setTabState({ ...tabState, currentTab })
    const setIntentIndicator = (intentAt) => setTabState({ ...tabState, intentAt })
    const setActiveIndicator = (activeAt) =>
    setTabState({ ...tabState, prevActiveAt: tabState.activeAt, activeAt })
    const { activeAt, intentAt, prevActiveAt, currentTab } = tabState
    // 1 = right, 0 = nowhere, -1 = left
    const direction = (() => {
    if (!activeAt || !prevActiveAt || activeAt.x === prevActiveAt.x) {
    return 0
    }
    return activeAt.x > prevActiveAt.x ? -1 : 1
    })()
    const handleOnInteraction: TabsTabProps['onInteraction'] = (type, layout) => {
    if (type === 'select') {
    setActiveIndicator(layout)
    } else {
    setIntentIndicator(layout)
    }
    }
    return (
    <Tabs value={currentTab} onValueChange={setCurrentTab} orientation="horizontal" size="$4" height={150} flexDirection="column" activationMode="manual" backgroundColor="$background" borderRadius="$4" >
    <YStack>
    <AnimatePresence>
    {intentAt && (
    <TabsRovingIndicator width={intentAt.width} height="$0.5" x={intentAt.x} bottom={0} />
    )}
    </AnimatePresence>
    <AnimatePresence>
    {activeAt && (
    <TabsRovingIndicator theme="active" active width={activeAt.width} height="$0.5" x={activeAt.x} bottom={0} />
    )}
    </AnimatePresence>
    <Tabs.List disablePassBorderRadius loop={false} aria-label="Manage your account" borderBottomLeftRadius={0} borderBottomRightRadius={0} paddingBottom="$1.5" borderColor="$color3" borderBottomWidth="$0.5" backgroundColor="transparent" >
    <Tabs.Tab unstyled paddingHorizontal="$3" paddingVertical="$2" value="tab1" onInteraction={handleOnInteraction} >
    <SizableText>Profile</SizableText>
    </Tabs.Tab>
    <Tabs.Tab unstyled paddingHorizontal="$3" paddingVertical="$2" value="tab2" onInteraction={handleOnInteraction} >
    <SizableText>Connections</SizableText>
    </Tabs.Tab>
    <Tabs.Tab unstyled paddingHorizontal="$3" paddingVertical="$2" value="tab3" onInteraction={handleOnInteraction} >
    <SizableText>Notifications</SizableText>
    </Tabs.Tab>
    </Tabs.List>
    </YStack>
    <AnimatePresence exitBeforeEnter custom={{ direction }} initial={false}>
    <AnimatedYStack key={currentTab}>
    <Tabs.Content value={currentTab} forceMount flex={1} justifyContent="center">
    <H5 textAlign="center">{currentTab}</H5>
    </Tabs.Content>
    </AnimatedYStack>
    </AnimatePresence>
    </Tabs>
    )
    }
    const TabsRovingIndicator = ({ active, ...props }: { active?: boolean } & StackProps) => {
    return (
    <YStack position="absolute" backgroundColor="$color5" opacity={0.7} animation="100ms" enterStyle={{ opacity: 0, }} exitStyle={{ opacity: 0, }} {...(active && { backgroundColor: '$color8', opacity: 0.6, })} {...props} />
    )
    }
    const AnimatedYStack = styled(YStack, {
    flex: 1,
    x: 0,
    opacity: 1,
    animation: '100ms',
    variants: {
    // 1 = right, 0 = nowhere, -1 = left
    direction: {
    ':number': (direction) => ({
    enterStyle: {
    x: direction > 0 ? -25 : 25,
    opacity: 0,
    },
    exitStyle: {
    zIndex: 0,
    x: direction < 0 ? -25 : 25,
    opacity: 0,
    },
    }),
    },
    } as const,
    })