How to build a rating input in SwiftUI
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
-
ForEach(1...maxStars) — iterates from 1 to
maxStars, giving each star its natural 1-based index. Comparingstar <= ratingdecides whether to show star.fill or the outline star SF Symbol. -
Image(systemName:) + resizable/scaledToFit — SF Symbols scale cleanly at any point size.
Setting an explicit frame via the
starSizeparameter makes the hit target consistent and easy to customise without touching internal layout. -
.symbolEffect(.bounce, value: rating == star) — iOS 17's
symbolEffectmodifier fires a one-shot bounce animation on the newly selected star wheneverratingchanges to match that star's index. -
.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. -
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
-
symbolEffect requires iOS 17. If you need to support iOS 16, remove the
.symbolEffect(.bounce, …)modifier — it won't compile on earlier SDKs even with an availability guard. -
ForEach range crash when maxStars < 1.
1...0is an invalid range and will trap at runtime. Always clamp withmax(1, maxStars)or add aguard maxStars >= 1before theForEach. -
Tiny tap targets on small star sizes. SF Symbol images below ~22 pt become hard to tap accurately.
Use
.contentShape(Rectangle())on each star so the entire frame — not just the glyph outline — registers touches.
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.