Spatial SwiftUI: hoverEffect modifier
Taking a look (😜) at the hoverEffect modifier.
Hover effects play a huge roll in visionOS. They add visual feedback and indicate focus when working with interfaces. They also feel a little like magic. In visionOS we had a simple hoverEffect() modifier and visionOS 2.0 brought custom effects.
Note: this post is about the SwiftUI modifier, not the RealityKit hover features. We’ll cover those in another example.
System hover effects
The most simple version of this to just use the modifier. This will let the system figure out how to apply a visual effect to the view we are looking at.
Circle()
.hoverEffect()We can enable or disable the effect
@State var enableHoverEffect: Bool = false
...
Circle()
.hoverEffect(isEnabled: enableHoverEffect)We can use a few system provided options
.hoverEffect(.automatic) // seems to use highlight
.hoverEffect(.highlight)
.hoverEffect(.automatic) // I can't see a difference between highlight and lift 🤷🏻♂️Custom hover effects
Starting in visionOS 2, we can create custom effects. Let’s start with one that changes the scale of our view. We can set a size and an anchor.
struct ScaleHoverEffect: CustomHoverEffect {
func body(content: Content) -> some CustomHoverEffect {
content.hoverEffect { effect, isActive, proxy in
effect.animation(.easeOut) {
$0.scaleEffect(
isActive ? CGSize(width: 1.2, height: 1.2) : CGSize(width: 1, height: 1),
anchor: .center
)
}
}
}
}Then we can use our custom effect
Circle()
.hoverEffect(ScaleHoverEffect())
// or
.hoverEffect(ScaleHoverEffect(), isEnabled: enableHoverEffect)Default hover effect
What if we want to apply a specific effect to all children in a view? We can use .defaultHoverEffect on the parent view. We still need to apply the hover effect on each view, but we don’t need to specify CustomMashupHoverEffect on each one.
GridRow {
Capsule()
.foregroundStyle(.regularMaterial)
.hoverEffect()
Capsule()
.foregroundStyle(.regularMaterial)
.hoverEffect()
Capsule()
.foregroundStyle(.regularMaterial)
.hoverEffect()
}
.defaultHoverEffect(CustomMashupHoverEffect())Caveat
CustomHoverEffect only supports a subset of modifiers and only the 2D versions. For example, we can’t use the shadow modifier at all. We can use offset(x:y:) but not offset(z:). We can’t use more advanced 3D modifiers like rotation3DEffect or transform3DEffect. I’m not sure why we are only limited to 2D effects, but I plan on filing a feedback/feature request.
Watch the video demo
Full example code
struct Example035: View {
@State var enableHoverEffect: Bool = false
var body: some View {
VStack(spacing: 12) {
Toggle("Hover Effect", isOn: $enableHoverEffect)
.toggleStyle(.button)
Grid(alignment: .center, horizontalSpacing: 24, verticalSpacing: 12) {
GridRow {
Text("System Hover Effects")
.font(.headline)
.gridCellColumns(3)
}
GridRow {
Capsule()
.foregroundStyle(.regularMaterial)
.overlay(Text("automatic"))
.hoverEffect(.automatic, isEnabled: enableHoverEffect) // automatic seems to use highlight
Capsule()
.foregroundStyle(.regularMaterial)
.overlay(Text("highlight"))
.hoverEffect(.highlight, isEnabled: enableHoverEffect)
Capsule()
.foregroundStyle(.regularMaterial)
.overlay(Text("lift"))
.hoverEffect(.lift, isEnabled: enableHoverEffect) // I can't see a difference between highlight and lift 🤷🏻♂️
}
GridRow {
Text("Custom Hover Effects")
.font(.headline)
.gridCellColumns(3)
}
GridRow {
Capsule()
.foregroundStyle(.regularMaterial)
.overlay(Text("Custom: fade"))
.hoverEffect(FadeHoverEffect(), isEnabled: enableHoverEffect)
Capsule()
.foregroundStyle(.regularMaterial)
.overlay(Text("Custom: scale"))
.hoverEffect(ScaleHoverEffect(), isEnabled: enableHoverEffect)
Capsule()
.foregroundStyle(.regularMaterial)
.overlay(Text("Custom: tilt"))
.hoverEffect(TiltHoverEffect(), isEnabled: enableHoverEffect)
}
GridRow {
Text("Default set on parent using defaultHoverEffect()")
.font(.headline)
.gridCellColumns(3)
}
GridRow {
Capsule()
.foregroundStyle(.regularMaterial)
.overlay(Text("Custom: mashup"))
.hoverEffect(isEnabled: enableHoverEffect)
Capsule()
.foregroundStyle(.regularMaterial)
.overlay(Text("Custom: mashup"))
.hoverEffect(isEnabled: enableHoverEffect)
Capsule()
.foregroundStyle(.regularMaterial)
.overlay(Text("Custom: mashup"))
.hoverEffect(isEnabled: enableHoverEffect)
}
.defaultHoverEffect(CustomMashupHoverEffect())
}
.frame(maxHeight: 300)
}
.padding()
}
}
struct FadeHoverEffect: CustomHoverEffect {
func body(content: Content) -> some CustomHoverEffect {
content.hoverEffect { effect, isActive, proxy in
effect.animation(.easeOut) {
$0.opacity(isActive ? 0.5 : 1)
}
}
}
}
struct ScaleHoverEffect: CustomHoverEffect {
func body(content: Content) -> some CustomHoverEffect {
content.hoverEffect { effect, isActive, proxy in
effect.animation(.easeOut) {
$0.scaleEffect(
isActive ? CGSize(width: 1.2, height: 1.2) : CGSize(width: 1, height: 1),
anchor: .center
)
}
}
}
}
struct TiltHoverEffect: CustomHoverEffect {
func body(content: Content) -> some CustomHoverEffect {
content.hoverEffect { effect, isActive, proxy in
effect.animation(.easeOut) {
$0.rotationEffect(.degrees(isActive ? 15 : 0), anchor: .bottomTrailing)
}
}
}
}
struct CustomMashupHoverEffect: CustomHoverEffect {
func body(content: Content) -> some CustomHoverEffect {
content.hoverEffect { effect, isActive, proxy in
effect.animation(.easeOut) {
$0.opacity(isActive ? 1 : 0.5)
.rotationEffect(.degrees(isActive ? 10 : 0), anchor: .center)
.scaleEffect(
isActive ? CGSize(width: 1.1, height: 1.1) : CGSize(width: 1, height: 1),
anchor: .center
)
}
}
}
}Leave a comment below and let me know what kind of custom hover effects you’re using in your apps.
Support our work so we can continue to bring you new examples and articles.
Download the Xcode project with this and many more examples from Step Into Vision.
Some examples are provided as standalone Xcode projects. You can find those here.

I added a feedback request for this, copied below. FB16343246.
In visionOS 2 we got the ability to create custom hover effects. Thank you! I’d like to see these effects enhanced a bit in the future.
CustomHoverEffect only supports a subset of modifiers and only the 2D versions.
Some examples:
– we can’t use the shadow modifier at all.
– We can use offset(x:y:) but not offset(z:).
– We can’t use more advanced 3D modifiers like rotation3DEffect or transform3DEffect.
I’m not sure why we are only limited to 2D effects. My hope is that this was just an omission that can be corrected at some point.
Example usage:
1. Use offset(z:) + shadow to raise an view from a surface on hover
2. Use rotation3DEffect or transform3DEffect to create subtle animations while the hover is ongoing.
I believe that support for these modifiers could be added to the existing CustomHoverEffect, but I would also understand if you choose to create a CustomHoverEffect3D version.