By designers, for developers.
ColorTokensKit is a powerful design library that extends Swift's native capabilities by offering ergonomic access to the LCH color system, and 100's of pre-defined colors built using the LCH color system.
- What are design tokens?
- How does LCH work?
- Simple Example
- Setting them up
- Going beyond the basics
- Sample Application
- Future ideas
- License
Design tokens are the fundamental building blocks of a design system. They represent the smallest, atomic decisions in your UI, such as colors, typography, spacing, and more. In the context of ColorTokensKit, we focus on color tokens. These tokens allow designers and developers to maintain consistency across an application, making it easier to update and maintain the visual language of your iOS app.
Imagine you have a primary color used for your brand. This color is used in various levels of brightness and saturation in various areas (backgrounds, text, hovers, buttons, onpress states etc). Instead of hardcoding each of the color values in multiple places, you define a design token named brandColor
. Now, whenever the brand color needs to be used, you just use brandColor.backgroundPrimary
. If it needs to change, you update just one token value, and all instances of brandColor
in your app automatically update. Design tokens ensure consistency and make it easier to maintain and update your design system.
Text("Hello to ColorTokensKit")
.background(Color(red: 0.5, green: 0.5, blue: 1.0)) // Messy: Defining colors inline
.background(Color.brandColorBackground) // Custom Variables: Often hardcoded. Changing various values associated with brandColor is hard and impractical.
.background(Color.brandColor.backgroundPrimary) // Design Tokens: Semantic naming that enables reusabusability, predictability and enables accessible colors.
Behind the scenes, brandColor
uses an LCH color system to get a specific color. It gets the "hue" value from brandColor
and calculates an accessible color ramps based on a few defined primitives.
The LCH (Lightness, Chroma, Hue) color system offers significant advantages over RGB and HSL based initializers. LCH is "perceptually uniform", meaning changes in color values correspond more closely to how humans perceive color differences. This makes it easier to create harmonious color palettes, ensure proper contrast for accessibility, and make predictable color adjustments. Unlike RGB or HSL, LCH also supports a wider gamut of colors and provides more intuitive control over color properties, making it an excellent choice for modern iOS app development.
[Insert image]
Text("Hello to ColorTokensKit")
.background(Color("#8080FF")) // HEX: Handpicked colors that aren't easy to scale to various usecases. Lots of room for error
.background(Color(red: 0.5, green: 0.5, blue: 1.0)) // RGB: Inconsistent brightness across colors
.background(Color(h: 0.24, s: 0.32, l: 0.44)) // HSL: Inconsistent color intensity. Some colors like yellow are brighter than others, making it hard to read
.background(Color(l: 0.32, c: 0.44, h: 0.9)) // LCH: Perceptually uniform; consistent brightness and saturation across hues
Let's create a simple container with a name and a subtitle. An extension on Color
offers ready to use design tokens with a Color.gray
color ramp
You can use the pre-defined color tokens below (like Color.backgroundPrimary
, Color.foregroundTertiary
, Color.outlinePrimary
), or create custom ones to your needs. The library integrates seamlessly with SwiftUI and UIKit, allowing you to use color tokens in your views and UI components with minimal effort.
import ColorTokensKit
VStack {
Text("Title")
.foregroundStyle(Color.foregroundPrimary) // Uses the darkest text color available
Text("Subtitle")
.foregroundStyle(Color.foregroundSecondary) // Since it's a secondary piece of text, it uses a lighter shade available
}
.background(
RoundedRectangle(cornerRadius: 16) // Creates a rounded rectangle container that works in light & dark mode
.fill(Color.backgroundPrimary) // Uses `backgroundPrimary` as its base, resulting in a white background
.stroke(Color.outlineTertiary, lineWidth: 1) // Uses the lightest gray outline for a border
)
Copy this into a new extension file called LCHColor+Ext
.
import ColorTokensKit
public extension LCHColor {
// Foreground colors
var foregroundPrimary: Color { Color(light: _100, dark: _0_pastel) }
var foregroundSecondary: Color { Color(light: _80, dark: _20_pastel) }
var foregroundTertiary: Color { Color(light: _65, dark: _35_pastel) }
// Inverted foreground colors
var invertedForegroundPrimary: Color { Color(light: _0, dark: _100_pastel) }
var invertedForegroundSecondary: Color { Color(light: _10, dark: _90_pastel) }
var invertedForegroundTertiary: Color { Color(light: _20, dark: _80_pastel) }
// Background colors
var backgroundPrimary: Color { Color(light: _0, dark: _100_pastel) }
var backgroundSecondary: Color { Color(light: _5, dark: _90_pastel) }
var backgroundTertiary: Color { Color(light: _20, dark: _80_pastel) }
// Surface colors
var surfacePrimary: Color { Color(light: _40, dark: _60).opacity(0.5) }
var surfaceSecondary: Color { Color(light: _40, dark: _60).opacity(0.3) }
var surfaceTertiary: Color { Color(light: _40, dark: _60).opacity(0.2) }
// Inverted background colors
var invertedBackgroundPrimary: Color { Color(light: _90, dark: _10) }
var invertedBackgroundSecondary: Color { Color(light: _75, dark: _25) }
var invertedBackgroundTertiary: Color { Color(light: _60, dark: _40) }
// Outline colors
var outlinePrimary: Color { Color(light: _50, dark: _30_pastel) }
var outlineSecondary: Color { Color(light: _30, dark: _50_pastel) }
var outlineTertiary: Color { Color(light: _10, dark: _70_pastel) }
}
Now, create your Default Gray Ramps as an extension to Color. Copy this into a new file called Color+Ext.swift
.
import ColorTokensKit
extension Color {
// Foreground colors
public static var foregroundPrimary: Color {
.gray.foregroundPrimary
}
public static var foregroundSecondary: Color {
.gray.foregroundSecondary
}
public static var foregroundTertiary: Color {
.gray.foregroundTertiary
}
// Inverted colors
public static var invertedForegroundPrimary: Color {
.gray.invertedForegroundPrimary
}
public static var invertedForegroundSecondary: Color {
.gray.invertedForegroundSecondary
}
public static var invertedForegroundTertiary: Color {
.gray.invertedForegroundTertiary
}
// Background colors
public static var backgroundPrimary: Color {
Color(light: .white, dark: .black) // Pure black and white
}
public static var backgroundSecondary: Color {
.gray.backgroundSecondary
}
public static var backgroundTertiary: Color {
.gray.backgroundTertiary
}
// Inverted background colors
public static var invertedBackgroundPrimary: Color {
Color(light: .black, dark: .white) // Pure black and white
}
public static var invertedBackgroundSecondary: Color {
.gray.invertedBackgroundSecondary
}
public static var invertedBackgroundTertiary: Color {
.gray.invertedBackgroundTertiary
}
// Inverted surface colors
public static var surfacePrimary: Color {
.gray.surfacePrimary
}
public static var surfaceSecondary: Color {
.gray.surfaceSecondary
}
public static var surfaceTertiary: Color {
.gray.surfaceTertiary
}
// Outline colors
public static var outlinePrimary: Color {
.gray.outlinePrimary
}
public static var outlineSecondary: Color {
.gray.outlineSecondary
}
public static var outlineTertiary: Color {
.gray.outlineTertiary
}
}
Now that we have our design token system, and the basic gray ramp set up, you can create additional tokens for your brand colors, or any primary/secondary colors you have.
import ColorTokensKit
public extension Color {
// Your main brand color can be defined by HEX, RGB, HSL or any other system
public static var brandColor: LCHColor {
LCHColor(hex: "#F12E53")
}
// Setup any additional secondary colors you need
public static var positive: LCHColor {
Color.proGreen
}
// For example, this one is created for destructive, negative moments like errors etc.
public static var negative: LCHColor {
Color.proRed
}
}
We're all set up. You're ready to start using them in code. You can just use the extension on Color
for themeless gray colors. OR, you can even pass themes to your components for a more powerful setup.
import ColorTokensKit
struct ContainerComponentView: View {
let title: String
let subtitle: String
var body: some View {
VStack {
Text(title)
.foregroundStyle(Color.foregroundPrimary) // Uses the darkest text color available
Text(subtitle)
.foregroundStyle(Color.foregroundSecondary) // Since it's a secondary piece of text, it uses a lighter shade available
}
.background(
RoundedRectangle(cornerRadius: 16) // Creates a rounded rectangle container that works in light & dark mode
.fill(Color.backgroundPrimary) // Uses `backgroundPrimary` as its base, resulting in a white background
.stroke(Color.outlineTertiary, lineWidth: 1) // Uses the lightest gray outline for a border
)
}
}
Theming is made extremely ergonomic with this approach. You can pass values theme values as needed, and all children elements are dynamically assigned colors depending on the LCH color chosen.
import ColorTokensKit
import SwifTUI
struct ContentView: View {
var body: some View {
CardView() // Themeless
CardView(theme: Color.proMint) // Mint theme
CardView(theme: Color.proBlue) // Blue theme
CardView(theme: Color.proGold) // Gold theme
CardView(theme: Color.proRuby) // Ruby theme
CardView(theme: LCHColor("#abcdef")) // Custom theme based on hex values
// We have 22 pre-made `pro` colors available.
}
}
struct CardView: View {
let title: String
let subtitle: String
let theme: LCHColor // This could very well be an `@Environment var`. Whatever floats your boat.
init(
title: String,
subtitle: String,
theme: LCHColor = Color.proGray // Default component could be themeless, or have a default hue
) {
self.title = title
self.subtitle = subtitle
self.theme = theme
}
var body: some View {
VStack {
Text(title)
.foregroundStyle(theme.foregroundPrimary) // Uses the darkest text color available
Text(subtitle)
.foregroundStyle(theme.foregroundSecondary) // Since it's a secondary piece of text, it uses a lighter shade available
}
.background(
RoundedRectangle(cornerRadius: 16) // Creates a rounded rectangle container that works in light & dark mode
.fill(theme.backgroundPrimary) // Uses `backgroundPrimary` as its base, resulting in a white background
.stroke(theme.outlineTertiary, lineWidth: 1) // Uses the lightest gray outline for a border
)
}
}
Some things just don't look good in dark mode. In that case, you can easily select a different design token for it.
For example, this approach below allows light mode to have themed green text, whereas dark mode would have dark gray text. The benefit of using the LCH system is that they'll offer the same levels of lightness to keep your UI looking beautiful.
import SwiftUI
struct CardView: View {
@Environment(\.colorScheme) var colorScheme
var body: some View {
Text("Hello World")
.foregroundStyle(colorScheme == .light ? Color.positive.foregroundPrimary : Color.foregroundPrimary)
}
}
If the existing tokens aren't enough for you app, you can always define new ones. Here, we define additional tokens for
// This setup would enable colors of all hues to have these values
public extension LCHColor {
// Container tokens
var containerPrimary: Color { Color(light: _100, dark: _0_pastel) }
var containerSecondary: Color { Color(light: _80, dark: _20_pastel) }
// Shadow tokens
var shadowPrimary: Color { Color(light: _30.opacity(0.5), dark: _70_pastel.opacity(0.5)) }
var shadowSecondary: Color { Color(light: _30.opacity(0.3), dark: _70_pastel.opacity(0.3)) }
}
// This setup enables an extension on Color, so you don't need to refer to an LCHColor when you just want to use your grays
public extension Color {
// Default container tokens
public static var containerPrimary: Color {
.gray.containerPrimary
}
public static var containerSecondary: Color {
.gray.containerSecondary
}
// Default shadow tokens
public static var shadowPrimary: Color {
.gray.shadowPrimary
}
public static var shadowSecondary: Color {
.gray.shadowSecondary
}
}
struct CardView: View {
var body: some View {
VStack {
Text("Hello World")
.foregroundStyle(Color.foregroundPrimary)
}
.background(Color.containerPrimary)
.shadow(color: Color.shadowPrimary, radius: 4, y: 2) // Gives a nice consistent shadow with or without theme colors
}
}
Transitioning colors using LCH offer much smoother color values.
let color1 = LCHColor(l: 40, c: 30, h: 60)
let color2 = LCHColor(l: 60, c: 60, h: 90)
let interpolatedColor = color1.lerp(color2, t: 0.5)
print(interpolatedColor) // Output: LCHColor(l: 50, c: 45, h: 75)
ColorKit allows you to convert between different color types - RGB, HSL, HEX, LCH, LAB, OKLAB, OKLCH etc. Here are some simple examples:
// HEX to Color
let color = Color(hex: "#abcdef")
// RGB to LCH Color
let rgbColor = Color(r: 0.5, g: 0.4, b: 0.3, alpha: 1.0)
let lchColor = LCHColor(color: rgbColor)
// HEX to LCH Color
let lchColor = LCHColor(hex: "#abcdef")
// HSL to LCH Color
let hslColor = Color(h: 50, s: 50, l: 50)
let lchColor = LCHColor(color: hslColor)
// LCH to RGB
let lchColor = LCHColor(l: 40, c: 30, h: 60)
let rgbColor: color = lchColor.toRGB()
// LCH to HEX
let lchColor = LCHColor(l: 40, c: 30, h: 60)
let hexColor: String = lchColor.toHex()
// LCH to UIColor
let lchColor = LCHColor(l: 42.33, c: 29.65, h: 59.53, alpha: 1.0)
let uiColor = lchColor.toColor()
print(uiColor) // Output: UIDeviceRGBColorSpace 0.5 0.4 0.3 1
Open ColorGenerator.xcproject
from the File Explorer to explore the spectrum of colors.
[insert image of the application]
- [] Offer
.lighten()
,.darken()
,.saturate()
and.desaturate()
for LCH Colors - [] Create smooth gradients using LCH colors
- [] Add resource links to Read Me
- [] Basic Unit Tests
- [] UI Snapshot Tests
- [] Example Figma
- [] Custom Lightness, Chroma or Hue curves
- [] Any other feedback?
The source code for the site is licensed under the MIT license, which you can find in the MIT-LICENSE.txt file.