Skip to content

Rating Stars

Tappable rating stars that pop with a spring and work as an adjustable value in VoiceOver.

Rating Stars, playing
Download .swift
Origin
Original work
Found by
rating · stars · review · score · feedback · spring · adjustable · valoración · estrellas · reseña · puntuación · opinión · muelle · ajustable

A row of tappable stars. Each star pops with a spring when it fills, and a tap on the chosen star clears the rating.

VoiceOver treats the row as one adjustable value. With Reduce Motion on, the stars only change color.

struct RatingStarsDemo: View {
@State private var rating = 0
@State private var isAutoPlaying = true
/// Any change made by the user, by touch or VoiceOver, ends the automatic rating.
private var userRating: Binding<Int> {
Binding(
get: { rating },
set: { newValue in
isAutoPlaying = false
rating = newValue
}
)
}
private var caption: String {
["Tap a star", "Terrible", "Not great", "It was fine", "Really good", "Loved it"][rating]
}
var body: some View {
ZStack {
LinearGradient(colors: [.orange.opacity(0.25), .pink.opacity(0.2)], startPoint: .top, endPoint: .bottom)
.ignoresSafeArea()
VStack(spacing: 20) {
Image(systemName: "fork.knife.circle.fill")
.font(.system(.largeTitle, design: .rounded))
.imageScale(.large)
.foregroundStyle(.orange.gradient)
.accessibilityHidden(true)
Text("How was your dinner?")
.font(.title2.bold())
RatingStars(rating: userRating)
Text(caption)
.font(.headline)
.foregroundStyle(.secondary)
.contentTransition(.opacity)
.animation(.default, value: rating)
}
.padding(32)
.frame(maxWidth: .infinity)
.background(.regularMaterial, in: .rect(cornerRadius: 28))
.padding(24)
}
.task(id: isAutoPlaying) {
guard isAutoPlaying else { return }
let script = [3, 5, 2, 4]
var step = 0
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(0.9))
guard !Task.isCancelled else { return }
rating = script[step % script.count]
step += 1
}
}
}
}
ParameterTypeDefault
ratingBinding<Int>Required
maximumInt5
colorColor.orange

Copy this code into your project, or download the file.

RatingStars.swift
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
enum RatingStarsValue {
/// Keeps a rating between zero and the number of stars.
static func clamped(_ rating: Int, maximum: Int) -> Int {
min(max(rating, 0), max(maximum, 0))
}
/// The rating after a tap on `star`: that star, or none when it was already the rating.
static func tapping(_ star: Int, rating: Int, maximum: Int) -> Int {
star == rating ? 0 : clamped(star, maximum: maximum)
}
}
/// A row of tappable stars. Each star pops with a spring when it fills, and a tap on the
/// chosen star clears the rating.
///
/// VoiceOver treats the row as one adjustable value. With Reduce Motion on, the stars only change color.
struct RatingStars: View {
@Binding var rating: Int
var maximum: Int = 5
var color: Color = .orange
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
HStack(spacing: 10) {
ForEach(1...max(maximum, 1), id: \.self) { star in
starButton(star)
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("Rating")
.accessibilityValue("\(rating) of \(maximum) stars")
.accessibilityAdjustableAction { direction in
switch direction {
case .increment: rating = RatingStarsValue.clamped(rating + 1, maximum: maximum)
case .decrement: rating = RatingStarsValue.clamped(rating - 1, maximum: maximum)
@unknown default: break
}
}
}
private func starButton(_ star: Int) -> some View {
let isFilled = star <= rating
return Button {
rating = RatingStarsValue.tapping(star, rating: rating, maximum: maximum)
} label: {
Image(systemName: "star.fill")
.font(.largeTitle)
.foregroundStyle(isFilled ? AnyShapeStyle(color.gradient) : AnyShapeStyle(.quaternary))
.animation(.easeOut(duration: 0.15), value: isFilled)
.phaseAnimator([1.0, 1.4], trigger: isFilled) { view, scale in
view.scaleEffect(isFilled && !reduceMotion ? scale : 1)
} animation: { scale in
scale > 1 ? .easeOut(duration: 0.1) : .spring(duration: 0.4, bounce: 0.6)
}
// A star answers to a touch up to the middle of the gap to the next one,
// which makes it as big as a touch target should be. The stars do not move.
.padding(5)
.contentShape(.rect)
.padding(-5)
}
.buttonStyle(.plain)
}
}
// MARK: - Demo
struct RatingStarsDemo: View {
@State private var rating = 0
@State private var isAutoPlaying = true
/// Any change made by the user, by touch or VoiceOver, ends the automatic rating.
private var userRating: Binding<Int> {
Binding(
get: { rating },
set: { newValue in
isAutoPlaying = false
rating = newValue
}
)
}
private var caption: String {
["Tap a star", "Terrible", "Not great", "It was fine", "Really good", "Loved it"][rating]
}
var body: some View {
ZStack {
LinearGradient(colors: [.orange.opacity(0.25), .pink.opacity(0.2)], startPoint: .top, endPoint: .bottom)
.ignoresSafeArea()
VStack(spacing: 20) {
Image(systemName: "fork.knife.circle.fill")
.font(.system(.largeTitle, design: .rounded))
.imageScale(.large)
.foregroundStyle(.orange.gradient)
.accessibilityHidden(true)
Text("How was your dinner?")
.font(.title2.bold())
RatingStars(rating: userRating)
Text(caption)
.font(.headline)
.foregroundStyle(.secondary)
.contentTransition(.opacity)
.animation(.default, value: rating)
}
.padding(32)
.frame(maxWidth: .infinity)
.background(.regularMaterial, in: .rect(cornerRadius: 28))
.padding(24)
}
.task(id: isAutoPlaying) {
guard isAutoPlaying else { return }
let script = [3, 5, 2, 4]
var step = 0
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(0.9))
guard !Task.isCancelled else { return }
rating = script[step % script.count]
step += 1
}
}
}
}
#Preview {
RatingStarsDemo()
}