Why Your UIKit Border Colors Break in Dark Mode (And How to Fix It)
The hidden gotcha with CGColor that every iOS developer should know
The Bug
I had a custom segmented control that looked perfect in light mode.
White container, purple border on the selected tab, everything matching our Figma design.
Then QA tested dark mode.
The borders vanished.
// This looks correct, but it's broken
backgroundContainer.layer.borderColor = UIColor.style.l1Divider.cgColor
selectedIndicator.layer.borderColor = UIColor.style.b1BrandColor.cgColorThe Problem: CGColor Doesn’t Auto-Update
Here’s what most iOS developers don’t realize:
UIColor is dynamic. CGColor is not.
When you write:
view.layer.borderColor = UIColor.systemGray.cgColorThe .cgColor is evaluated once, at that exact moment, for the current trait collection.
When the system switches to dark mode:
UIColor.systemGraywould return a different color- But your
layer.borderColoris already set and won't update
This affects:
layer.borderColorlayer.shadowColorlayer.backgroundColor(when set via CALayer, not UIView)- Any other
CGColorproperty on CALayer
The Fix: traitCollectionDidChange
Override traitCollectionDidChange to manually update your CGColor values:
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
// Only update if the color appearance actually changed
if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) {
updateColors()
}
}
private func updateColors() {
// Re-evaluate UIColor and get new cgColor for current appearance
backgroundContainer.layer.borderColor = UIColor.style.l1Divider.cgColor
selectedIndicator.layer.borderColor = UIColor.style.b1BrandColor.cgColor
}Why hasDifferentColorAppearance?
traitCollectionDidChange is called for any trait change—size classes, layout direction, display scale, etc.
We only care about color changes.
hasDifferentColorAppearance(comparedTo:) specifically checks if:
userInterfaceStylechanged (light/dark)accessibilityContrastchanged (normal/high)userInterfaceLevelchanged (base/elevated)
Complete Example
final class CustomSegmentedControl: UIView {
private let backgroundContainer = UIView()
private let selectedIndicator = UIView()
private var buttons: [UIButton] = []
private var selectedIndex: Int = 0
// ... init and setup code ...
private func setupView() {
// Initial color setup
backgroundContainer.layer.borderWidth = 1
backgroundContainer.layer.borderColor = UIColor.style.l1Divider.cgColor
selectedIndicator.layer.borderWidth = 1
selectedIndicator.layer.borderColor = UIColor.style.b1BrandColor.cgColor
// ... rest of setup ...
}
// MARK: - Dark Mode Support
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) {
updateColors()
}
}
private func updateColors() {
// Update layer border colors
backgroundContainer.layer.borderColor = UIColor.style.l1Divider.cgColor
selectedIndicator.layer.borderColor = UIColor.style.b1BrandColor.cgColor
// Update button text colors for consistency
for (index, button) in buttons.enumerated() {
if index == selectedIndex {
button.setTitleColor(.style.b1BrandColor, for: .normal)
} else {
button.setTitleColor(.style.t3Body, for: .normal)
}
}
}
}This bug cost me 2 hours of debugging. Hopefully this article saves you that time.
Thank you for reading! If you enjoyed it, please consider clapping and following for more updates! 👏
