```html SwiftUI: How to Build Rating Input (iOS 17+, 2026)

How to build a rating input in SwiftUI

iOS 17+ Xcode 16+ Beginner APIs: HStack / Image Updated: May 11, 2026
TL;DR

Loop from 1...maxStars inside an HStack, render each star with Image(systemName:) switching between star.fill and star, and attach a tap gesture to update a binding integer.

struct StarRatingView: View {
    @Binding var rating: Int
    var maxStars: Int = 5

    var body: some View {
        HStack(spacing: 4) {
            ForEach(1...maxStars, id: \.self) { star in
                Image(systemName: star <= rating ? "star.fill" : "star")
                    .foregroundStyle(star <= rating ? .yellow : .secondary)
                    .onTapGesture { rating = star }
            }
        }
    }
}

Full implementation

The full component wraps the core star row in a reusable StarRatingView that accepts a binding, a configurable maximum, an optional label, and a size parameter so it slots neatly into any form. We also wire up proper VoiceOver support by treating the whole control as a single adjustable element, and add a subtle spring animation on each tap for tactile feel.

import SwiftUI

// MARK: - StarRatingView

struct StarRatingView: View {
    @Binding var rating: Int
    var maxStars: Int = 5
    var label: String = "Rating"
    var starSize: CGFloat = 28

    var body: some View {
        HStack(spacing: 6) {
            ForEach(1...maxStars, id: \.self) { star in
                Image(systemName: star <= rating ? "star.fill" : "star")
                    .resizable()
                    .scaledToFit()
                    .frame(width: starSize, height: starSize)
                    .foregroundStyle(star <= rating ? Color.yellow : Color.secondary)
                    .symbolEffect(.bounce, value: rating == star)
                    .onTapGesture {
                        withAnimation(.spring(response: 0.25, dampingFraction: 0.6)) {
                            rating = star
                        }
                    }
                    .accessibilityHidden(true)   // hidden individually; container handles a11y
            }
        }
        // Treat the whole row as one accessible element
        .accessibilityElement(children: .ignore)
        .accessibilityLabel(label)
        .accessibilityValue("\(rating) out of \(maxStars) stars")
        .accessibilityAdjustableAction { direction in
            switch direction {
            case .increment: if rating < maxStars { rating += 1 }
            case .decrement: if rating > 0         { rating -= 1 }
            @unknown default: break
            }
        }
    }
}

// MARK: - Demo form

struct RatingFormView: View {
    @State private var movieRating: Int = 0
    @State private var serviceRating: Int = 3

    var body: some View {
        Form {
            Section("Rate your experience") {
                VStack(alignment: .leading, spacing: 8) {
                    Text("Movie").font(.subheadline).foregroundStyle(.secondary)
                    StarRatingView(rating: $movieRating, label: "Movie rating")
                }
                .padding(.vertical, 4)

                VStack(alignment: .leading, spacing: 8) {
                    Text("Service").font(.subheadline).foregroundStyle(.secondary)
                    StarRatingView(rating: $serviceRating, maxStars: 3,
                                   label: "Service rating", starSize: 22)
                }
                .padding(.vertical, 4)
            }

            Section {
                Button("Submit") {
                    print("Movie: \(movieRating), Service: \(serviceRating)")
                }
                .disabled(movieRating == 0)
            }
        }
        .navigationTitle("Leave a review")
    }
}

// MARK: - Preview

#Preview("Rating Form") {
    NavigationStack {
        RatingFormView()
    }
}

#Preview("Standalone — 4 of 5") {
    @Previewable @State var rating = 4
    StarRatingView(rating: $rating)
        .padding()
}

How it works

  1. ForEach(1...maxStars) — iterates from 1 to maxStars, giving each star its natural 1-based index. Comparing star <= rating decides whether to show star.fill or the outline star SF Symbol.
  2. Image(systemName:) + resizable/scaledToFit — SF Symbols scale cleanly at any point size. Setting an explicit frame via the starSize parameter makes the hit target consistent and easy to customise without touching internal layout.
  3. .symbolEffect(.bounce, value: rating == star) — iOS 17's symbolEffect modifier fires a one-shot bounce animation on the newly selected star whenever rating changes to match that star's index.
  4. .accessibilityAdjustableAction — exposes swipe-up / swipe-down gestures for VoiceOver users, letting them change the rating without tapping individual stars. Combined with .accessibilityValue, the screen reader announces "3 out of 5 stars" in plain language.
  5. Button disabled state — in the demo form, Submit is disabled while movieRating == 0, preventing empty submissions and giving users clear feedback that a selection is required.

Variants

Half-star precision

struct HalfStarRatingView: View {
    @Binding var rating: Double   // 0.0, 0.5, 1.0 ... 5.0
    var maxStars: Int = 5

    private func symbolName(for star: Int) -> String {
        let value = rating - Double(star - 1)
        if value >= 1.0 { return "star.fill" }
        if value >= 0.5 { return "star.leadinghalf.filled" }
        return "star"
    }

    var body: some View {
        HStack(spacing: 6) {
            ForEach(1...maxStars, id: \.self) { star in
                Image(systemName: symbolName(for: star))
                    .foregroundStyle(.yellow)
                    .frame(width: 30, height: 30)
                    .contentShape(Rectangle())
                    .gesture(
                        DragGesture(minimumDistance: 0)
                            .onChanged { value in
                                // left half = 0.5, right half = whole star
                                let half = value.location.x < 15 ? 0.5 : 1.0
                                rating = Double(star - 1) + half
                            }
                    )
            }
        }
        .accessibilityLabel("Rating")
        .accessibilityValue(String(format: "%.1f out of %d stars", rating, maxStars))
    }
}

Read-only display mode

Pass a constant binding with .constant(3) and remove the .onTapGesture modifier (or guard the setter) to render a non-interactive star display — useful in list rows, review cards, or previews where you want to show an existing rating without letting the user change it. Add .allowsHitTesting(false) to the HStack to block accidental taps entirely.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement a rating input in SwiftUI for iOS 17+.
Use HStack and Image(systemName:) with SF Symbols star/star.fill.
Accept a @Binding<Int>, a configurable maxStars parameter, and a starSize.
Make it accessible (VoiceOver labels, accessibilityAdjustableAction).
Add a #Preview with realistic sample data showing 3 of 5 stars selected.

Drop this prompt into Soarias during the Build phase — Claude Code will scaffold the component, wire it into your existing form view, and run a SwiftUI preview build automatically.

Related

FAQ

Does this work on iOS 16?

The core HStack / Image(systemName:) pattern compiles fine on iOS 16. The one iOS-17-only feature is .symbolEffect(.bounce, …) — wrap it in if #available(iOS 17, *) or simply remove it for backward-compatible builds.

How do I persist the rating with SwiftData?

Annotate your @Model class with an var rating: Int = 0 property, then pass $item.rating directly to StarRatingView(rating:). SwiftData's @Bindable macro (iOS 17+) makes model properties directly bindable — no extra @State wrapper needed.

What's the UIKit equivalent?

UIKit has no built-in star rating control. The common approach is a custom UIControl subclass holding an UIStackView of UIButtons configured with SF Symbol images — substantially more boilerplate than the SwiftUI version above. Alternatively, wrap StarRatingView in a UIHostingController to embed it in a UIKit form.

Last reviewed: 2026-05-11 by the Soarias team.

```