Tab Bar
Floating tab bar with a liquid-glass pill you can hold and slide between tabs.
Installation
Terminal
npx magic-native-ui@latest add tab-barUsage
The example running in the preview above. Imports use the default aliases from components.json.
tab-bar-demo.tsx
import * as React from 'react'
import { View } from 'react-native'
import {
CircleUserRound,
House,
LayoutGrid,
PlayCircle,
ShoppingCart,
} from 'lucide-react-native'
import { TabBar, TabBarBadge, TabBarItem } from '@/components/ui/tab-bar'
import { Text } from '@/components/ui/text'
import { iconWithClassName } from '@/lib/icons'
const HouseIcon = iconWithClassName(House)
const PlayIcon = iconWithClassName(PlayCircle)
const GridIcon = iconWithClassName(LayoutGrid)
const AccountIcon = iconWithClassName(CircleUserRound)
const CartIcon = iconWithClassName(ShoppingCart)
type Tab = { value: string; label: string; Icon: typeof HouseIcon; badge?: string }
const TABS: Tab[] = [
{ value: 'home', label: 'Home', Icon: HouseIcon },
{ value: 'play', label: 'Play', Icon: PlayIcon },
{ value: 'categories', label: 'Categories', Icon: GridIcon },
{ value: 'account', label: 'Account', Icon: AccountIcon },
{ value: 'cart', label: 'Cart', Icon: CartIcon, badge: '1' },
]
export function TabBarDemo() {
const [tab, setTab] = React.useState<string>('home')
return (
<View className="w-full items-center gap-3">
<TabBar value={tab} onValueChange={setTab}>
{TABS.map(({ value, label, Icon, badge }) => (
<TabBarItem
key={value}
value={value}
label={label}
icon={({ selected }) => (
<Icon
size={20}
className={selected ? 'text-primary' : 'text-muted-foreground'}
/>
)}
badge={
badge ? (
<TabBarBadge>
<Text>{badge}</Text>
</TabBarBadge>
) : undefined
}
/>
))}
</TabBar>
<Text className="text-xs text-muted-foreground">
Tap a tab, or hold and slide across the bar.
</Text>
</View>
)
}Component source
What the CLI copies into your project. Once it is there, the code is yours to edit.
registry/ui/tab-bar.tsx
import * as React from 'react'
import {
Animated,
PanResponder,
Pressable,
View,
type LayoutRectangle,
type ViewProps,
} from 'react-native'
import { BlurView } from 'expo-blur'
import { withUniwind } from 'uniwind'
import { Text, TextClassContext } from '@/registry/ui/text'
import { cn } from '@/registry/lib/utils'
const AnimatedView = withUniwind(Animated.View)
const GlassView = withUniwind(BlurView)
/**
* Damped just short of bouncing: the pill should look heavy and settle in one
* move, not spring past the tab and come back.
*/
const SPRING = { stiffness: 220, damping: 26, mass: 1, useNativeDriver: false } as const
/** How far the pill stretches along its direction of travel while held. */
const STRETCH = 1.06
/** Movement, in points, before a press on a tab turns into a slide. */
const SLIDE_THRESHOLD = 4
type TabBarContextValue = {
value: string
select: (value: string) => void
measure: (value: string, layout: LayoutRectangle) => void
}
const TabBarContext = React.createContext<TabBarContextValue | null>(null)
function useTabBar() {
const context = React.useContext(TabBarContext)
if (!context) {
throw new Error('TabBar parts must be rendered inside a <TabBar>.')
}
return context
}
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max)
}
type TabBarProps = Omit<ViewProps, 'children'> & {
value: string
onValueChange: (value: string) => void
children: React.ReactNode
/** Strength of the blur behind the bar, 0-100. */
intensity?: number
tint?: React.ComponentProps<typeof BlurView>['tint']
}
/**
* A floating tab bar whose selection is a single pill that slides between
* tabs. Holding and dragging along the bar hands the pill to the finger — it
* tracks the touch directly, stretches while travelling and settles onto
* whichever tab it is released over, so the selection reads as one piece of
* liquid rather than a highlight blinking from tab to tab.
*/
function TabBar({
className,
value,
onValueChange,
children,
intensity = 60,
tint = 'default',
...props
}: TabBarProps) {
const [layouts, setLayouts] = React.useState<Record<string, LayoutRectangle>>({})
// The pan handlers are created once, so everything a drag reads has to come
// from a ref rather than from that first render's values.
const layoutsRef = React.useRef(layouts)
const valueRef = React.useRef(value)
const originRef = React.useRef(0)
const rowWidthRef = React.useRef(0)
const pillWidthRef = React.useRef(0)
const draggingRef = React.useRef(false)
const hoverRef = React.useRef<string | null>(null)
const rowRef = React.useRef<View>(null)
layoutsRef.current = layouts
valueRef.current = value
const x = React.useRef(new Animated.Value(0)).current
const width = React.useRef(new Animated.Value(0)).current
const stretch = React.useRef(new Animated.Value(1)).current
// The pill has nowhere to sit until the tabs have been measured.
const [ready, setReady] = React.useState(false)
// Following the finger means positioning the pill by its centre, which needs
// whatever width it currently has rather than the width it is heading for.
React.useEffect(() => {
const id = width.addListener(({ value: current }) => {
pillWidthRef.current = current
})
return () => width.removeListener(id)
}, [width])
const measure = React.useCallback((tab: string, layout: LayoutRectangle) => {
setLayouts((current) => {
const previous = current[tab]
if (previous?.x === layout.x && previous?.width === layout.width) {
return current
}
return { ...current, [tab]: layout }
})
}, [])
const settle = React.useCallback(
(tab: string) => {
const layout = layoutsRef.current[tab]
if (!layout) return
Animated.parallel([
Animated.spring(x, { ...SPRING, toValue: layout.x }),
Animated.spring(width, { ...SPRING, toValue: layout.width }),
]).start()
},
[width, x]
)
const active = layouts[value]
React.useEffect(() => {
if (!active) return
// A drag positions the pill itself; springing at the same time fights it.
if (draggingRef.current) return
if (!ready) {
// Appear in place the first time, rather than sliding in from the edge.
x.setValue(active.x)
width.setValue(active.width)
pillWidthRef.current = active.width
setReady(true)
return
}
Animated.parallel([
Animated.spring(x, { ...SPRING, toValue: active.x }),
Animated.spring(width, { ...SPRING, toValue: active.width }),
]).start()
}, [active, ready, width, x])
const panResponder = React.useMemo(
() =>
PanResponder.create({
// A tap belongs to the tab under it; only a sideways drag is a slide.
onMoveShouldSetPanResponder: (_event, gesture) =>
Math.abs(gesture.dx) > SLIDE_THRESHOLD && Math.abs(gesture.dx) > Math.abs(gesture.dy),
onPanResponderGrant: () => {
draggingRef.current = true
hoverRef.current = valueRef.current
x.stopAnimation()
Animated.spring(stretch, { ...SPRING, toValue: STRETCH }).start()
},
onPanResponderMove: (_event, gesture) => {
const local = gesture.moveX - originRef.current
const hit = Object.entries(layoutsRef.current).find(
([, layout]) => local >= layout.x && local <= layout.x + layout.width
)
if (hit && hit[0] !== hoverRef.current) {
hoverRef.current = hit[0]
// Only the width is animated during a drag, and only when the tab
// under the finger changes — restarting it every frame stutters.
Animated.spring(width, { ...SPRING, toValue: hit[1].width }).start()
onValueChange(hit[0])
}
// Tracking the touch directly is what makes the drag feel attached.
const pill = pillWidthRef.current
x.setValue(clamp(local - pill / 2, 0, Math.max(rowWidthRef.current - pill, 0)))
},
onPanResponderRelease: () => {
draggingRef.current = false
Animated.spring(stretch, { ...SPRING, toValue: 1 }).start()
settle(hoverRef.current ?? valueRef.current)
},
onPanResponderTerminate: () => {
draggingRef.current = false
Animated.spring(stretch, { ...SPRING, toValue: 1 }).start()
settle(hoverRef.current ?? valueRef.current)
},
}),
[onValueChange, settle, stretch, width, x]
)
const context = React.useMemo<TabBarContextValue>(
() => ({ value, select: onValueChange, measure }),
[measure, onValueChange, value]
)
return (
<TabBarContext.Provider value={context}>
<GlassView
intensity={intensity}
tint={tint}
className={cn(
'w-full overflow-hidden rounded-full border border-border/60 bg-card/70 shadow-lg',
className
)}
{...props}
>
<View className="flex-row items-center p-1">
<View
ref={rowRef}
className="w-full flex-row items-center"
// A drag reports screen coordinates, while tabs are measured from
// this row, so the row keeps track of where it sits on screen.
onLayout={(event) => {
rowWidthRef.current = event.nativeEvent.layout.width
rowRef.current?.measureInWindow((pageX) => {
originRef.current = pageX
})
}}
{...panResponder.panHandlers}
>
<View className="absolute bottom-0 left-0 right-0 top-0" pointerEvents="none">
<AnimatedView
style={{
width,
opacity: ready ? 1 : 0,
transform: [{ translateX: x }, { scaleX: stretch }],
}}
className="h-full rounded-full border border-border/50 bg-foreground/10"
/>
</View>
{children}
</View>
</View>
</GlassView>
</TabBarContext.Provider>
)
}
type TabBarItemProps = Omit<React.ComponentProps<typeof Pressable>, 'children'> & {
value: string
label?: string
/** Handed the selected state, so the caller can colour its own icon. */
icon?: (state: { selected: boolean }) => React.ReactNode
badge?: React.ReactNode
}
/**
* `flex-1` gives every tab the same share of the bar, so the pill keeps one
* width wherever it lands rather than resizing to each label. `h-12` plus the
* bar's own padding is a 56pt bar — the height a compact iOS tab bar sits at,
* and tight enough that the labels stay close to their icons.
*/
function TabBarItem({ className, value, label, icon, badge, ...props }: TabBarItemProps) {
const { value: current, select, measure } = useTabBar()
const selected = current === value
return (
<Pressable
role="tab"
aria-selected={selected}
onPress={() => select(value)}
onLayout={(event) => measure(value, event.nativeEvent.layout)}
className={cn('h-12 flex-1 items-center justify-center gap-1 px-0.5', className)}
{...props}
>
<TextClassContext.Provider
// 10pt is the size iOS sets tab labels at, and the size that keeps a
// word like "Categories" intact once five tabs share a phone's width.
value={cn(
'text-[10px] font-medium leading-none',
selected ? 'text-primary' : 'text-muted-foreground'
)}
>
{icon ? (
<View>
{icon({ selected })}
{badge}
</View>
) : null}
{label ? <Text numberOfLines={1}>{label}</Text> : null}
</TextClassContext.Provider>
</Pressable>
)
}
/** A count or dot pinned to the top-right of a tab icon. */
function TabBarBadge({ className, children, ...props }: ViewProps) {
return (
<View
className={cn(
'absolute -right-2.5 -top-1.5 h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1',
className
)}
{...props}
>
<TextClassContext.Provider value="text-[10px] font-bold leading-none text-destructive-foreground">
{children}
</TextClassContext.Provider>
</View>
)
}
export { TabBar, TabBarBadge, TabBarItem, useTabBar }
export type { TabBarItemProps, TabBarProps }