Skip to content

Cover Flow Carousel

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

Cover Flow Carousel, playing
Download .swift
Origin
Original work
Found by
cover flow · carousel · 3d · albums · horizontal scroll · snap · perspective · cover flow · carrusel · 3d · álbumes · desplazamiento horizontal · perspectiva

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
))
}
}
}
ParameterTypeDefault
items[CoverFlowCarouselItem]Required
selectionBinding<String?>Required

Copy this code into your project, or download the file.

CoverFlowCarousel.swift
// 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()
}