suiss/uim · React Native Component
Tabs
A set of tabs that switch between panels of content shown in the same place.
Tabs is an open-source mobile component from suiss/uim for React Native and Expo, with SwiftUI and Jetpack Compose versions built from the same design tokens. The @esuiss/uim CLI copies its source into your project, so you own the code.


import * as React from "react"
import { View } from "react-native"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Text } from "@/components/ui/text"
function TabsDemo() {
const [value, setValue] = React.useState("account")
return (
<Tabs value={value} onValueChange={setValue} className="w-full">
<TabsList>
<TabsTrigger value="account">
<Text>Account</Text>
</TabsTrigger>
<TabsTrigger value="password">
<Text>Password</Text>
</TabsTrigger>
</TabsList>
<TabsContent value="account">
<Card>
<CardHeader>
<CardTitle>Account</CardTitle>
<CardDescription>
Make changes to your account here.
</CardDescription>
</CardHeader>
<CardContent className="gap-4">
<View className="gap-2">
<Label nativeID="tabs-name">Name</Label>
<Input
accessibilityLabelledBy="tabs-name"
defaultValue="Pedro Duarte"
/>
</View>
<View className="gap-2">
<Label nativeID="tabs-username">Username</Label>
<Input
accessibilityLabelledBy="tabs-username"
defaultValue="@peduarte"
autoCapitalize="none"
/>
</View>
</CardContent>
<CardFooter>
<Button>
<Text>Save changes</Text>
</Button>
</CardFooter>
</Card>
</TabsContent>
<TabsContent value="password">
<Card>
<CardHeader>
<CardTitle>Password</CardTitle>
<CardDescription>
Change your password here. You will be logged out after saving.
</CardDescription>
</CardHeader>
<CardContent className="gap-4">
<View className="gap-2">
<Label nativeID="tabs-current">Current password</Label>
<Input accessibilityLabelledBy="tabs-current" secureTextEntry />
</View>
<View className="gap-2">
<Label nativeID="tabs-new">New password</Label>
<Input accessibilityLabelledBy="tabs-new" secureTextEntry />
</View>
</CardContent>
<CardFooter>
<Button>
<Text>Save password</Text>
</Button>
</CardFooter>
</Card>
</TabsContent>
</Tabs>
)
}
export function TabsExample() {
return (
<TabsDemo />
)
}import EsuissUI
import SwiftUI
struct TabsDemo: View {
@State private var value = "account"
@State private var name = "Pedro Duarte"
@State private var username = "@peduarte"
@State private var current = ""
@State private var next = ""
var body: some View {
Tabs(selection: $value) {
TabsList {
TabsTrigger("Account", value: "account")
TabsTrigger("Password", value: "password")
}
TabsContent(value: "account") {
Card {
CardHeader("Account", description: "Make changes to your account here.")
CardContent {
VStack(alignment: .leading, spacing: 16) {
TabsLabeledField(label: "Name") {
TextField("Name", text: $name).textFieldStyle(.input)
}
TabsLabeledField(label: "Username") {
TextField("Username", text: $username)
.textFieldStyle(.input)
.textInputAutocapitalization(.never)
}
}
}
CardFooter {
Button("Save changes") {}.buttonStyle(.variant())
}
}
}
TabsContent(value: "password") {
Card {
CardHeader("Password", description: "Change your password here. You will be logged out after saving.")
CardContent {
VStack(alignment: .leading, spacing: 16) {
TabsLabeledField(label: "Current password") {
SecureField("Current password", text: $current).textFieldStyle(.input)
}
TabsLabeledField(label: "New password") {
SecureField("New password", text: $next).textFieldStyle(.input)
}
}
}
CardFooter {
Button("Save password") {}.buttonStyle(.variant())
}
}
}
}
.frame(maxWidth: .infinity)
}
}
struct TabsLabeledField<Content: View>: View {
let label: LocalizedStringKey
@ViewBuilder let content: () -> Content
var body: some View {
VStack(alignment: .leading, spacing: 8) {
FieldLabel(label)
.accessibilityHidden(true)
content()
.frame(maxWidth: .infinity)
}
}
}import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.Card
import com.esuiss.ui.components.CardContent
import com.esuiss.ui.components.CardFooter
import com.esuiss.ui.components.CardHeader
import com.esuiss.ui.components.Input
import com.esuiss.ui.components.Label
import com.esuiss.ui.components.Tabs
import com.esuiss.ui.components.TabsContent
import com.esuiss.ui.components.TabsList
import com.esuiss.ui.components.TabsTrigger
@Composable
fun TabsField(label: String, initial: String, password: Boolean = false, capitalize: Boolean = true) {
var value by rememberSaveable(label) { mutableStateOf(initial) }
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Label(label)
Input(
value,
{ value = it },
Modifier.semantics { contentDescription = label },
keyboardOptions = when {
password -> KeyboardOptions(keyboardType = KeyboardType.Password)
capitalize -> KeyboardOptions.Default
else -> KeyboardOptions(capitalization = KeyboardCapitalization.None, autoCorrectEnabled = false)
},
visualTransformation = if (password) PasswordVisualTransformation() else VisualTransformation.None,
)
}
}
@Composable
fun TabsDemo() {
var value by rememberSaveable { mutableStateOf("account") }
Tabs(value, { value = it }, Modifier.fillMaxWidth()) {
TabsList {
TabsTrigger("account", "Account")
TabsTrigger("password", "Password")
}
TabsContent("account") {
Card(Modifier.fillMaxWidth()) {
CardHeader("Account", description = "Make changes to your account here.")
CardContent {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
TabsField("Name", "Pedro Duarte")
TabsField("Username", "@peduarte", capitalize = false)
}
}
CardFooter {
Button("Save changes", onClick = {})
}
}
}
TabsContent("password") {
Card(Modifier.fillMaxWidth()) {
CardHeader("Password", description = "Change your password here. You will be logged out after saving.")
CardContent {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
TabsField("Current password", "", password = true)
TabsField("New password", "", password = true)
}
}
CardFooter {
Button("Save password", onClick = {})
}
}
}
}
}Tabs groups a TabsList of TabsTrigger buttons with matching TabsContent panels. The active tab is marked by an indicator that slides between tabs with a Reanimated layout transition.
TabsList has two variants: default, a segmented control with a raised indicator on a filled track, and line, a transparent list with a thin underline. Both work in horizontal and vertical orientation.
Installation
$ pnpm dlx @esuiss/uim@latest add tabs$ pnpm dlx @esuiss/uim@latest add tabs --platform swift$ pnpm dlx @esuiss/uim@latest add tabs --platform kotlinThe CLI also adds what it builds on: Text.
Manual
- Install the dependencies:
npx expo install class-variance-authority cn react-native-reanimated react-native-worklets-
Add the pieces it builds on: Text, and the primitives
components/primitives/tabs.tsx,components/primitives/use-render.tsx. -
Copy the source into
components/ui/tabs.tsx:
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import * as React from "react"
import Animated, {
Easing,
LinearTransition,
ReduceMotion,
} from "react-native-reanimated"
import * as TabsPrimitive from "@/components/primitives/tabs"
import { resolveClassName } from "@/components/primitives/use-render"
import { TextClassContext } from "@/components/ui/text"
type TabsListVariant = NonNullable<
VariantProps<typeof tabsListVariants>["variant"]
>- Update the import paths to match your project.
- Install the dependencies:
npx expo install class-variance-authority cn react-native-reanimated react-native-worklets-
Add the pieces it builds on: Text, and the primitives
components/primitives/tabs.tsx,components/primitives/use-render.tsx. -
Copy the source into
components/ui/tabs.tsx:
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import * as React from "react"
import Animated, {
Easing,
LinearTransition,
ReduceMotion,
} from "react-native-reanimated"
import * as TabsPrimitive from "@/components/primitives/tabs"
import { resolveClassName } from "@/components/primitives/use-render"
import { TextClassContext } from "@/components/ui/text"
type TabsListVariant = NonNullable<
VariantProps<typeof tabsListVariants>["variant"]
>- Update the import paths to match your project.
- Install the dependencies:
npx expo install react-native-reanimated react-native-worklets-
Add the pieces it builds on: Text, and the primitives
components/primitives/tabs.tsx,components/primitives/use-render.tsx. -
Copy the source into
components/ui/tabs.tsx:
import * as React from "react"
import { StyleSheet } from "react-native"
import Animated, {
Easing,
LinearTransition,
ReduceMotion,
} from "react-native-reanimated"
import * as TabsPrimitive from "@/components/primitives/tabs"
import { TextStyleContext } from "@/components/ui/text"
import { fontSize, radius, shadow, useTheme } from "@/components/ui/theme"
type TabsListVariant = "default" | "line"
const TabsListContext = React.createContext<TabsListVariant>("default")
const INDICATOR_TRANSITION = LinearTransition.duration(300)- Update the import paths to match your project.
-
Target iOS 17 or later.
-
Add the Swift package to your app:
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")- Copy the theme files every view uses:
import SwiftUI
import UIKit
struct RGBA: Sendable {
let red: CGFloat
let green: CGFloat
let blue: CGFloat
let alpha: CGFloat
init(_ hex: UInt32, _ alpha: CGFloat = 1) {
red = CGFloat((hex >> 16) & 0xFF) / 255
green = CGFloat((hex >> 8) & 0xFF) / 255
blue = CGFloat(hex & 0xFF) / 255
self.alpha = alpha
}import SwiftUI
extension Color {
public static let surface = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x000000))
public static let surfaceSecondary = Color.adaptive(light: .init(0xF5F5F7), dark: .init(0x161617))
public static let surfaceTertiary = Color.adaptive(light: .init(0xE8E8ED), dark: .init(0x2C2C2E))
public static let surfaceRaised = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x1D1D1F))
public static let label = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
public static let labelSecondary = Color.adaptive(light: .init(0x6E6E73), dark: .init(0xA1A1A6))
public static let labelTertiary = Color.adaptive(light: .init(0x86868B), dark: .init(0x86868B))
public static let labelQuaternary = Color.adaptive(light: .init(0xD2D2D7), dark: .init(0x424245))
public static let control = Color.adaptive(light: .init(0x000000, 0.04), dark: .init(0xFFFFFF, 0.1))
public static let controlPressed = Color.adaptive(light: .init(0x000000, 0.12), dark: .init(0xFFFFFF, 0.22))
public static let controlSelected = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
public static let controlDisabled = Color.adaptive(light: .init(0x000000, 0.03), dark: .init(0xFFFFFF, 0.05))
public static let onControlSelected = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x000000))- Copy the source and every file it uses:
import SwiftUI
public struct Tabs<Content: View>: View {
let selection: Binding<AnyHashable>
let orientation: Axis
let content: Content
@Environment(\.accessibilityReduceMotion) private var reduceMotion
public init<Value: Hashable & Sendable>(
selection: Binding<Value>,
orientation: Axis = .horizontal,
@ViewBuilder content: () -> Content
) {
self.selection = Binding(
get: { AnyHashable(selection.wrappedValue) },import SwiftUI
public struct VariantButtonStyle: ButtonStyle {
public enum Variant: CaseIterable, Sendable {
case `default`, outline, secondary, tinted, ghost, plain, destructive, link
}
public enum Size: CaseIterable, Sendable {
case `default`, xs, sm, lg, xl, icon, iconXs, iconSm, iconLg, iconXl
var height: CGFloat {
switch self {
case .xs, .iconXs: 24
case .sm, .iconSm: 32
case .default, .icon: 36
case .lg, .iconLg: 44import SwiftUI
extension EnvironmentValues {
@Entry var buttonGroup: Axis? = nil
@Entry var buttonGroupSize: CGSize = .zero
}
enum ButtonGroupSpace: Hashable {
case group
}
struct ButtonGroupFrame {
let bounds: Anchor<CGRect>
let outlined: Bool
}import PhosphorSwift
import SwiftUI
extension Shadow {
static let control = Shadow(color: .black.opacity(0.08), radius: 0.5, y: 0.5)
static let thumb = Shadow(color: .black.opacity(0.12), radius: 1.5, y: 1)
}
struct SelectionIndicator<Mark: View>: View {
let checked: Bool
let pressed: Bool
let cornerRadius: CGFloat
let mark: Mark
@Environment(\.isEnabled) private var isEnabled
@Environment(\.isInvalid) private var invalidimport PhosphorSwift
import SwiftUI
enum MenuPresentation: Sendable {
case system, popup
}
extension EnvironmentValues {
@Entry var menuPresentation: MenuPresentation = .system
@Entry var menuRadio = MenuRadioContext()
}
struct MenuRadioContext: @unchecked Sendable {
var selected: AnyHashable?
var select: @MainActor @Sendable (AnyHashable) -> Void = { _ in }
}import SwiftUI
extension EnvironmentValues {
@Entry public var isInvalid = false
@Entry var fieldInset = false
@Entry var fieldGroupInset = false
@Entry var fieldSetInset = false
}
extension View {
public func invalid(_ isInvalid: Bool = true) -> some View {
environment(\.isInvalid, isInvalid)
}
}
public enum FieldOrientation: CaseIterable, Sendable {import SwiftUI
extension EnvironmentValues {
@Entry public var hapticsEnabled = true
}
extension View {
public func haptics(_ enabled: Bool) -> some View {
environment(\.hapticsEnabled, enabled)
}
func hapticFeedback<Trigger: Equatable>(_ feedback: SensoryFeedback, trigger: Trigger) -> some View {
modifier(HapticFeedbackModifier(feedback: feedback, trigger: trigger) { _, _ in true })
}
func hapticFeedback<Trigger: Equatable>(import PhosphorSwift
import Observation
import SwiftUI
public enum PopupAlignment: Sendable, CaseIterable {
case start, center, end
}
struct OverlayDismiss: Sendable {
let action: @MainActor @Sendable () -> Void
@MainActor func callAsFunction() {
action()
}
}import PhosphorSwift
import SwiftUI
struct SelectContext: @unchecked Sendable {
var selected: AnyHashable?
var registering = false
var select: @MainActor @Sendable (AnyHashable) -> Void = { _ in }
}
extension EnvironmentValues {
@Entry var select = SelectContext()
}
struct SelectLabelsKey: PreferenceKey {
static var defaultValue: [AnyHashable: Text] { [:] }import SwiftUI
public struct Separator: View {
let orientation: Axis
public init(_ orientation: Axis = .horizontal) {
self.orientation = orientation
}
public var body: some View {
Rectangle()
.fill(Color.separator)
.frame(width: orientation == .vertical ? 1 : nil, height: orientation == .horizontal ? 1 : nil)
.frame(maxWidth: orientation == .horizontal ? .infinity : nil, maxHeight: orientation == .vertical ? .infinity : nil)
.accessibilityHidden(true)
}import SwiftUI
public enum TextVariant: CaseIterable, Sendable {
case `default`, h1, h2, h3, h4, p, blockquote, code, lead, large, small, muted
var style: TextStyle {
switch self {
case .h1: .xl3
case .h2: .xl2
case .h3: .xl
case .h4, .lead, .large: .lg
case .code, .small, .muted: .sm
default: .base
}
}- Add the files to your app target.
- Set
minSdkto 24 or higher and add the dependencies:
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")- Copy the theme files every view uses:
package com.esuiss.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.shadow.Shadow as DropShadow
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffsetpackage com.esuiss.ui.theme
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
@Immutable
data class Colors(
val surface: Color,
val surfaceSecondary: Color,
val surfaceTertiary: Color,
val surfaceRaised: Color,
val label: Color,
val labelSecondary: Color,
val labelTertiary: Color,
val labelQuaternary: Color,- Copy the source and every file it uses:
package com.esuiss.ui.components
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidthpackage com.esuiss.ui.components
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
val LocalContentStyle = compositionLocalOf { TextStyle.Default }
val LocalContentColor = compositionLocalOf { Color.Unspecified }
val LocalIconSize = compositionLocalOf { 20.dp }- Update the
com.esuiss.uipackage names to match your project.
Usage
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"<TabsDemo />Give each TabsTrigger and its TabsContent the same value. Use defaultValue for an uncontrolled tab set, or value with onValueChange to control it.
Set orientation="vertical" on Tabs to stack the list beside the panels. Put an Icon next to the Text in a trigger for icon tabs.
Inactive panels are unmounted unless you set keepMounted on TabsContent. tabsListVariants is exported for reuse.
Examples
With icons
Tabs with an icon beside each label.
import { GearIcon, UserIcon } from "phosphor-react-native"
import * as React from "react"
import { Icon } from "@/components/ui/icon"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Text } from "@/components/ui/text"
type ListVariant = "default" | "line"
function WithIconsDemo({ variant }: { variant?: ListVariant }) {
const [value, setValue] = React.useState("profile")
return (
<Tabs value={value} onValueChange={setValue}>
<TabsList variant={variant}>
<TabsTrigger value="profile">
<Icon as={UserIcon} className="size-4" />
<Text>Profile</Text>
</TabsTrigger>
<TabsTrigger value="settings">
<Icon as={GearIcon} className="size-4" />
<Text>Settings</Text>
</TabsTrigger>
</TabsList>
<TabsContent value="profile">
<Text className="text-sm text-label-secondary">
Your public profile information.
</Text>
</TabsContent>
<TabsContent value="settings">
<Text className="text-sm text-label-secondary">
Manage your preferences.
</Text>
</TabsContent>
</Tabs>
)
}
export function TabsWithIconsExample() {
return (
<WithIconsDemo />
)
}import EsuissUI
import PhosphorSwift
import SwiftUI
struct TabsPanelText: View {
let text: LocalizedStringKey
var body: some View {
Text(text)
.textStyle(.sm)
.foregroundStyle(Color.labelSecondary)
}
}
struct TabsWithIconsDemo: View {
var variant: TabsListVariant = .default
@State private var value = "profile"
var body: some View {
Tabs(selection: $value) {
TabsList(variant: variant) {
TabsTrigger(value: "profile") {
Label {
Text("Profile")
} icon: {
Ph.user.regular
}
}
TabsTrigger(value: "settings") {
Label {
Text("Settings")
} icon: {
Ph.gear.regular
}
}
}
TabsContent(value: "profile") {
TabsPanelText(text: "Your public profile information.")
}
TabsContent(value: "settings") {
TabsPanelText(text: "Manage your preferences.")
}
}
.frame(maxWidth: .infinity)
}
}import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.Gear
import com.adamglin.phosphoricons.regular.User
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.Tabs
import com.esuiss.ui.components.TabsContent
import com.esuiss.ui.components.TabsList
import com.esuiss.ui.components.TabsListVariant
import com.esuiss.ui.components.TabsTrigger
import com.esuiss.ui.components.Text
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography
@Composable
fun TabsWithIconsExample() {
TabsWithIcons(TabsListVariant.Default)
}
@Composable
fun TabsSecondaryText(text: String) {
Text(text, style = Typography.sm, color = EsuissTheme.colors.labelSecondary)
}
@Composable
fun TabsWithIcons(variant: TabsListVariant) {
var value by rememberSaveable { mutableStateOf("profile") }
Tabs(value, { value = it }) {
TabsList(variant = variant) {
TabsTrigger("profile") {
Icon(PhosphorIcons.Regular.User, contentDescription = null, size = 16.dp)
Text("Profile")
}
TabsTrigger("settings") {
Icon(PhosphorIcons.Regular.Gear, contentDescription = null, size = 16.dp)
Text("Settings")
}
}
TabsContent("profile") { TabsSecondaryText("Your public profile information.") }
TabsContent("settings") { TabsSecondaryText("Manage your preferences.") }
}
}Disabled tab
A tab set with one disabled tab.


import * as React from "react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Text } from "@/components/ui/text"
type ListVariant = "default" | "line"
function DisabledTabDemo({ variant }: { variant?: ListVariant }) {
const [value, setValue] = React.useState("overview")
return (
<Tabs value={value} onValueChange={setValue}>
<TabsList variant={variant}>
<TabsTrigger value="overview">
<Text>Overview</Text>
</TabsTrigger>
<TabsTrigger value="analytics">
<Text>Analytics</Text>
</TabsTrigger>
<TabsTrigger value="reports" disabled>
<Text>Reports</Text>
</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<Text className="text-sm text-label-secondary">
A summary of your activity.
</Text>
</TabsContent>
<TabsContent value="analytics">
<Text className="text-sm text-label-secondary">
Detailed metrics and trends.
</Text>
</TabsContent>
</Tabs>
)
}
export function TabsDisabledTabExample() {
return (
<DisabledTabDemo />
)
}import EsuissUI
import SwiftUI
struct TabsPanelText: View {
let text: LocalizedStringKey
var body: some View {
Text(text)
.textStyle(.sm)
.foregroundStyle(Color.labelSecondary)
}
}
struct TabsDisabledDemo: View {
var variant: TabsListVariant = .default
@State private var value = "overview"
var body: some View {
Tabs(selection: $value) {
TabsList(variant: variant) {
TabsTrigger("Overview", value: "overview")
TabsTrigger("Analytics", value: "analytics")
TabsTrigger("Reports", value: "reports")
.disabled(true)
}
TabsContent(value: "overview") {
TabsPanelText(text: "A summary of your activity.")
}
TabsContent(value: "analytics") {
TabsPanelText(text: "Detailed metrics and trends.")
}
}
.frame(maxWidth: .infinity)
}
}import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import com.esuiss.ui.components.Tabs
import com.esuiss.ui.components.TabsContent
import com.esuiss.ui.components.TabsList
import com.esuiss.ui.components.TabsListVariant
import com.esuiss.ui.components.TabsTrigger
import com.esuiss.ui.components.Text
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography
@Composable
fun TabsDisabledTabExample() {
TabsDisabledTab(TabsListVariant.Default)
}
@Composable
fun TabsSecondaryText(text: String) {
Text(text, style = Typography.sm, color = EsuissTheme.colors.labelSecondary)
}
@Composable
fun TabsDisabledTab(variant: TabsListVariant) {
var value by rememberSaveable { mutableStateOf("overview") }
Tabs(value, { value = it }) {
TabsList(variant = variant) {
TabsTrigger("overview", "Overview")
TabsTrigger("analytics", "Analytics")
TabsTrigger("reports", "Reports", enabled = false)
}
TabsContent("overview") { TabsSecondaryText("A summary of your activity.") }
TabsContent("analytics") { TabsSecondaryText("Detailed metrics and trends.") }
}
}Line
The line variant with an underline indicator.


import * as React from "react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Text } from "@/components/ui/text"
type ListVariant = "default" | "line"
function SectionsDemo({
variant,
orientation,
}: {
variant?: ListVariant
orientation?: "horizontal" | "vertical"
}) {
const [value, setValue] = React.useState("overview")
return (
<Tabs value={value} onValueChange={setValue} orientation={orientation}>
<TabsList variant={variant}>
<TabsTrigger value="overview">
<Text>Overview</Text>
</TabsTrigger>
<TabsTrigger value="analytics">
<Text>Analytics</Text>
</TabsTrigger>
<TabsTrigger value="reports">
<Text>Reports</Text>
</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<Text className="text-sm text-label-secondary">
A summary of your activity.
</Text>
</TabsContent>
<TabsContent value="analytics">
<Text className="text-sm text-label-secondary">
Detailed metrics and trends.
</Text>
</TabsContent>
<TabsContent value="reports">
<Text className="text-sm text-label-secondary">
Exported reports and schedules.
</Text>
</TabsContent>
</Tabs>
)
}
export function TabsLineExample() {
return (
<SectionsDemo variant="line" />
)
}import EsuissUI
import SwiftUI
struct TabsLineExample: View {
var body: some View {
TabsSectionsDemo(variant: .line)
}
}
struct TabsPanelText: View {
let text: LocalizedStringKey
var body: some View {
Text(text)
.textStyle(.sm)
.foregroundStyle(Color.labelSecondary)
}
}
struct TabsSectionsDemo: View {
var variant: TabsListVariant = .default
var orientation: Axis = .horizontal
@State private var value = "overview"
var body: some View {
Tabs(selection: $value, orientation: orientation) {
TabsList(variant: variant) {
TabsTrigger("Overview", value: "overview")
TabsTrigger("Analytics", value: "analytics")
TabsTrigger("Reports", value: "reports")
}
TabsContent(value: "overview") {
TabsPanelText(text: "A summary of your activity.")
}
TabsContent(value: "analytics") {
TabsPanelText(text: "Detailed metrics and trends.")
}
TabsContent(value: "reports") {
TabsPanelText(text: "Exported reports and schedules.")
}
}
.frame(maxWidth: .infinity)
}
}import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import com.esuiss.ui.components.Tabs
import com.esuiss.ui.components.TabsContent
import com.esuiss.ui.components.TabsList
import com.esuiss.ui.components.TabsListVariant
import com.esuiss.ui.components.TabsTrigger
import com.esuiss.ui.components.Text
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography
@Composable
fun TabsLineExample() {
TabsSections(TabsListVariant.Line)
}
@Composable
fun TabsSecondaryText(text: String) {
Text(text, style = Typography.sm, color = EsuissTheme.colors.labelSecondary)
}
@Composable
fun TabsSections(variant: TabsListVariant) {
var value by rememberSaveable { mutableStateOf("overview") }
Tabs(value, { value = it }) {
TabsList(variant = variant) {
TabsTrigger("overview", "Overview")
TabsTrigger("analytics", "Analytics")
TabsTrigger("reports", "Reports")
}
TabsContent("overview") { TabsSecondaryText("A summary of your activity.") }
TabsContent("analytics") { TabsSecondaryText("Detailed metrics and trends.") }
TabsContent("reports") { TabsSecondaryText("Exported reports and schedules.") }
}
}Vertical
A vertical tab list next to its panels.


import * as React from "react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Text } from "@/components/ui/text"
type ListVariant = "default" | "line"
function SectionsDemo({
variant,
orientation,
}: {
variant?: ListVariant
orientation?: "horizontal" | "vertical"
}) {
const [value, setValue] = React.useState("overview")
return (
<Tabs value={value} onValueChange={setValue} orientation={orientation}>
<TabsList variant={variant}>
<TabsTrigger value="overview">
<Text>Overview</Text>
</TabsTrigger>
<TabsTrigger value="analytics">
<Text>Analytics</Text>
</TabsTrigger>
<TabsTrigger value="reports">
<Text>Reports</Text>
</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<Text className="text-sm text-label-secondary">
A summary of your activity.
</Text>
</TabsContent>
<TabsContent value="analytics">
<Text className="text-sm text-label-secondary">
Detailed metrics and trends.
</Text>
</TabsContent>
<TabsContent value="reports">
<Text className="text-sm text-label-secondary">
Exported reports and schedules.
</Text>
</TabsContent>
</Tabs>
)
}
export function TabsVerticalExample() {
return (
<SectionsDemo orientation="vertical" />
)
}import EsuissUI
import SwiftUI
struct TabsVerticalExample: View {
var body: some View {
TabsSectionsDemo(orientation: .vertical)
}
}
struct TabsPanelText: View {
let text: LocalizedStringKey
var body: some View {
Text(text)
.textStyle(.sm)
.foregroundStyle(Color.labelSecondary)
}
}
struct TabsSectionsDemo: View {
var variant: TabsListVariant = .default
var orientation: Axis = .horizontal
@State private var value = "overview"
var body: some View {
Tabs(selection: $value, orientation: orientation) {
TabsList(variant: variant) {
TabsTrigger("Overview", value: "overview")
TabsTrigger("Analytics", value: "analytics")
TabsTrigger("Reports", value: "reports")
}
TabsContent(value: "overview") {
TabsPanelText(text: "A summary of your activity.")
}
TabsContent(value: "analytics") {
TabsPanelText(text: "Detailed metrics and trends.")
}
TabsContent(value: "reports") {
TabsPanelText(text: "Exported reports and schedules.")
}
}
.frame(maxWidth: .infinity)
}
}Vertical line
A vertical line list with a side indicator.


import * as React from "react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Text } from "@/components/ui/text"
type ListVariant = "default" | "line"
function SectionsDemo({
variant,
orientation,
}: {
variant?: ListVariant
orientation?: "horizontal" | "vertical"
}) {
const [value, setValue] = React.useState("overview")
return (
<Tabs value={value} onValueChange={setValue} orientation={orientation}>
<TabsList variant={variant}>
<TabsTrigger value="overview">
<Text>Overview</Text>
</TabsTrigger>
<TabsTrigger value="analytics">
<Text>Analytics</Text>
</TabsTrigger>
<TabsTrigger value="reports">
<Text>Reports</Text>
</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<Text className="text-sm text-label-secondary">
A summary of your activity.
</Text>
</TabsContent>
<TabsContent value="analytics">
<Text className="text-sm text-label-secondary">
Detailed metrics and trends.
</Text>
</TabsContent>
<TabsContent value="reports">
<Text className="text-sm text-label-secondary">
Exported reports and schedules.
</Text>
</TabsContent>
</Tabs>
)
}
export function TabsVerticalLineExample() {
return (
<SectionsDemo variant="line" orientation="vertical" />
)
}import EsuissUI
import SwiftUI
struct TabsVerticalLineExample: View {
var body: some View {
TabsSectionsDemo(variant: .line, orientation: .vertical)
}
}
struct TabsPanelText: View {
let text: LocalizedStringKey
var body: some View {
Text(text)
.textStyle(.sm)
.foregroundStyle(Color.labelSecondary)
}
}
struct TabsSectionsDemo: View {
var variant: TabsListVariant = .default
var orientation: Axis = .horizontal
@State private var value = "overview"
var body: some View {
Tabs(selection: $value, orientation: orientation) {
TabsList(variant: variant) {
TabsTrigger("Overview", value: "overview")
TabsTrigger("Analytics", value: "analytics")
TabsTrigger("Reports", value: "reports")
}
TabsContent(value: "overview") {
TabsPanelText(text: "A summary of your activity.")
}
TabsContent(value: "analytics") {
TabsPanelText(text: "Detailed metrics and trends.")
}
TabsContent(value: "reports") {
TabsPanelText(text: "Exported reports and schedules.")
}
}
.frame(maxWidth: .infinity)
}
}With icons line
Icon tabs in the line variant.
import { GearIcon, UserIcon } from "phosphor-react-native"
import * as React from "react"
import { Icon } from "@/components/ui/icon"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Text } from "@/components/ui/text"
type ListVariant = "default" | "line"
function WithIconsDemo({ variant }: { variant?: ListVariant }) {
const [value, setValue] = React.useState("profile")
return (
<Tabs value={value} onValueChange={setValue}>
<TabsList variant={variant}>
<TabsTrigger value="profile">
<Icon as={UserIcon} className="size-4" />
<Text>Profile</Text>
</TabsTrigger>
<TabsTrigger value="settings">
<Icon as={GearIcon} className="size-4" />
<Text>Settings</Text>
</TabsTrigger>
</TabsList>
<TabsContent value="profile">
<Text className="text-sm text-label-secondary">
Your public profile information.
</Text>
</TabsContent>
<TabsContent value="settings">
<Text className="text-sm text-label-secondary">
Manage your preferences.
</Text>
</TabsContent>
</Tabs>
)
}
export function TabsWithIconsLineExample() {
return (
<WithIconsDemo variant="line" />
)
}import EsuissUI
import PhosphorSwift
import SwiftUI
struct TabsWithIconsLineExample: View {
var body: some View {
TabsWithIconsDemo(variant: .line)
}
}
struct TabsPanelText: View {
let text: LocalizedStringKey
var body: some View {
Text(text)
.textStyle(.sm)
.foregroundStyle(Color.labelSecondary)
}
}
struct TabsWithIconsDemo: View {
var variant: TabsListVariant = .default
@State private var value = "profile"
var body: some View {
Tabs(selection: $value) {
TabsList(variant: variant) {
TabsTrigger(value: "profile") {
Label {
Text("Profile")
} icon: {
Ph.user.regular
}
}
TabsTrigger(value: "settings") {
Label {
Text("Settings")
} icon: {
Ph.gear.regular
}
}
}
TabsContent(value: "profile") {
TabsPanelText(text: "Your public profile information.")
}
TabsContent(value: "settings") {
TabsPanelText(text: "Manage your preferences.")
}
}
.frame(maxWidth: .infinity)
}
}import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.Gear
import com.adamglin.phosphoricons.regular.User
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.Tabs
import com.esuiss.ui.components.TabsContent
import com.esuiss.ui.components.TabsList
import com.esuiss.ui.components.TabsListVariant
import com.esuiss.ui.components.TabsTrigger
import com.esuiss.ui.components.Text
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography
@Composable
fun TabsWithIconsLineExample() {
TabsWithIcons(TabsListVariant.Line)
}
@Composable
fun TabsSecondaryText(text: String) {
Text(text, style = Typography.sm, color = EsuissTheme.colors.labelSecondary)
}
@Composable
fun TabsWithIcons(variant: TabsListVariant) {
var value by rememberSaveable { mutableStateOf("profile") }
Tabs(value, { value = it }) {
TabsList(variant = variant) {
TabsTrigger("profile") {
Icon(PhosphorIcons.Regular.User, contentDescription = null, size = 16.dp)
Text("Profile")
}
TabsTrigger("settings") {
Icon(PhosphorIcons.Regular.Gear, contentDescription = null, size = 16.dp)
Text("Settings")
}
}
TabsContent("profile") { TabsSecondaryText("Your public profile information.") }
TabsContent("settings") { TabsSecondaryText("Manage your preferences.") }
}
}Disabled tab line
A disabled tab in the line variant.


import * as React from "react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Text } from "@/components/ui/text"
type ListVariant = "default" | "line"
function DisabledTabDemo({ variant }: { variant?: ListVariant }) {
const [value, setValue] = React.useState("overview")
return (
<Tabs value={value} onValueChange={setValue}>
<TabsList variant={variant}>
<TabsTrigger value="overview">
<Text>Overview</Text>
</TabsTrigger>
<TabsTrigger value="analytics">
<Text>Analytics</Text>
</TabsTrigger>
<TabsTrigger value="reports" disabled>
<Text>Reports</Text>
</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<Text className="text-sm text-label-secondary">
A summary of your activity.
</Text>
</TabsContent>
<TabsContent value="analytics">
<Text className="text-sm text-label-secondary">
Detailed metrics and trends.
</Text>
</TabsContent>
</Tabs>
)
}
export function TabsDisabledTabLineExample() {
return (
<DisabledTabDemo variant="line" />
)
}import EsuissUI
import SwiftUI
struct TabsDisabledTabLineExample: View {
var body: some View {
TabsDisabledDemo(variant: .line)
}
}
struct TabsPanelText: View {
let text: LocalizedStringKey
var body: some View {
Text(text)
.textStyle(.sm)
.foregroundStyle(Color.labelSecondary)
}
}
struct TabsDisabledDemo: View {
var variant: TabsListVariant = .default
@State private var value = "overview"
var body: some View {
Tabs(selection: $value) {
TabsList(variant: variant) {
TabsTrigger("Overview", value: "overview")
TabsTrigger("Analytics", value: "analytics")
TabsTrigger("Reports", value: "reports")
.disabled(true)
}
TabsContent(value: "overview") {
TabsPanelText(text: "A summary of your activity.")
}
TabsContent(value: "analytics") {
TabsPanelText(text: "Detailed metrics and trends.")
}
}
.frame(maxWidth: .infinity)
}
}import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import com.esuiss.ui.components.Tabs
import com.esuiss.ui.components.TabsContent
import com.esuiss.ui.components.TabsList
import com.esuiss.ui.components.TabsListVariant
import com.esuiss.ui.components.TabsTrigger
import com.esuiss.ui.components.Text
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography
@Composable
fun TabsDisabledTabLineExample() {
TabsDisabledTab(TabsListVariant.Line)
}
@Composable
fun TabsSecondaryText(text: String) {
Text(text, style = Typography.sm, color = EsuissTheme.colors.labelSecondary)
}
@Composable
fun TabsDisabledTab(variant: TabsListVariant) {
var value by rememberSaveable { mutableStateOf("overview") }
Tabs(value, { value = it }) {
TabsList(variant = variant) {
TabsTrigger("overview", "Overview")
TabsTrigger("analytics", "Analytics")
TabsTrigger("reports", "Reports", enabled = false)
}
TabsContent("overview") { TabsSecondaryText("A summary of your activity.") }
TabsContent("analytics") { TabsSecondaryText("Detailed metrics and trends.") }
}
}API Reference
Tabs
Root that holds the active value and orientation.
React.ComponentProps<typeof TabsPrimitive.Root>TabsList
Row or column of triggers with the animated indicator.
React.ComponentProps<typeof TabsPrimitive.List>- About
defaultfor a filled segmented track, orlinefor an underline.- Type
"default""line"- Default
- "default"
TabsTrigger
Pressable tab that activates its panel.
React.ComponentProps<typeof TabsPrimitive.Tab>TabsContent
Panel shown when its value is active.
React.ComponentProps<typeof TabsPrimitive.Panel>Accessibility
TabsListhas thetablistrole and each trigger has thetabrole.- Triggers report
selectedanddisabledin their accessibility state, so VoiceOver and TalkBack announce the current tab. - Hidden panels kept with
keepMountedare removed from the accessibility tree. - The indicator animation follows the system reduce motion setting.
Notes
- The
defaulttrack usesbg-controlwith abg-surface-raisedindicator (bg-label-quaternaryin dark mode); thelineindicator usesbg-label. - Labels use
text-labelwhen active,text-label-secondarywhen inactive andtext-label-quaternarywhen disabled. - Light and dark colors follow the system appearance.