Components PulseCarousel
PulseCarousel
Swipe, snap, repeat.
interactive demo
01 / ONBOARDINGMeet the tokensOne accent. Every component re-skins.
02 / THEMESwap light & darkTwo themes from one structure.
03 / MOTIONSprings everywhereFeel the bounce on every tap.
04 / SHIPCopy into your appReal SwiftUI, ready to paste.
A live browser stand-in using the exact tokens, motion and API of the SwiftUI component.
Want all components at once?
Open the studio
Swipe, snap, repeat.
A paging carousel with dot and bar indicators, configurable spacing and per-item content — for onboarding and featured decks.
Snap-paging horizontal scroll
Dots or animated bars indicator
Item spacing control
Identifiable Sendable items
Usage
PulseCarousel(items: slides, indicatorStyle: .bars) { slide in
PulseCard(.gradient) { slide.view }
.frame(width: 220)
} Install
$ pulse add carousel Source files
Sources/PulseUI/Components/PulseCarousel/PulseCarousel.swift
Full source Sources/PulseUI/Components/PulseCarousel/PulseCarousel.swift
import SwiftUI
// MARK: - PulseCarousel
/// Horizontal scroll carousel with optional paging, snap behavior, and page indicators.
public struct PulseCarousel<Content: View, Item: Identifiable & Sendable>: View {
@Environment(\.pulseTheme) private var theme
@Environment(\.accessibilityReduceMotion) private var reduceMotion
let items: [Item]
let showsIndicators: Bool
let indicatorStyle: CarouselIndicatorStyle
let spacing: CGFloat
let content: (Item) -> Content
@State private var currentPage: Int = 0
@State private var visibleFrames: [Int: CGRect] = [:]
public init(
items: [Item],
showsIndicators: Bool = true,
indicatorStyle: CarouselIndicatorStyle = .dots,
spacing: CGFloat = 16,
@ViewBuilder content: @escaping (Item) -> Content
) {
self.items = items
self.showsIndicators = showsIndicators
self.indicatorStyle = indicatorStyle
self.spacing = spacing
self.content = content
}
public var body: some View {
VStack(spacing: theme.spacing.md) {
ScrollViewReader { proxy in
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: spacing) {
ForEach(Array(items.enumerated()), id: \.element.id) { index, item in
content(item)
.id(index)
.onAppear {
updateCurrentPage(index, proxy: proxy)
}
}
}
.padding(.horizontal, 2)
}
.scrollTargetLayout()
}
.scrollTargetBehavior(.viewAligned)
if showsIndicators && items.count > 1 {
indicatorView
}
}
}
private func updateCurrentPage(_ index: Int, proxy: ScrollViewProxy) {
DispatchQueue.main.async {
withAnimation(reduceMotion ? .none : theme.motion.spring) {
currentPage = index
}
}
}
@ViewBuilder
private var indicatorView: some View {
HStack(spacing: 6) {
ForEach(0..<items.count, id: \.self) { index in
indicator(for: index)
.animation(reduceMotion ? .none : theme.motion.springSnappy, value: currentPage)
}
}
.accessibilityLabel("Page \(currentPage + 1) of \(items.count)")
}
@ViewBuilder
private func indicator(for index: Int) -> some View {
switch indicatorStyle {
case .dots:
Capsule()
.fill(index == currentPage ? theme.colors.accent : theme.colors.foregroundTertiary.opacity(0.3))
.frame(width: index == currentPage ? 20 : 6, height: 6)
case .bars:
RoundedRectangle(cornerRadius: 1)
.fill(index == currentPage ? theme.colors.accent : theme.colors.foregroundTertiary.opacity(0.3))
.frame(width: 10, height: 3)
case .numbers:
if index == currentPage {
Text("\(index + 1)")
.font(.caption2.bold())
.foregroundStyle(theme.colors.accent)
} else {
Text("\(index + 1)")
.font(.caption2)
.foregroundStyle(theme.colors.foregroundTertiary)
}
}
}
}
// MARK: - Types
public enum CarouselIndicatorStyle: Sendable {
case dots
case bars
case numbers
}