Streaming is available in most browsers,
and in the Developer app.
-
Create custom hover effects in visionOS
Learn how to develop custom hover effects that update views when people look at them. Find out how to build an expanding button effect that combines opacity, scale, and clip effects. Discover best practices for creating effects that are comfortable and respect people's accessibility needs.
Chapters
- 0:00 - Introduction
- 2:35 - Content effects
- 7:46 - Effect groups
- 9:40 - Delayed effects
- 12:09 - Accessibility
Resources
Related Videos
WWDC24
WWDC23
-
Download
Hello, and welcome to “Create custom hover effects in visionOS”. I’m Christian, an engineer on the SwiftUI team. In this video, you’ll learn how to make SwiftUI views respond when people look at them, using the new Custom Hover Effect API.
On visionOS, interactive areas highlight when people look at them. These highlights are applied using hover effects. Hover effects make your app feel responsive while providing feedback about which element will be triggered when people tap their fingers together.
Hover effects are added automatically to standard controls And can be added to custom controls, using the hoverEffect view modifier.
For more about standard effects like highlight, watch "Elevate your windowed app for spatial computing".
The standard highlight effect works great in most cases, but some views benefit from custom effects.
Sliders show a knob to invite interaction. Back buttons grow to show the name of the previous page. Tab bars pop open to show labels.
And the Safari navigation bar expands to reveal browser tabs.
With the new Custom Hover Effect API in visionOS 2, you can build custom hover effects just like these.
Custom hover effects can be applied to SwiftUI views anywhere in your app, including ornaments, and Reality View attachments. These effects are applied when people look at views, reach out with their finger or move a mouse cursor over them.
Custom hover effects were designed from the ground up to preserve privacy. They’re applied by the system, outside the app process. without requiring extra entitlements or extensions.
RealityKit also has new API to apply hover effects to 3D content. For details, check out "What’s New in RealityKit".
I’m really excited to share this API with you. I’ll start by explaining how to change a view’s appearance using content effects. Then I’ll show how to apply multiple effects in concert, using effect groups. Next, I’ll describe how to control the timing of an effect, using delayed effects.
Finally, I’ll use the new CustomHoverEffect protocol to create reusable effects that adapt to people’s accessibility preferences. Let’s get started talking about content effects! Content effects are fundamental effects that change how a view looks. They can change a view’s opacity, transform its geometry, or apply a clip shape. These effects only change how a view looks, and can’t impact the layout of nearby views. Effects transition between two states. When a view isn’t being looked at, the effect applies its inactive state.
When someone looks at the view, the effect transitions to its active state and updates the view. Geometry effects, like this scale effect, modify a view’s geometry.
Clip effects reveal hidden parts of a view And opacity effects fade content in or out. When someone looks away from the view, the effect transitions back to its inactive state. Multiple effects can be composed together to create detailed transitions like this expansion effect. I’ve been developing this effect for a video playback app I’m working on. I’ll spend the rest of this video building this effect, step-by-step, until it’s perfect.
Here’s the Destination Video app, running in the simulator. In the top-left corner, I’ve added a button to switch profiles. When I look at the button, it highlights. As a first step towards building the full expansion effect, I’ll make the button scale up when I look at it. Let’s write some code.
I’ve already placed the icon and detail text in a custom button and am using a custom button style to control how the button looks. This is where I’ll add the scale effect.
Inside the ButtonStyle, I’ve added a standard highlight using the hoverEffect modifier.
To apply the scale effect, I’ll add a new block-based hoverEffect modifier.
Inside the block, I can use modifiers like scaleEffect to change how the view looks as it transitions between the active and inactive states.
The block will be called with isActive true to get the effect’s active state, and with isActive false to get the effect’s inactive state. Since effects are applied by the system, these calls happen up front, not when the hover actually occurs.
Since I want the button to scale up when I look at it, I’ll apply a 5% scale when active, and no scale when inactive. Let’s check this out in the simulator.
Nice. The highlight and scale effects are applied together as I look at the button.
Next, I’ll use a clip effect to hide and reveal the button’s detail text. Clip effects change which part of a view is visible and can be used to hide additional content when not active. When the effect becomes active, the clip effect can expand to reveal the previously hidden content.
Back in the custom button style, I’ll move the existing clipShape modifier into the hoverEffect block so I can change the clipShape as the effect becomes active or inactive.
To change the clip shape’s size, I’ll add a size modifier to the shape and use the geometry proxy provided to the hover effect block to calculate the size of the active and inactive clip shapes.
When the effect is active, the clipShape should span the width of the button, so that the entire button is visible. But when the effect is inactive, the button should be circular and only the icon should be visible. So I’ll make the clipShape’s width and height match, resulting in a circular clipShape.
Lastly, I’ll use the new anchor parameter to align the clip shape to the button’s leading edge, ensuring the icon is visible when the effect is inactive. Let’s try this out in the simulator.
Amazing! When I look at the button it expands, and it collapses when I look away. I’ll add a little bit of polish to the effect, and make the detail text fade in as the button expands.
Since only the text should fade, I’ll add a hoverEffect to the detail text and apply an opacity effect that fades the text from 0 to 1.
This is close, but not quite right. Let’s look at it again.
I wanted the detail text to fade in as the button expands, but it only fades in when I look at the space where the detail text should be. Let’s discuss why.
Hover effects become active when someone looks at the view they’re attached to. I applied the scale and clipShape effects to the entire button, so they become active when I look anywhere inside the button. But I applied the opacity effect to the detail text, so it only becomes active when I look at the text. Instead, I need a way for the effects to activate together and to do that I’ll use Effect groups. Grouped effects are applied together, whenever any effect in the group becomes active. When effects from different views are grouped together, the group becomes active when I look at any of those views. This means effect groups control which areas of your app activate an effect.
There are two ways to group effects. Explicitly, and implicitly. I’ll start by explicitly grouping these effects.
To do so, I’ll create a HoverEffectGroup to represent the group. I’ll provide the group a unique ID using a Namespace.
Now that I’ve identified the group, I’ll explicitly add each effect to the group, starting with the opacity effect. I’ll provide the group to the ButtonStyle and add the remaining effects to the group. With all the effects in the same group, they should now activate together as a single effect.
This is really good! All the effects activate together in concert. The text fades in as the button expands, and fades out as the button collapses.
Explicitly grouping effects like this provides the most flexibility and control when grouping effects. But when I don’t need that level of control, I can also implicitly group effects.
Instead of providing a hoverEffectGroup to every effect, I can simply add a hoverEffectGroup modifier to the view. This will implicitly add every effect on this view and its sub-views to the group. So, I don’t need to add the group to every effect. And if I don’t provide a group to the modifier, a group will be implicitly created for me as well. Super convenient, right? The profile button’s really coming along. I’ve used content effects and effect groups to apply all the visual changes I need. But the button expands as soon as I look at it, which can get distracting. It would be better if it waited a moment before expanding. I can control when my button expands using delayed effects. By default, hover effects are applied immediately. This is great for subtle effects that invite interaction. But nearly all effects benefit from even a short delay. This prevents effects from briefly activating as people glance around an app.
People are never perfectly still. Using a delay to briefly keep an effect active accommodates for this natural motion, and avoids flickering.
Lastly, effects that reveal additional content should have longer delays. These effects easily become distracting, and should be reserved for moments when people are focused on a particular element of an app. The right delay is different for every effect, so always try effects out while wearing Apple Vision Pro and see what feels right.
Since the profile button reveals content, I’ll add a longer delay.
To apply the delay, I’ll wrap the effect in an animation modifier and provide a delayed animation.
I’ve used the default animation with a longer delay when the effect becomes active, and a shorter delay when the effect becomes inactive.
I won’t delay the scaleEffect though. This effect provides immediate feedback, so it should apply immediately as well.
Since the text fades in as the button expands, I’ll apply the same animation to the opacity effect so the effects stay in sync. Let’s try the effect again now that it has a delay.
Sweet. The button still provides immediate feedback via the scale and highlight effects. But it waits a moment before expanding.
Before moving on, let’s talk a bit more about animating effects.
When an animation isn’t specified, effects use SwiftUI’s default animation.
Hover effects support familiar animations like linear, easeOut, and spring animations as well as animations with custom timing curves.
But CustomAnimation types are not supported, since they can’t be applied outside your app’s process.
The expanding button feels really great to me. But for people with motion sensitivity, it could feel uncomfortable. You should always keep accessibility in mind when developing effects, and provide alternative effects when needed. In the remainder of this video, I’ll update the profile button to use a cross-fade effect when the “reduce motion” setting is enabled.
The button will still expand and collapse as I look at it, but will use a fade effect instead. Now, I’ve already written a fade effect to fade in the detail text. Instead of creating a duplicate effect, I’ll use the new CustomHoverEffect protocol to create an effect that I can use in both places.
To create a reusable effect, I’ll copy the hoverEffect modifier I wrote earlier and place it in a new FadeEffect type that conforms to CustomHoverEffect.
Within the effect’s body method, I have access to the same hoverEffect modifiers I used in the view. This makes it easy to start simple, and then refactor as I need to. Now that this effect is it’s own type, I can make it even more useful by allowing the inactive and active opacity values to be customized.
Now that I have a re-usable fade effect, I’ll go back and use it in the button view. I simply need to remove the hoverEffect block And replace it with my new FadeEffect().
I really love how this makes my code reusable and cleaner at the same time. So I’ll move the expand effect into a CustomHoverEffect type as well. Just as before, I’ll move the hoverEffect block from the view and place it in a new CustomHoverEffect type which I’ll call ExpandEffect.
Back in the button style, I’ll remove the block-based hoverEffect and replace it with my new ExpandEffect(). Beautiful! Now that all my effects are reusable, I can update the profile button to cross-fade when reduce motion is enabled.
I’ll start by adding an @Environment property to access the reduceMotion setting.
When reduceMotion is enabled my ExpandEffect() shouldn’t be applied. So I’ll apply an empty effect instead. The empty effect is just that an effect that does nothing. To dynamically switch between different effects, I need to wrap each in a HoverEffect type to erase their individual types. I’m not done yet, but I’ll check my progress in the simulator.
The background shape isn’t right yet, but the button isn’t expanding anymore and the detail text still fades in as it should. Let’s update the background to cross-fade in sync with the text.
To do that I’ll replace the existing background with two separate background views. The first is a Capsule() that spans the width of the button, and should become visible when I look at the button. So I’ll apply my FadeEffect to it when reduceMotion is enabled.
The second, circular background should only be visible when I’m not looking at the button. I’ll apply my FadeEffect here as well with custom opacity values so it fades out as the other fades in.
This works great! When I look at the button, the background and detail text now fade in together. The expanding button is now complete! I’ve created a detailed effect by combining just a few simple effects together. By choosing the right delays, and respecting peoples’ accessibility preferences I’ve ensured the effect feels great for everyone. Now it’s time to get creative and build your own custom hover effects! I encourage you to start simple, and build effects step-by-step. But keep some parts of a view static, like the icon in the profile button I created. These anchoring elements provide continuity during effect transitions. And of course, thoroughly test your effects. The simulator is great for quick iteration, but the only way to know how effects feel is to test them out while wearing Apple Vision Pro.
For more tips on creating amazing hover effects, check out the updated Human Interface Guidelines., and thanks for watching!
-
-
4:06 - Button with Scale Effect
struct ProfileButtonView: View { var action: () -> Void = { } var body: some View { Button(action: action) { HStack(spacing: 2) { ProfileIconView() ProfileDetailView() } } .buttonStyle(ProfileButtonStyle()) } struct ProfileButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .background(.thinMaterial) .hoverEffect(.highlight) .clipShape(.capsule) .hoverEffect { effect, isActive, _ in effect.scaleEffect(isActive ? 1.05 : 1.0) } } } struct ProfileIconView: View { var body: some View { Image(systemName: "person.crop.circle") .resizable() .scaledToFit() .frame(width: 44, height: 44) .padding(6) } } struct ProfileDetailView: View { var body: some View { VStack(alignment: .leading) { Text("Peter McCullough") .font(.body) .foregroundStyle(.primary) Text("Switch profiles") .font(.footnote) .foregroundStyle(.tertiary) } .padding(.trailing, 24) } } }
-
5:37 - Button with Clip and Scale Effects
struct ProfileButtonView: View { var action: () -> Void = { } var body: some View { Button(action: action) { HStack(spacing: 2) { ProfileIconView() ProfileDetailView() } } .buttonStyle(ProfileButtonStyle()) } struct ProfileButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .background(.thinMaterial) .hoverEffect(.highlight) .hoverEffect { effect, isActive, proxy in effect.clipShape(.capsule.size( width: isActive ? proxy.size.width : proxy.size.height, height: proxy.size.height, anchor: .leading )) .scaleEffect(isActive ? 1.05 : 1.0) } } } struct ProfileIconView: View { var body: some View { Image(systemName: "person.crop.circle") .resizable() .scaledToFit() .frame( width: 44, height: 44 ) .padding(6) } } struct ProfileDetailView: View { var body: some View { VStack(alignment: .leading) { Text("Peter McCullough") .font(.body) .foregroundStyle(.primary) Text("Switch profiles") .font(.footnote) .foregroundStyle(.tertiary) } .padding(.trailing, 24) } } }
-
6:50 - Expanding Button with Ungrouped Fade
struct ProfileButtonView: View { var action: () -> Void = { } var body: some View { Button(action: action) { HStack(spacing: 2) { ProfileIconView() ProfileDetailView() .hoverEffect { effect, isActive, _ in effect.opacity(isActive ? 1 : 0) } } } .buttonStyle(ProfileButtonStyle()) } struct ProfileButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .background(.thinMaterial) .hoverEffect(.highlight) .hoverEffect { effect, isActive, proxy in effect.clipShape(.capsule.size( width: isActive ? proxy.size.width : proxy.size.height, height: proxy.size.height, anchor: .leading )) .scaleEffect(isActive ? 1.05 : 1.0) } } } struct ProfileIconView: View { var body: some View { Image(systemName: "person.crop.circle") .resizable() .scaledToFit() .frame(width: 44, height: 44) .padding(6) } } struct ProfileDetailView: View { var body: some View { VStack(alignment: .leading) { Text("Peter McCullough") .font(.body) .foregroundStyle(.primary) Text("Switch profiles") .font(.footnote) .foregroundStyle(.tertiary) } .padding(.trailing, 24) } } }
-
8:19 - Expanding Button with Explicit Group
struct ProfileButtonView: View { var action: () -> Void = { } @Namespace var hoverNamespace var hoverGroup: HoverEffectGroup { HoverEffectGroup(hoverNamespace) } var body: some View { Button(action: action) { HStack(spacing: 2) { ProfileIconView() ProfileDetailView() .hoverEffect(in: hoverGroup) { effect, isActive, _ in effect.opacity(isActive ? 1 : 0) } } } .buttonStyle(ProfileButtonStyle(hoverGroup: hoverGroup)) } struct ProfileIconView: View { var body: some View { Image(systemName: "person.crop.circle") .resizable() .scaledToFit() .frame(width: 44, height: 44) .padding(6) } } struct ProfileDetailView: View { var body: some View { VStack(alignment: .leading) { Text("Peter McCullough") .font(.body) .foregroundStyle(.primary) Text("Switch profiles") .font(.footnote) .foregroundStyle(.tertiary) } .padding(.trailing, 24) } } } struct ProfileButtonStyle: ButtonStyle { var hoverGroup: HoverEffectGroup? func makeBody(configuration: Configuration) -> some View { configuration.label .background(.thinMaterial) .hoverEffect(.highlight, in: hoverGroup) .hoverEffect(in: hoverGroup) { effect, isActive, proxy in effect.clipShape(.capsule.size( width: isActive ? proxy.size.width : proxy.size.height, height: proxy.size.height, anchor: .leading )) .scaleEffect(isActive ? 1.05 : 1.0) } } }
-
9:13 - Expanding Button with Implicit Group
struct ProfileButtonView: View { var action: () -> Void = { } var body: some View { Button(action: action) { HStack(spacing: 2) { ProfileIconView() ProfileDetailView() .hoverEffect { effect, isActive, _ in effect.opacity(isActive ? 1 : 0) } } } .buttonStyle(ProfileButtonStyle()) .hoverEffectGroup() } struct ProfileButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .background(.thinMaterial) .hoverEffect(.highlight) .hoverEffect { effect, isActive, proxy in effect.clipShape(.capsule.size( width: isActive ? proxy.size.width : proxy.size.height, height: proxy.size.height, anchor: .leading )) .scaleEffect(isActive ? 1.05 : 1.0) } } } struct ProfileIconView: View { var body: some View { Image(systemName: "person.crop.circle") .resizable() .scaledToFit() .frame( width: 44, height: 44 ) .padding(6) } } struct ProfileDetailView: View { var body: some View { VStack(alignment: .leading) { Text("Peter McCullough") .font(.body) .foregroundStyle(.primary) Text("Switch profiles") .font(.footnote) .foregroundStyle(.tertiary) } .padding(.trailing, 24) } } }
-
10:51 - Expanding Button with Delayed Effect
struct ProfileButtonView: View { var action: () -> Void = { } var body: some View { Button(action: action) { HStack(spacing: 2) { ProfileIconView() ProfileDetailView() .hoverEffect { effect, isActive, _ in effect.animation(.default.delay(isActive ? 0.8 : 0.2)) { $0.opacity(isActive ? 1 : 0) } } } } .buttonStyle(ProfileButtonStyle()) .hoverEffectGroup() } struct ProfileButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .background(.thinMaterial) .hoverEffect(.highlight) .hoverEffect { effect, isActive, proxy in effect.animation(.default.delay(isActive ? 0.8 : 0.2)) { $0.clipShape(.capsule.size( width: isActive ? proxy.size.width : proxy.size.height, height: proxy.size.height, anchor: .leading )) }.scaleEffect(isActive ? 1.05 : 1.0) } } } struct ProfileIconView: View { var body: some View { Image(systemName: "person.crop.circle") .resizable() .scaledToFit() .frame( width: 44, height: 44 ) .padding(6) } } struct ProfileDetailView: View { var body: some View { VStack(alignment: .leading) { Text("Peter McCullough") .font(.body) .foregroundStyle(.primary) Text("Switch profiles") .font(.footnote) .foregroundStyle(.tertiary) } .padding(.trailing, 24) } } }
-
12:50 - Expanding Button with Reusable Effects
struct ProfileButtonView: View { var action: () -> Void = { } var body: some View { Button(action: action) { HStack(spacing: 2) { ProfileIconView() ProfileDetailView() .hoverEffect(FadeEffect()) } } .buttonStyle(ProfileButtonStyle()) .hoverEffectGroup() } struct ProfileButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .background(.thinMaterial) .hoverEffect(.highlight) .hoverEffect(ExpandEffect()) } } struct ExpandEffect: CustomHoverEffect { func body(content: Content) -> some CustomHoverEffect { content.hoverEffect { effect, isActive, proxy in effect.animation(.default.delay(isActive ? 0.8 : 0.2)) { $0.clipShape(.capsule.size( width: isActive ? proxy.size.width : proxy.size.height, height: proxy.size.height, anchor: .leading )) }.scaleEffect(isActive ? 1.05 : 1.0) } } } struct FadeEffect: CustomHoverEffect { var from: Double = 0 var to: Double = 1 func body(content: Content) -> some CustomHoverEffect { content.hoverEffect { effect, isActive, _ in effect.animation(.default.delay(isActive ? 0.8 : 0.2)) { $0.opacity(isActive ? to : from) } } } } struct ProfileIconView: View { var body: some View { Image(systemName: "person.crop.circle") .resizable() .scaledToFit() .frame( width: 44, height: 44 ) .padding(6) } } struct ProfileDetailView: View { var body: some View { VStack(alignment: .leading) { Text("Peter McCullough") .font(.body) .foregroundStyle(.primary) Text("Switch profiles") .font(.footnote) .foregroundStyle(.tertiary) } .padding(.trailing, 24) } } }
-
14:14 - Final Expanding Button with Accessibility Support
struct ProfileButtonView: View { var action: () -> Void = { } var body: some View { Button(action: action) { HStack(spacing: 2) { ProfileIconView() ProfileDetailView() .hoverEffect(FadeEffect()) } } .buttonStyle(ProfileButtonStyle()) .hoverEffectGroup() } struct ProfileButtonStyle: ButtonStyle { @Environment(\.accessibilityReduceMotion) var reduceMotion func makeBody(configuration: Configuration) -> some View { configuration.label .background { ZStack(alignment: .leading) { Capsule() .fill(.thinMaterial) .hoverEffect(.highlight) .hoverEffect( reduceMotion ? HoverEffect(FadeEffect()) : HoverEffect(.empty)) if reduceMotion { Circle() .fill(.thinMaterial) .hoverEffect(.highlight) .hoverEffect(FadeEffect(from: 1, to: 0)) } } } .hoverEffect( reduceMotion ? HoverEffect(.empty) : HoverEffect(ExpandEffect()) ) } } struct ExpandEffect: CustomHoverEffect { func body(content: Content) -> some CustomHoverEffect { content.hoverEffect { effect, isActive, proxy in effect.animation(.default.delay(isActive ? 0.8 : 0.2)) { $0.clipShape(.capsule.size( width: isActive ? proxy.size.width : proxy.size.height, height: proxy.size.height, anchor: .leading )) }.scaleEffect(isActive ? 1.05 : 1.0) } } } struct FadeEffect: CustomHoverEffect { var from: Double = 0 var to: Double = 1 func body(content: Content) -> some CustomHoverEffect { content.hoverEffect { effect, isActive, _ in effect.animation(.default.delay(isActive ? 0.8 : 0.2)) { $0.opacity(isActive ? to : from) } } } } struct ProfileIconView: View { var body: some View { Image(systemName: "person.crop.circle") .resizable() .scaledToFit() .frame( width: 44, height: 44 ) .padding(6) } } struct ProfileDetailView: View { var body: some View { VStack(alignment: .leading) { Text("Peter McCullough") .font(.body) .foregroundStyle(.primary) Text("Switch profiles") .font(.footnote) .foregroundStyle(.tertiary) } .padding(.trailing, 24) } } }
-
-
Looking for something specific? Enter a topic above and jump straight to the good stuff.
An error occurred when submitting your query. Please check your Internet connection and try again.