- Nov 4, 2025
Build a Smooth, Animated Star Rating View in SwiftUI
- DevTechie
- SwiftUI
Ever needed to show a product rating in your app, like 4.7 out of 5 stars? It’s a common UI element, and with SwiftUI, you can build a beautiful, animated version that handles fractional ratings (like that 4.7) with surprising ease.
Using of this view is incredibly simple. Just create a StarRatingView (ratings view that will be created soon) and pass it a rating value (a Double between 0.0 and 5.0):
VStack(spacing: 20) {
StarRatingView(rating: 4.7)
StarRatingView(rating: 3.9)
StarRatingView(rating: 2.9)
StarRatingView(rating: 1.5)
StarRatingView(rating: 2.9)
}
.padding()The magic of this view comes from three main concepts: a smooth animation, a clever layering trick with ZStack, and a .mask to create the partial-star effect.
1. The Main View & Animation
The StarRatingView struct holds the logic.
let rating: Double: This is the target rating we want to show (e.g., 4.7).@State private var animatedRating: Double = 0.0: This is the currently displayed rating. We use a separate@Statevariable so we can animate it.HStack(spacing: 6) { ... }: This simply lays out 5 stars horizontally..onAppearand.onChange: These are the animation triggers.When the view first appears (
.onAppear) or when theratingvalue is changed externally (.onChange), we tell SwiftUI to animate.withAnimation(.easeInOut(duration: 0.6)) { ... }wraps the change.Inside, we set
animatedRating = rating. SwiftUI sees this and smoothly animatesanimatedRatingfrom its old value (e.g., 0.0) to the newrating(e.g., 4.7) over 0.6 seconds.
import SwiftUI
struct StarRatingView: View {
let rating: Double // range 0.0 to 5.0
@State private var animatedRating: Double = 0.0
var body: some View {
HStack(spacing: 6) {
ForEach(0..<5) { index in
starView(for: index)
}
}
.onAppear {
withAnimation(.easeInOut(duration: 0.6)) {
animatedRating = rating
}
}
.onChange(of: rating) { _, newRating in
withAnimation(.easeInOut(duration: 0.6)) {
animatedRating = rating
}
}
}The starView function is responsible for drawing each individual star. It uses a ZStack to layer two star images on top of each other.
The Background Layer: This is a gray, semi-transparent star. It represents the “empty” part of the rating.
Image(systemName: "star.fill")
.foregroundStyle(.gray.gradient.opacity(0.3))2. The Foreground Layer: This is a bright, colorful star with a yellow-to-orange gradient. This represents the “filled” part of the rating.
Image(systemName: "star.fill")
.foregroundStyle(
LinearGradient(...)
)3. The mask: Creating the Partial Fill
This is the cleverest part. How do you show a star that’s only 70% full? You use a mask.
We apply a .mask modifier to the foreground (colored) star. A mask works like a stencil—it only lets the parts of the view covered by the mask show through. Our mask is just a simple Rectangle(). The key is calculating its width. First, we calculate the fillAmount for the current star (based on its index from 0 to 4):
let fillAmount = min(max(animatedRating - Double(index) , 0), 1)Let’s trace this with our animatedRating of 4.7:
Star 0 (index 0):
4.7 - 0 = 4.7. Clamped to 1.0. (Full star)Star 1 (index 1):
4.7 - 1 = 3.7. Clamped to 1.0. (Full star)Star 2 (index 2):
4.7 - 2 = 2.7. Clamped to 1.0. (Full star)Star 3 (index 3):
4.7 - 3 = 1.7. Clamped to 1.0. (Full star)Star 4 (index 4):
4.7 - 4 = 0.7. This is 0.7. (Partial star)
The GeometryReader gets the full width of the star, and we use it to set our mask's width:
.mask {
GeometryReader { geometry in
Rectangle()
.frame(width: geometry.size.width * fillAmount) // e.g., width * 0.7
.frame(maxWidth: .infinity, alignment: .leading)
}
}So for Star 4, the mask is a Rectangle that is only 70% of the total width. This only lets the first 70% of the colored gradient star show through, perfectly creating the partial-star effect!
The Complete Code
Here is the full, reusable code for your projects:
import SwiftUI
struct StarRatingView: View {
let rating: Double // range 0.0 to 5.0
@State private var animatedRating: Double = 0.0
var body: some View {
HStack(spacing: 6) {
ForEach(0..<5) { index in
starView(for: index)
}
}
.onAppear {
withAnimation(.easeInOut(duration: 0.6)) {
animatedRating = rating
}
}
.onChange(of: rating) { _, newRating in
withAnimation(.easeInOut(duration: 0.6)) {
animatedRating = rating
}
}
}
@ViewBuilder
private func starView(for index: Int) -> some View {
// Calculate the fill amount for this specific star
// Clamps the value between 0.0 (empty) and 1.0 (full)
let fillAmount = min(max(animatedRating - Double(index) , 0), 1)
ZStack {
// Background (empty) star
Image(systemName: "star.fill")
.resizable()
.scaledToFit()
.foregroundStyle(.gray.gradient.opacity(0.3))
// Foreground (filled) star
Image(systemName: "star.fill")
.resizable()
.scaledToFit()
.foregroundStyle(
LinearGradient(colors: [.yellow, .orange], startPoint: .leading, endPoint: .trailing)
)
// Apply the mask
.mask {
GeometryReader { geometry in
Rectangle()
.frame(width: geometry.size.width * fillAmount)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
.frame(width: 30, height: 30) // Set the size for each star
}
}
// Example of how to use it
#Preview {
VStack(spacing: 20) {
StarRatingView(rating: 4.7)
StarRatingView(rating: 3.9)
StarRatingView(rating: 2.9)
StarRatingView(rating: 1.5)
StarRatingView(rating: 0.0)
}
.padding()
}This view was used for Apple Foundation Models course in which you will learn to build AI powered apps using on-device LLM.
Courses from DevTechie.com
Visit https://www.DevTechie.com for more


