How to Add a Rounded Border to a Button in UIKit

How to Add a Rounded Border to a Button in UIKit

Written by

in

Quick answer: In modern UIKit (iOS 15+), assign a UIButton.Configuration.plain() with config.background.strokeColor = .systemBlue, config.background.strokeWidth = 1, and config.background.cornerRadius = 8. For legacy layer styling, set button.layer.cornerRadius = 8, button.layer.borderWidth = 1, and button.layer.borderColor = UIColor.systemBlue.cgColor.

Why this happens

In UIKit, UIButton does not expose direct properties like borderColor or cornerRadius on the control itself. Unlike simple styling properties such as backgroundColor or tintColor, border styling historically belongs to the underlying Core Animation layer (CALayer). Developers attempting to style a button often run into several points of friction:

First, CALayer.borderColor requires a CGColor, not a UIColor. Attempting to assign button.layer.borderColor = UIColor.systemBlue triggers the Swift compiler error Cannot assign value of type 'UIColor' to type 'CGColor?'. Because CGColor does not store dynamic semantic information, colors resolved to CGColor do not automatically refresh when the device toggles between Light and Dark Mode.

Second, iOS 15 introduced UIButton.Configuration. When a button uses modern configurations (such as .plain(), .filled(), or .bordered()), UIKit dynamically manages the view hierarchy and background presentation. Modifying layer.cornerRadius or layer.borderColor directly on a configuration-backed button can lead to visual glitches, ignored properties, or borders being overwritten whenever the button changes state (e.g., highlighted or disabled).

Understanding whether your button relies on modern UIButton.Configuration or traditional CALayer manipulation determines how you should apply rounded borders.


How to fix it

1. Modern approach: Use UIButton.Configuration (iOS 15+)

On iOS 15 and later, Apple recommends configuring buttons using UIButton.Configuration. The configuration object manages background rendering, including border stroke width, stroke color, and corner styling, without requiring manual CALayer calls.

To create a button with a clear background and a 1-point rounded border:

import UIKit

func configureRoundedBorderButton() -> UIButton {
    var config = UIButton.Configuration.plain()
    config.title = "Action Button"
    config.baseForegroundColor = .systemBlue

    // Configure the border stroke
    config.background.strokeColor = .systemBlue
    config.background.strokeWidth = 1.0
    config.background.cornerRadius = 8.0

    // Ensure the background fill remains clear
    config.background.backgroundColor = .clear

    let button = UIButton(configuration: config, primaryAction: UIAction { _ in
        print("Button tapped")
    })

    return button
}

Using UIButton.Configuration provides distinct advantages:
– The strokeColor accepts a UIColor directly, fully supporting dynamic and asset-catalog colors in Dark Mode.
– The border and corner styling automatically respect the button’s layout metrics and edge insets.
– UIKit preserves the border appearance seamlessly across highlighted, focused, and disabled states.


2. Classic approach: Configure CALayer properties

If your project supports custom subclasses, legacy codebases, or targets where UIButton.Configuration is disabled, you can style the button’s backing CALayer directly.

import UIKit

let button = UIButton(type: .system)
button.setTitle("Bordered Button", for: .normal)
button.setTitleColor(.systemBlue, for: .normal)
button.backgroundColor = .clear

// Layer border and corner radius styling
button.layer.cornerRadius = 8.0
button.layer.borderWidth = 1.0
button.layer.borderColor = UIColor.systemBlue.cgColor

// Prevent content overflow
button.clipsToBounds = true

Remember to call .cgColor on your UIColor instance. If your button contains subviews or background images that extend beyond the rounded perimeter, set button.clipsToBounds = true (or button.layer.masksToBounds = true).


3. Support Dark Mode with dynamic CALayer border colors

Because CALayer.borderColor holds an unmanaged CGColorRef, it does not automatically re-evaluate dynamic colors when the user switches between Light Mode and Dark Mode. If you assign a dynamic color (like UIColor.label.cgColor or a color from an asset catalog), the border will stay stuck in the theme that was active when the button was instantiated.

To resolve this on iOS 17 and later, register for trait changes:

class BorderedButton: UIButton {
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupBorder()
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setupBorder()
    }

    private func setupBorder() {
        layer.cornerRadius = 8.0
        layer.borderWidth = 1.0
        updateBorderColor()

        // Update automatically when the user interface style changes
        registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (self: Self, _) in
            self.updateBorderColor()
        }
    }

    private func updateBorderColor() {
        layer.borderColor = UIColor.label.cgColor
    }
}

For iOS 16 and earlier, override traitCollectionDidChange(_:) inside your UIButton or containing UIViewController:

override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
    super.traitCollectionDidChange(previousTraitCollection)
    if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) {
        button.layer.borderColor = UIColor.label.cgColor
    }
}

4. Configure borders in Interface Builder using @IBInspectable

If you build interfaces using Storyboards or XIB files, Xcode’s Identity Inspector allows you to set User Defined Runtime Attributes. However, CALayer.borderColor does not work natively in Interface Builder because Xcode cannot convert a storyboard color into a CGColor at runtime.

You can bridge this gap by adding an extension or custom subclass exposing @IBInspectable properties:

import UIKit

@IBDesignable
class InspectableBorderButton: UIButton {

    @IBInspectable var cornerRadius: CGFloat {
        get { layer.cornerRadius }
        set { layer.cornerRadius = newValue }
    }

    @IBInspectable var borderWidth: CGFloat {
        get { layer.borderWidth }
        set { layer.borderWidth = newValue }
    }

    @IBInspectable var borderColor: UIColor? {
        get {
            guard let color = layer.borderColor else { return nil }
            return UIColor(cgColor: color)
        }
        set {
            layer.borderColor = newValue?.cgColor
        }
    }
}

Assign this class to your button in the Identity Inspector. You can then edit Border Width, Corner Radius, and Border Color directly inside the Attributes Inspector, with Xcode rendering the rounded border live in your canvas.


5. SwiftUI alternative: Using .overlay with RoundedRectangle

If you are working in SwiftUI or migrating UIKit components, styling a button with an outline border is straightforward using view modifiers:

import SwiftUI

struct BorderedButtonView: View {
    var body: some View {
        Button(action: {
            print("SwiftUI button tapped")
        }) {
            Text("Outline Button")
                .font(.headline)
                .foregroundColor(.blue)
                .padding(.horizontal, 16)
                .padding(.vertical, 8)
                .background(
                    RoundedRectangle(cornerRadius: 8)
                        .stroke(Color.blue, lineWidth: 1)
                )
        }
    }
}

In SwiftUI, RoundedRectangle(cornerRadius:).stroke(...) automatically manages layout, clipping, and dynamic color adjustments for both appearance modes without needing manual trait observation.


How to prevent it

  • Prefer UIButton.Configuration on modern iOS: Use UIButton.Configuration.plain() and set config.background.strokeColor and config.background.cornerRadius. This prevents state bugs and eliminates manual CGColor conversions.
  • Avoid mixing layer styles with configurations: Do not set button.layer.cornerRadius if you have also applied a UIButton.Configuration; configure the background property on the configuration instead.
  • Always update CGColor on theme changes: When using CALayer.borderColor with dynamic system colors, listen for trait changes (registerForTraitChanges or traitCollectionDidChange) so the border responds to Dark Mode toggles.
  • Apply clipping when filling backgrounds: If your button contains an internal background color or image, enable clipsToBounds = true so internal content does not bleed past your rounded border corners.

FAQ

Why doesn’t layer.borderColor accept a UIColor directly?

CALayer belongs to Core Animation, an underlying framework shared across macOS and iOS that uses Core Graphics types. Because CALayer is platform-agnostic, its borderColor property accepts a CGColorRef (CGColor) rather than UIKit’s platform-specific UIColor object.

Why does my button border disappear when pressed on iOS 15+?

If you set button.layer.borderColor on a button configured with UIButton.Configuration, UIKit’s internal configuration update cycle redraws the button’s background during touches, wiping out manual layer overrides. Use config.background.strokeColor instead.

How do I make a button completely pill-shaped (capsule)?

In modern UIKit, set config.cornerStyle = .capsule on your UIButton.Configuration. In classic CALayer code, set button.layer.cornerRadius = button.bounds.height / 2 inside layoutSubviews so the radius always tracks half the button’s current height.

Sources & credits

Parts of this guide are adapted from content licensed under CC BY-SA 4.0; this article is shared under the same license.