• Nov 4, 2025

Build a Smooth, Animated Star Rating View in 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 @State variable so we can animate it.

  • HStack(spacing: 6) { ... }: This simply lays out 5 stars horizontally.

  • .onAppear and .onChange: These are the animation triggers.

  • When the view first appears (.onAppear) or when the rating value 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 animates animatedRating from its old value (e.g., 0.0) to the new rating (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.

  1. 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