Components PulseTabBar
PulseTabBar
The tab bar, elevated.
interactive demo
Home
A live browser stand-in using the exact tokens, motion and API of the SwiftUI component.
Want all components at once?
Open the studio
The tab bar, elevated.
A floating glass tab bar with selected-state pills and springy icons. Insets and solid variants for every layout.
Floating glass / solid / inset
Selected-pill animation
Automatic labels + traits
Scroll-friendly padding
Usage
PulseTabBar(selection: $tab, tabs: [
TabItem(label: "Home", icon: "house"),
TabItem(label: "Search", icon: "magnifyingglass"),
], style: .floating)
.frame(maxHeight: .infinity, alignment: .bottom) Install
$ pulse add tab_bar Source files
Sources/PulseUI/Components/PulseTabBar/PulseTabBar.swift
Full source Sources/PulseUI/Components/PulseTabBar/PulseTabBar.swift
import SwiftUI
// MARK: - PulseTabBar
/// Premium animated tab bar with floating glass background and magic morphing indicator.
public struct PulseTabBar: View {
@Environment(\.pulseTheme) private var theme
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Binding var selection: Int
let tabs: [TabItem]
let style: PulseTabBarStyle
public init(
selection: Binding<Int>,
tabs: [TabItem],
style: PulseTabBarStyle = .floating
) {
self._selection = selection
self.tabs = tabs
self.style = style
}
public var body: some View {
HStack(spacing: 0) {
ForEach(Array(tabs.enumerated()), id: \.element.id) { index, tab in
tabButton(for: index, tab: tab)
}
}
.background(backgroundView)
.overlay(overlayView)
.clipShape(containerShape)
.padding(.horizontal, style == .floating ? 16 : 0)
.padding(.bottom, style == .floating ? 8 : 0)
.shadow(
color: theme.colors.foreground.opacity(style == .floating ? 0.08 : 0),
radius: 18,
y: 8
)
.accessibilityElement(children: .contain)
}
private var containerShape: AnyShape {
AnyShape(
RoundedRectangle(
cornerRadius: style == .floating ? theme.radius.xxl : 0,
style: .continuous
)
)
}
@ViewBuilder
private func tabButton(for index: Int, tab: TabItem) -> some View {
let isSelected = selection == index
Button {
withAnimation(reduceMotion ? .none : theme.motion.springSnappy) {
selection = index
}
PulseHaptic.selection()
} label: {
VStack(spacing: 4) {
ZStack {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(isSelected ? selectedFill : .clear)
.frame(width: 44, height: 30)
Image(systemName: tab.icon)
.font(.system(size: 17, weight: isSelected ? .semibold : .regular))
.foregroundStyle(isSelected ? selectedIconColor : theme.colors.foregroundTertiary)
.scaleEffect(isSelected && !reduceMotion ? 1.1 : 1)
.symbolEffect(.bounce, options: .nonRepeating, value: isSelected)
}
Text(tab.label)
.font(.system(size: 10, weight: isSelected ? .semibold : .regular))
.foregroundStyle(isSelected ? theme.colors.foreground : theme.colors.foregroundTertiary)
.lineLimit(1)
}
.frame(maxWidth: .infinity)
.padding(.top, 8)
.padding(.bottom, 6)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(tab.label)
.accessibilityAddTraits(isSelected ? .isSelected : [])
.accessibilityHint(isSelected ? "" : "Double tap to select \(tab.label)")
}
@ViewBuilder
private var backgroundView: some View {
switch style {
case .floating:
Color.clear.pulseGlass(.regular, cornerRadius: theme.radius.xxl)
.overlay(theme.colors.glassFill)
case .solid, .inset:
theme.colors.background
}
}
@ViewBuilder
private var overlayView: some View {
if style != .floating {
Rectangle()
.fill(theme.colors.border.opacity(0.5))
.frame(height: 0.5)
.frame(maxHeight: .infinity, alignment: .top)
} else if style == .floating {
containerShape.stroke(theme.colors.glassStroke.opacity(0.5), lineWidth: 0.5)
}
}
private var selectedFill: Color {
theme.colors.accent.opacity(0.15)
}
private var selectedIconColor: Color {
theme.colors.accent
}
}
// MARK: - Types
public struct TabItem: Identifiable, Sendable {
public let id: UUID
public let label: String
public let icon: String
public init(label: String, icon: String) {
self.id = UUID()
self.label = label
self.icon = icon
}
}
public enum PulseTabBarStyle: Sendable {
case floating
case solid
case inset
}