How to make a simple bevel effect using inner shadows in SwiftUI

⋅ 11 min read ⋅ SwiftUI

Table of Contents

We can make a simple bevel effect using two inner shadows. SwiftUI has a built-in way to add a drop shadow with the .shadow modifier. But if you want to add an inner shadow effect, you need to be a bit creative.

Inner shadow is an effect that can simulate many real-world objects by combining one or more inner shadows and adjust their parameters, e.g., position and blur radius. I will show you the simple bevel effect where the inner shadows have zero blur radius. This will produce a retro-style look and feel.

I will show you two examples of how I apply a bevel effect to rectangular and circular shapes. You will see what modifiers you need for each case.

Bevel effect

The beveled edge is an edge of a structure that is not perpendicular to the faces of the piece. As a result, it will have an edge that got highlight and one with darker.

Wooden frame with a beveled edge.
Wooden frame with a beveled edge.

Some use this technique on actionable UI to make it stand out from the rest. You might have seen this in an operating system UI or retro game since it can easily build. The highlight and shadow edge can be build from a single solid line.

Windows 98 uses a bevel effect on their UI.
Windows 98 uses a bevel effect on their UI.

To create a bevel effect, we need two non-blur inner shadows. One as a highlighted edge, the other as a shadowed edge. Here is a design I created in the Sketch app to show you the result we want.

We will create a text field with a bevel effect.
We will create a text field with a bevel effect.

Rectangle

Our first example will create a text field with a bevel effect. We need two inner shadows laying out in opposite directions—one for a highlighted edge and one for a darker edge.

overlay and stroke

To create an inner shadow, I will use an overlay and stroke modifiers. There might be other ways, but I think this is the easiest.

struct ContentView: View {
var body: some View {
Rectangle()
.fill(Color.pink)
.frame(width: 200, height: 200) // 1
.overlay(
Rectangle() // 2
.stroke(Color.black, lineWidth: 20) // 3
)
.border(Color.blue) // 4
}
}

<1> A pink rectangle of size 200x200. This is an object that we want to apply a bevel effect on.
<2> Create an overlay over the object.
<3> We create an overlay of a rectangle with a stroke with a width of 20.
<4> A border for debugging purposes.

A rectangle with a stroke.
A rectangle with a stroke.

As you can see, a stroke is drawn along the center of the rectangular path splitting the stroke width into an equals width of 10. This stroke will use as a shadow and highlight edges. We want just two edges (top and left) for shadow edges. So, we will move right and bottom edges away with the .offset modifier.

offset

We move our rectangular overlay stroke with an offset modifier to move the bottom and right edges out of the parent frame.

struct ContentView: View {
var body: some View {
Rectangle()
.fill(Color.pink)
.frame(width: 200, height: 200)
.overlay(
Rectangle()
.stroke(Color.black, lineWidth: 20)
.offset(x: 10.0, y: 10.0) // 1
)
.border(Color.blue)

}
}

<1> Offset to the bottom right by 10 points.

Overlay stroke gets offset by 10 points toward the bottom-right direction.
Overlay stroke gets offset by 10 points toward the bottom-right direction.

Moving the stroke ten points to the right and ten points to the bottom make the bottom and trailing edges move out of the parent frame. At this point, the overflow edges still render as normal. The only thing left is to clip that out with the .clipped modifier.

clipped

clipped will clip the view to its bounding rectangular frame.

struct ContentView: View {
var body: some View {
Rectangle()
.fill(Color.pink)
.frame(width: 200, height: 200)
.overlay(
Rectangle()
.stroke(Color.black, lineWidth: 20)
.offset(x: 10.0, y: 10.0)
.clipped() // 1
)
.border(Color.blue)

}
}

<1> Clipped stroke to the parent frame.

The overflow stroke is getting clipped to the parent frame.
The overflow stroke is getting clipped to the parent frame.

Now we got an inner shadow to represent a shadow edge of a beveled structure. We need another shadow for the highlight edge. We can copy most of the code over to create the highlight edges.

Second Inner Shadow

We can create a second inner shadow using the same technique. The only difference is we put it in the opposite direction by invert x and y offset.

struct ContentView: View {
var body: some View {
Rectangle()
.fill(Color.pink)
.frame(width: 200, height: 200)
.overlay(
Rectangle()
.stroke(Color.black, lineWidth: 20)
.offset(x: 10.0, y: 10.0)
.clipped()
)
.overlay(
Rectangle()
.stroke(Color.gray, lineWidth: 20)
.offset(x: -10.0, y: -10.0) // 1
.clipped()
)
.border(Color.blue)
}
}

<1> Invert x and y offset by using negative values.

Add the second shadow at opposite edges.
Add the second shadow at opposite edges.

This might not look like a working effect, but it is. Since this effect composes of very simple components, the quality of the result depends heavily on the size (of object and shadow) and color choices.

Demo

Here is the example of a text field replicated Windows 98 theme using the above technique.

struct ContentView: View {
@State var input: String = "Hello, SwiftUI!"

var body: some View {
ZStack {
Color("BackgroundColor").edgesIgnoringSafeArea(.all)
TextField("Title", text: $input)
.font(.custom("Windows", size: 20))
.padding()
.overlay(
Rectangle()
.stroke(Color("ShadowColor"), lineWidth: 4)
.offset(x: 2, y: 2)
.clipped()
)
.overlay(
Rectangle()
.stroke(Color("HighlightColor"), lineWidth: 4)
.offset(x: -2, y: -2)
.clipped()
)
.padding()
}

}
}

As you can see, I use the same technique with a custom font and appropriate shadow width to yield a pleasant result.

A text field with a bevel effect.
A text field with a bevel effect.

Circle

Let's see another example. This time, I will try to apply the bevel effect on a Circle view.

I start by replacing Rectangle with Circle.

struct ContentView: View {
var body: some View {
Circle() // 1
.fill(Color.pink)
.frame(width: 200, height: 200)
.overlay(
Circle() // 2
.stroke(Color.black, lineWidth: 20)
.offset(x: 10.0, y: 10.0)
.clipped()
)
.overlay(
Circle()
.stroke(Color.gray, lineWidth: 20)
.offset(x: -10.0, y: -10.0)
.clipped()
)
.border(Color.blue)
}
}

<1>, <2> Replace Rectangle with Circle shape.

A bevel effect on a circular shape.
A bevel effect on a circular shape.

Since the .clipped modifier clips our shadow to the parent rectangular frame, we need to find another way to make a circular clipping shape.

clipShape

clipShape sets a clipping shape for the view. It accepts Shape as an argument which is perfect for our case.

We replace .clipped with .clipShape(Circle()) to make sure our shadows get clipped to the circle's shape.

struct ContentView: View {
var body: some View {
Circle()
.fill(Color.pink)
.frame(width: 200, height: 200)
.overlay(
Circle()
.stroke(Color.black, lineWidth: 20)
.offset(x: 10.0, y: 10.0)
.clipShape(Circle()) // 1
)
.overlay(
Circle()
.stroke(Color.gray, lineWidth: 20)
.offset(x: -10.0, y: -10.0)
.clipShape(Circle())
)
.border(Color.blue)
}
}

<1> Replace .clipped with .clipShape(Circle())

A bevel effect on circle shape with a circle clip shape.

Clip a shadow with a circle's shape.
Clip a shadow with a circle's shape.

Our shadows now get clipped into a circular shape, but we still have another issue, the shadow does not perfectly align with its parent, revealing pink color on both sides.

Offset

As you might see, we can't use the same offset as our rectangular shape. The shadow curve is not aligned with the parent circle. I know there will be a mathematical formula somewhere to calculate the perfect offset, but I won't cover it here. For this example, I will just reduce the offset until it fits.

I play around until I find the best possible alignment.

struct ContentView: View {
var body: some View {
Circle()
.fill(Color.pink)
.frame(width: 200, height: 200)
.overlay(
Circle()
.stroke(Color.black, lineWidth: 20)
.offset(x: 7, y: 7)
.clipShape(Circle())
)
.overlay(
Circle()
.stroke(Color.gray, lineWidth: 20)
.offset(x: -7, y: -7)
.clipShape(Circle())
)
}
}
A bevel effect with an adjusted offset.
A bevel effect with an adjusted offset.

With our current constraint, we can't produce anything better than this.

  1. Small offset will lead to overlap of two shadows (Left: x and y offset equals 1)
  2. Too much offset will lead to a visible gap between shadows and parent (Right: x and y offset equals 10)
  3. You can still see an overlap of shadow and highlight even with the right amount of offset (Middle: x and y offset equals 7)
x, y offset equals 1, 7, and 10, respectively.
x, y offset equals 1, 7, and 10, respectively.

To solve this, we need to adjust the size of our shadow.

Adjust shadow size

Increase the shadow size create more room for us to place our shadow. The shadow in the following example has a size of 220 with an offset of 0, 7, 10, respectively.

x, y offset equals 0, 7, and 10, respectively.
x, y offset equals 1, 7, and 10, respectively.
struct ContentView: View {
var body: some View {
HStack(spacing: 40) {
Circle()
.fill(Color.pink)
.frame(width: 200, height: 200)
.overlay(
Circle()
.stroke(Color.black, lineWidth: 20)
.frame(width: 220, height: 220)
.offset(x: 0, y: 0)
)
.border(Color.blue)
Circle()
.fill(Color.pink)
.frame(width: 200, height: 200)
.overlay(
Circle()
.stroke(Color.black, lineWidth: 20)
.frame(width: 220, height: 220)
.offset(x: 7, y: 7)
)
.border(Color.blue)

Circle()
.fill(Color.pink)
.border(Color.blue)
.frame(width: 200, height: 200)
.overlay(
Circle()
.stroke(Color.black, lineWidth: 20)
.frame(width: 220, height: 220)
.offset(x: 10, y: 10)
).border(Color.blue)
}
}
}

Let's try to use this new size for our example.

struct ContentView: View {
var body: some View {
Circle()
.fill(Color.pink)
.frame(width: 200, height: 200)
.overlay(
Circle()
.stroke(Color.black, lineWidth: 20)
.frame(width: 220, height: 220) // 1
.offset(x: 7, y: 7)
.clipShape(Circle())
)
.overlay(
Circle()
.stroke(Color.gray, lineWidth: 20)
.frame(width: 220, height: 220)
.offset(x: -7, y: -7)
.clipShape(Circle())
)
.border(Color.blue)
}
}

<1> Increase the size of the shadow.

Shadow is clipped by the larger circle, which yields the same result as before.
Shadow is clipped by the larger circle, which yields the same result as before.

The result looks the same as the last example, but that's because we use the wrong clipping technique. We use .clipShape(Circle()), which clips to our large shadow circle size (size 220).

Since .clipShape accepts Shape as an argument, we can't specify the clipping shape's size.

.clipShape(
Circle()
.frame(width: 200, height: 200) // Try to set the size
)

Doing so and you will get the following error.

Instance method 'clipShape(_:style:)' requires that 'some View' conform to 'Shape'

Luckily, we have a working alternative.

mask

Mask working similar to the .clipShape, but instead of shape, it accepts view as an argument. This makes a perfect fit for our case.

struct ContentView: View {
var body: some View {
Circle()
.fill(Color.pink)
.frame(width: 200, height: 200)
.overlay(
Circle()
.stroke(Color.black, lineWidth: 20)
.frame(width: 220, height: 220)
.offset(x: 7, y: 7)
.mask( // 1
Circle()
.frame(width: 200, height: 200)
)
)
.overlay(
Circle()
.stroke(Color.gray, lineWidth: 20)
.frame(width: 220, height: 220)
.offset(x: -7, y: -7)
.mask(
Circle()
.frame(width: 200, height: 200)
)
)
.border(Color.blue)
}
}

<1> Mask the shadow with the same size as the parent circle (200).

With this change, we got a better shadow for a circular shape.

Clip shadow to the parent size.
Clip shadow to the parent size.

Demo

Again, this might not look like a pleasant shadow, but that because it is on the wrong color combination. Here is an example using the method above.

struct ContentView: View {
var body: some View {
ZStack {
Color("ControllerBackgroundColor") // Red background
.ignoresSafeArea()
Circle() // Our button
.fill(Color("ControllerButtonColor"))
.frame(width: 200, height: 200)
.shadow(color: .black.opacity(0.3), radius: 0, x: 0, y: 20)
.overlay(
Circle()
.stroke(Color("ControllerHighlightColor"), lineWidth: 20)
.frame(width: 220, height: 220)
.offset(x: 0, y: 10)
.mask(
Circle()
.frame(width: 200, height: 200)
)
)
.overlay(
Circle()
.stroke(Color("ControllerShadowColor"), lineWidth: 20)
.frame(width: 220, height: 220)
.offset(x: 0, y: -10)
.mask(
Circle()
.frame(width: 200, height: 200)
)
)
Text("A") // A label
.font(.system(size: 120, weight: .heavy))
.foregroundColor(.white)
}
}
}

I use a bevel effect to simulate a game controller's button.

A game controller's button created using a bevel effect.
A game controller's button created using a bevel effect.

Conclusion

In this article, you learn how to create a bevel effect using two inner shadows. We use overlay, stroke, offset, and clip/mask to simulate an inner shadow effect.

I didn't pack a bevel effect into a view modifier or reusable component because the implementation detail might vary based on usage. As you can see, even rectangles shape and circular shapes have different implementation detail. Trying to generalize them might be complicated than necessary for this article.

But with all the basics you learn in this article, I think you can create your own version of a bevel effect and group them as needed into a reusable component based on your use case.


Read more article about SwiftUI or see all available topic

Feel free to follow me on Twitter and ask your questions related to this post. Thanks for reading and see you next time.

If you enjoy my writing, please check out my Patreon https://www.patreon.com/sarunw and become my supporter. Sharing the article is also greatly appreciated.

Become a patron Tweet Share
Previous
What is Property Wrappers in Swift

As the name implies, a property wrapper is a new type that wraps a property to add additional logic. Let's see what it capable of and the benefit it provided.

Next
How to set up iOS environments: develop, staging, and production

Learn how to create a separate environment for your app with the help of Configuration and Scheme. Create a different app and variables for each environment on the same codebase.

← Home