Creating a SwiftUI Parallax Card Carousel
Step by Step, from Basic to Fully Finished
A parallax carousel can make it feel like your app is instantly beautiful.
While a parallax card carousel might seem intimidating, we can create your very own with a few simple steps and actually understand what's happening as we go.
I will walk you through a step by step process, from a basic horizontal scroll, to a fully finished production-ready parallax carousel.
I will provide you a codesnap for each step, the reasoning behind each step, so you won't just know what you need to write; but more importantly, why it's written that way.
So, why create a parallax card carousel?
Many tutorials give you only a "glance" at something.
Very few provide you with a complete and practical real-world use case solution that you can just take and modify for your own application.
The goal is to take you from "that's neat" to "I can ship this", with a lot less time and guesswork.
Step 1: A Basic Example of Horizontal Scrolling Cards
Let's start simply: we will start with a simple 'ScrollView', and keep it simple, and horizontally scroll through a row of images.
struct SimpleCarousel: View {
let images: [String]
var body: some View {
ScrollView(.horizontal) {
HStack(spacing: 16) {
ForEach(images, id: \.self) { image in
Image(image)
.resizable()
.scaledToFill()
.frame(width: 250, height: 360)
.clipped()
}
}
.padding(.horizontal, 32)
}
}
}Important notes:
Use a
.horizontalScrollViewfor scrolling across the horizontal axis of the screen.stack your images in an
HStack, with spacing between them.use
.clipped()so the images don't get pulled to fill.
At this stage, you have a row of scrollable cards.
There won't be any effects just yet, but you have the foundation.
Step 2: Put in a Data Model So You Can Be Flexible
Hardcoding image names gets ugly fast.
Create a data structure so you can add other content-types later, and you've got a carousel!
struct ParallaxCard: Identifiable {
let id = UUID()
let image: String
let caption: String?
}The wizard now supports local images and remote URLs, and it provides the ability to add titles and tags to each card.
Step 3: URL and Local Asset Support
Some images may be bundled locally, some may be remote URLs.
We can abstract this logic into a reusable component:
struct ParallaxImage: View {
let imageName: String
var body: some View {
if let url = URL(string: imageName), imageName.hasPrefix("http") {
AsyncImage(url: url) { phase in
switch phase {
case .success(let img): img.resizable()
default: Color.gray
}
}
} else {
Image(imageName)
.resizable()
}
}
}Now, each of the cards can show either a bundled asset or a remote image with no additional work.
Step 4: Locate the Card Using GeometryReader
To achieve the parallax effect, we need to know how far away your individual cards are from the center of the screen.
We can accomplish this goal with GeometryReader.
Here’s what we are going to do:
We are going to wrap an outer GeometryReader so we can size and position our outer container
We will wrap each card in its own inner GeometryReader to compute the distance between the center of the card and the center of the container.
GeometryReader { outerProxy in
let containerWidth = outerProxy.size.width
ScrollView(.horizontal) {
HStack(spacing: 16) {
ForEach(cards) { card in
GeometryReader { innerProxy in
let cardFrame = innerProxy.frame(in: .global)
let scrollFrame = outerProxy.frame(in: .global)
let diff = cardFrame.midX - scrollFrame.midX
// 'diff' is the distance between card center and screen center
}
}
}
}
}Step 5: Apply the Parallax Offset Animation
Now you have diff (the distance from center) and you are ready to animate the image in the card.
What you'll see is that the distance from center the card has traveled, the greater the offset, hence the depth effect.
let parallax = -diff / containerWidth * parallaxStrength * cardWidthWhat does all this mean?
diffis the distance in pixels the card is from the card's center from the center of the container./ containerWidthis applying the distance to a proportion so when you see a fraction like -0.25, you know the object has moved a quarter of the screen to the left.parallaxStrength(like .5, .7, or 1) this is how you control how "strong" the animation effect is.* cardWidthapplies a scale to the offset position to keep the effect consistent, regardless of the dimensions of the card.The minus sign makes it so the image moves opposite of the card's movement, which provides that "parallax" look.
Example:
If your container width is 400, card width is 250, parallaxStrength of 0.7, and the card has moved 100 points to the right of center,
let parallax = -100 / 400 * .7 * 250 // = -43.75This means, the image in the card will move left by a total of 43.75 points.
In code:
ParallaxImage(imageName: card.image)
.scaledToFill()
.frame(width: cardWidth * 1.25, height: cardHeight)
.offset(x: parallax)What is the purpose of the image being wider than the card?
If the image is the same width as the card, and you slide the image inside the card (because of the offset), the width movement will reveal empty space on one side of the card.
If you make the image, say, 1.25 times the card width, there is enough content to keep the card visually "full," no matter how far you slide it.
It's like putting a photo in a frame that is a little bigger than the photo itself.
No matter how much you slide the photo, you never see a gap in the frame.
Step 6: A few finishing touches - Make it look right; make it feel right.
You should have the basis for a working carousel by now, however we can apply a few finishing touches so it is starting to look and feel production quality:
You can apply
clipShape(RoundedRectangle(cornerRadius: ...)to round the edges of the cards -and generally make things look a bit tidierRemember to utilize
.frame(width: cardWidth, height: cardHeight)to guarantee every card and the image in the card are exactly the same size. This will help to eliminate layout issues when the width or heigt of the card varies on-screen.You can use
.padding(.horizontal, (containerWidth - cardWidth) / 2)to add some side padding so the first and last cards can be centered too - again, rather than just "sticking" to the side of the screen.
Now, for the final touch:
SwiftUI (in iOS 17 and later) now has .scrollTargetLayout() and .scrollTargetBehavior(.viewAligned) and when combined these will create a scrolling view that snaps to the cards like Apple's own banners.
So, what are they doing?
.scrollTargetLayout()will create every card as a "snap point". So as the user scrolls or flicks the carousel, SwiftUI will locate the nearest snap point and centre it in the display..scrollTargetBehavior(.viewAligned)defines the snapping behaviour for the scrolling function -.viewAlignedwill be the most "natural" because it will guarantee that the nearest card will always land in the centre.
This is the same smooth, "snapping to the centre" experience you would have while scrolling through the App Store banners.
Without these, users will essentially need to manually centre cards in view - which will be very clunky and imprecise - With these the scrolling function is added smoothly and professionally, with essentially zero extra cost.
Bringing It All Together
So now that we have completed all the work, here is the complete ParallaxCarousel:
struct ParallaxCarousel<Content: View>: View {
let cards: [ParallaxCard]
let cardWidth: CGFloat
let cardHeight: CGFloat
let parallaxStrength: CGFloat
let cornerRadius: CGFloat
let content: (ParallaxCard) -> Content
var body: some View {
GeometryReader { outerProxy in
let containerWidth = outerProxy.size.width
ScrollView(.horizontal) {
HStack(spacing: 16) {
ForEach(cards) { card in
GeometryReader { innerProxy in
let cardFrame = innerProxy.frame(in: .global)
let scrollFrame = outerProxy.frame(in: .global)
let diff = cardFrame.midX - scrollFrame.midX
let parallax = -diff / containerWidth * parallaxStrength * cardWidth
ZStack {
ParallaxImage(imageName: card.image)
.scaledToFill()
.frame(width: cardWidth * 1.25, height: cardHeight)
.offset(x: parallax)
content(card)
}
.frame(width: cardWidth, height: cardHeight)
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
}
.frame(width: cardWidth, height: cardHeight)
}
}
.padding(.horizontal, (containerWidth - cardWidth) / 2)
.scrollTargetLayout()
}
.scrollIndicators(.hidden)
.scrollTargetBehavior(.viewAligned)
}
.frame(height: cardHeight)
}
}
Conclusion
Now that every step of the guide is very hands-on.
You can change the content, the parallax strength, or card styles—and you will always know why it works.
Give this a plug into your own project.
What do you have?
A beautiful, interactive carousel, easy to customize, and one that makes your application shine.

