Cover Flow Carousel
A horizontal carousel where the centered item faces forward and its neighbours turn away in 3D as you scroll.

- Scrolling
- iOS 18.0+
- intermediate
- Origin
- Original work
- Found by
- cover flow · carousel · 3d · albums · horizontal scroll · snap · perspective · cover flow · carrusel · 3d · álbumes · desplazamiento horizontal · perspectiva
Description
Section titled “Description”A horizontal carousel where the centered item faces forward and its neighbours turn away in 3D.
With Reduce Motion on, items stay flat and only fade.
struct CoverFlowCarouselDemo: View { @State private var selection: String? = "Midnight" /// Only for this demo: it plays by itself until someone touches it. @State private var isAutoPlaying = true
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private let items = [ CoverFlowCarouselItem(id: "Sunset", title: "Sunset", image: artwork([.orange, .pink], symbol: "sun.horizon.fill")), CoverFlowCarouselItem(id: "Forest", title: "Forest", image: artwork([.green, .teal], symbol: "tree.fill")), CoverFlowCarouselItem(id: "Midnight", title: "Midnight", image: artwork([.indigo, .black], symbol: "moon.stars.fill")), CoverFlowCarouselItem(id: "Ocean", title: "Ocean", image: artwork([.cyan, .blue], symbol: "water.waves")), CoverFlowCarouselItem(id: "Bloom", title: "Bloom", image: artwork([.pink, .purple], symbol: "camera.macro")), ]
var body: some View { VStack(spacing: 18) { Text("Albums") .font(.system(.largeTitle, design: .rounded, weight: .bold)) .foregroundStyle(.white)
CoverFlowCarousel(items: items, selection: $selection) .frame(height: 400)
Text(selection ?? "") .font(.headline) .foregroundStyle(.white.opacity(0.75)) .contentTransition(.opacity) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(.black) .simultaneousGesture(DragGesture(minimumDistance: 0).onChanged { _ in isAutoPlaying = false }) .task(id: reduceMotion) { guard !reduceMotion else { return } let order = [3, 4, 3, 2, 1, 0, 1, 2] var step = 3 while isAutoPlaying { try? await Task.sleep(for: .seconds(1.3)) guard !Task.isCancelled, isAutoPlaying else { return } step = (step + 1) % order.count withAnimation(.smooth(duration: 0.8)) { selection = items[order[step]].id } } } }
private static func artwork(_ colors: [Color], symbol: String) -> Image { let size = CGSize(width: 300, height: 420) return Image(size: size, opaque: true) { context in context.fill( Path(CGRect(origin: .zero, size: size)), with: .linearGradient(Gradient(colors: colors), startPoint: .zero, endPoint: CGPoint(x: size.width, y: size.height)) )
var icon = context.resolve(Image(systemName: symbol)) icon.shading = .color(.white.opacity(0.85)) let scale = 110 / max(icon.size.width, icon.size.height, 1) let iconSize = CGSize(width: icon.size.width * scale, height: icon.size.height * scale) context.draw(icon, in: CGRect( x: (size.width - iconSize.width) / 2, y: (size.height - iconSize.height) / 2, width: iconSize.width, height: iconSize.height )) } }}API reference
Section titled “API reference”| Parameter | Type | Default |
|---|---|---|
items | [CoverFlowCarouselItem] | Required |
selection | Binding<String?> | Required |
Copy this code into your project, or download the file.
// SPDX-License-Identifier: MIT// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
struct CoverFlowCarouselItem: Identifiable { let id: String let title: String let image: Image}
/// A horizontal carousel where the centered item faces forward and its neighbours turn away in 3D.////// With Reduce Motion on, items stay flat and only fade.struct CoverFlowCarousel: View { let items: [CoverFlowCarouselItem] @Binding var selection: String?
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View { GeometryReader { geometry in let containerWidth = geometry.size.width let itemWidth = max(containerWidth * 0.62, 1) let tilts = !reduceMotion
ScrollView(.horizontal) { LazyHStack(spacing: 8) { ForEach(Array(items.enumerated()), id: \.element.id) { index, item in card(for: item, position: index + 1) .frame(width: itemWidth, height: min(itemWidth * 1.4, geometry.size.height)) .frame(maxHeight: .infinity) .visualEffect { content, proxy in let position = CoverFlowCarouselMath.position(midX: proxy.frame(in: .scrollView).midX, width: containerWidth) return content .scaleEffect(tilts ? 1 - abs(position) * 0.14 : 1) .rotation3DEffect(.degrees(tilts ? position * -52 : 0), axis: (0, 1, 0), perspective: 0.5) .opacity(1 - abs(position) * 0.4) } } } .scrollTargetLayout() } .contentMargins(.horizontal, max((containerWidth - itemWidth) / 2, 0), for: .scrollContent) .scrollIndicators(.hidden) .scrollTargetBehavior(.viewAligned) .scrollPosition(id: $selection, anchor: .center) } }
private func card(for item: CoverFlowCarouselItem, position: Int) -> some View { Color.clear .overlay { item.image .resizable() .scaledToFill() .accessibilityRepresentation { EmptyView() } } .overlay(alignment: .bottomLeading) { Text(item.title) .font(.title3.weight(.bold)) .foregroundStyle(.white) .lineLimit(2) .minimumScaleFactor(0.7) .padding(16) .frame(maxWidth: .infinity, alignment: .leading) .background(LinearGradient(colors: [.clear, .black.opacity(0.55)], startPoint: .top, endPoint: .bottom)) } .clipShape(.rect(cornerRadius: 24, style: .continuous)) .shadow(color: .black.opacity(0.25), radius: 14, y: 8) .accessibilityElement(children: .ignore) .accessibilityLabel(item.title) .accessibilityValue("\(position) of \(items.count)") }}
nonisolated enum CoverFlowCarouselMath { /// How far an item is from the center of the carousel: 0 at the center, -1 or 1 one half width away. static func position(midX: Double, width: Double) -> Double { guard width > 0, midX.isFinite else { return 0 } let halfWidth = width / 2 return min(max((midX - halfWidth) / halfWidth, -1), 1) }}
// MARK: - Demo
struct CoverFlowCarouselDemo: View { @State private var selection: String? = "Midnight" /// Only for this demo: it plays by itself until someone touches it. @State private var isAutoPlaying = true
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private let items = [ CoverFlowCarouselItem(id: "Sunset", title: "Sunset", image: artwork([.orange, .pink], symbol: "sun.horizon.fill")), CoverFlowCarouselItem(id: "Forest", title: "Forest", image: artwork([.green, .teal], symbol: "tree.fill")), CoverFlowCarouselItem(id: "Midnight", title: "Midnight", image: artwork([.indigo, .black], symbol: "moon.stars.fill")), CoverFlowCarouselItem(id: "Ocean", title: "Ocean", image: artwork([.cyan, .blue], symbol: "water.waves")), CoverFlowCarouselItem(id: "Bloom", title: "Bloom", image: artwork([.pink, .purple], symbol: "camera.macro")), ]
var body: some View { VStack(spacing: 18) { Text("Albums") .font(.system(.largeTitle, design: .rounded, weight: .bold)) .foregroundStyle(.white)
CoverFlowCarousel(items: items, selection: $selection) .frame(height: 400)
Text(selection ?? "") .font(.headline) .foregroundStyle(.white.opacity(0.75)) .contentTransition(.opacity) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(.black) .simultaneousGesture(DragGesture(minimumDistance: 0).onChanged { _ in isAutoPlaying = false }) .task(id: reduceMotion) { guard !reduceMotion else { return } let order = [3, 4, 3, 2, 1, 0, 1, 2] var step = 3 while isAutoPlaying { try? await Task.sleep(for: .seconds(1.3)) guard !Task.isCancelled, isAutoPlaying else { return } step = (step + 1) % order.count withAnimation(.smooth(duration: 0.8)) { selection = items[order[step]].id } } } }
private static func artwork(_ colors: [Color], symbol: String) -> Image { let size = CGSize(width: 300, height: 420) return Image(size: size, opaque: true) { context in context.fill( Path(CGRect(origin: .zero, size: size)), with: .linearGradient(Gradient(colors: colors), startPoint: .zero, endPoint: CGPoint(x: size.width, y: size.height)) )
var icon = context.resolve(Image(systemName: symbol)) icon.shading = .color(.white.opacity(0.85)) let scale = 110 / max(icon.size.width, icon.size.height, 1) let iconSize = CGSize(width: icon.size.width * scale, height: icon.size.height * scale) context.draw(icon, in: CGRect( x: (size.width - iconSize.width) / 2, y: (size.height - iconSize.height) / 2, width: iconSize.width, height: iconSize.height )) } }}
#Preview { CoverFlowCarouselDemo()}