color-thief reimagined for Apple — a modern, iOS-native palette extractor with a built-in palette-driven graphic primitive. Swift Package, SwiftUI- and UIKit-friendly: OKLCH perceptual quantization, Display P3 wide-gamut support, Semantic Swatches, async-only Sendable API.
Demo app: palette extraction → PaletteGraphic, on two different photos
Demo app: palette extraction → MeshGraphic & AnimatedGraphic
// Package.swift
dependencies: [
.package(url: "https://github.com/2dubu/PaletteKit", from: "2.1.0"),
]Minimum iOS 17 · Swift 6.0 · Xcode 16+. For on-device naming and summaries,
also add the optional PaletteKitInsights product (iOS 26+).
import PaletteKit
import SwiftUI
let extractor = PaletteExtractor()
let palette = try await extractor.palette(from: .data(imageData))
let swatches = try await extractor.swatches(from: .data(imageData))
Rectangle()
.fill(palette.dominant ?? .black)
Text("Hello")
.foregroundStyle(swatches.titleTextColor(for: .vibrant, fallback: .black))import PaletteKit
import UIKit
let extractor = PaletteExtractor()
let palette = try await extractor.palette(from: .data(imageData))
let swatches = try await extractor.swatches(from: .data(imageData))
view.backgroundColor = UIColor(palette.dominant ?? .black)
label.textColor = UIColor(swatches.titleTextColor(for: .vibrant, fallback: .black))import PaletteKit
import SwiftUI
let configuration = PaletteGraphic.Configuration(
direction: .linear,
colorCount: .three,
swatchStrategy: .vibrant,
grain: .standard
)
PaletteGraphic(palette: palette, swatches: swatches, configuration: configuration)
.frame(width: 320, height: 320)
.clipShape(RoundedRectangle(cornerRadius: 24))UIKit: PaletteGraphicView (UIView, no UIHostingController). For a static
UIImage, use PaletteGraphic.makeImage(size:scale:) or
PaletteGraphicView.snapshotImage(scale:).
AsyncPaletteGraphic extracts the palette internally and shows your
placeholder while loading — no explicit PaletteExtractor step.
import PaletteKit
import SwiftUI
AsyncPaletteGraphic(image: .url(url)) {
Color.gray.opacity(0.1) // shown during loading and on failure
}
.frame(width: 320, height: 320)
.clipShape(RoundedRectangle(cornerRadius: 24))UIKit pair: AsyncPaletteGraphicView. Both share a process-wide
PaletteCache.shared. See Loading palettes
asynchronously
for caching, transitions, and error handling.
AnimatedPaletteGraphic renders the palette as a slowly morphing "living
gradient" — a multi-point, LAB-blended flow with organic, non-periodic motion.
It fills its frame; clip to any shape.
AnimatedPaletteGraphic(
palette: palette,
configuration: .init(
colorCount: .three, // .two ... .five
speed: .regular, // .slow / .regular / .fast
isAnimated: true
)
)
.frame(width: 320, height: 420)
.clipShape(RoundedRectangle(cornerRadius: 24))UIKit pair: AnimatedPaletteGraphicView. Both honor Reduce Motion and Low
Power Mode (hold a static frame) and pause while off-screen.
PaletteInsightsGenerator (iOS 26+, PaletteKitInsights module) names a
palette and writes a one-line summary with the on-device FoundationModels
model — no network. With the product added:
import PaletteKit
import PaletteKitInsights
let palette = try await PaletteExtractor().palette(from: image)
let generator = PaletteInsightsGenerator()
guard generator.isAvailable, generator.supportsLocale() else { return } // fallback otherwise
let insights = try await generator.insights(
for: palette,
guidance: "warm and nostalgic" // optional
)
print(insights.name) // e.g. "Faded Coastline"
print(insights.summary) // one sentence, in the device language- Async, Sendable, Swift 6 strict concurrency. Every entry point is
async throws.PaletteExtractoris a value type — one per call site or shared across actors. - Palette-driven graphic primitive.
PaletteGraphic(SwiftUI) andPaletteGraphicView(UIKit) render gradient + grain artwork from any palette via a shared Core Image / Core Graphics renderer — pixel-equivalent across platforms,NSCache-memoized, configurable along four axes (direction, color count, swatch strategy, grain). - Rich
PaletteColor. hex, HSL, OKLCH, contrast,isDark/isLight, and ShapeStyle conformance for direct use in any SwiftUI fill / foreground / background modifier. - OKLCH perceptual quantization by default. Palettes feel evenly spaced to the eye, not in sRGB.
- Display P3 native. iPhone photos keep their chroma instead of clipping to sRGB.
- CPU by default, Metal opt-in.
MmcqQuantizer(CPU, Accelerate) is the default and what.autoselects.MetalMmcqQuantizer(GPU compute shader) is opt-in for raw mode on ≥4MP inputs (~5-10% quantize speedup). Bring your own via theQuantizerprotocol. - Automatic pre-downsampling keeps memory bounded for 12-megapixel photos.
- Semantic swatches. Six OKLCH roles (vibrant, muted, darkVibrant, darkMuted, lightVibrant, lightMuted) with accessible text-color recommendations.
- EXIF auto-orientation for real-world iPhone photos.
os.Logger+ signposts wired into Instruments' "Points of Interest".- Typed errors via
PaletteError.
extractor.dominantColor(from:) // PaletteColor?
extractor.palette(from:) // Palette
extractor.swatches(from:) // SwatchMapImageSource (.cgImage / .data / .url) and the full ExtractionOptions
surface (colorCount, quality, colorSpace, downsample, quantizer, …)
are documented in the
DocC reference.
Tip: Prefer .data(...) for HEIC/JPEG bytes already in memory or fetched
over the network — the data path skips file-system overhead (~17% faster than
.url(...) on iPhone 15 Pro for a 4MP HEIC). Use .url(...) for on-disk files
so the decoder can mmap them directly.
PaletteKit keeps palette colors in the source color space
(CGImage.colorSpace): Display P3 input → Display P3 output. OKLCH is used
only internally during quantization for perceptual uniformity.
let palette = try await extractor.palette(from: .url(hdrPhotoURL))
palette.colorSpaceUsed // .displayP3 on an iPhone HEIC, .sRGB elsewhereDefault (.auto) always uses CPU MMCQ. On-device measurements (iPhone 15
Pro, 4096² photos) put CPU and Metal within ≤4ms after auto-downsample, so
size-based routing added complexity without measurable wins. Metal helps in one
narrow band — raw mode + ≥4MP input, ~5-10% off quantize; use .metal only
with downsampling disabled.
| You want… | quantizer |
downsample |
Notes |
|---|---|---|---|
| A palette, no fuss | .auto |
default | The default. CPU + auto-downsample. |
| Maximum color accuracy | .cpu |
.disabled |
Process every pixel. Slowest, most accurate. |
| Accuracy + speed on large inputs | .metal |
.disabled |
≥4MP only. ~5-10% quantize win vs CPU raw. |
| Ensure work runs on GPU | .metal |
default | Falls back to CPU if Metal is unavailable. |
// Default — CPU with auto-downsample to ~1M pixels:
try await extractor.palette(from: source)
// Maximum accuracy — every pixel, CPU MMCQ:
try await extractor.palette(from: source,
options: ExtractionOptions(downsample: .disabled, quantizer: .cpu))
// Large-input accuracy + Metal (≥4MP raw):
try await extractor.palette(from: source,
options: ExtractionOptions(downsample: .disabled, quantizer: .metal))Metal warms up on first MetalContext use (shader compile + pipeline build);
later calls are steady-state. DEBUG builds log a hint if you select .metal
on input too small for the speedup to land.
let palette = try await extractor.palette(
from: .url(url),
options: ExtractionOptions(collectTimings: true)
)
palette.timings?.decode // Duration
palette.timings?.sample // Duration
palette.timings?.quantize // Duration
palette.timings?.total // Duration
palette.timings?.quantizerUsed // "MMCQ-CPU" or "MMCQ-Metal"Traces are annotated via os_signpost (com.paletteKit / pointsOfInterest) —
use the "Points of Interest" template for decode / sample / quantize intervals.
Full DocC catalog ships with the package:
PaletteKitreferenceGettingStarted.md·Options.md·PerformanceTuning.md·Card.mdAlgorithmDeepDive.md— MMCQ, OKLCH, Swatches internals
Build locally with xcodebuild docbuild or browse on
Swift Package Index.
Examples/PaletteKitDemo — a minimal SwiftUI app: photo-picker → palette grid
→ swatches → timings. Tap Generate Graphic on the result screen for the
Graphic Lab, an interactive playground for every configuration axis on your
actual extracted palette.
make setup # one-time: installs xcodegen via Homebrew if missing
make demo-app # generate PaletteKitDemo.xcodeproj and open it in XcodeSee Examples/PaletteKitDemo/README.md
for how the app is wired.
The demo app ships an on-device benchmark harness (speedometer icon, top-right): pick a real photo or the synthesized fixture, vary size / quantizer / downsample, Run, then Export per-stage timings (decode, sample, quantize) as CSV for cross-device comparison. It's primarily an internal discipline tool — every CPU/GPU change clears a measurement gate before it lands — so most apps don't need to run it.
- iOS 17+
- Xcode 16+
- Swift 6.0 (strict concurrency)
Thanks to color-thief by Lokesh Dhakar (MIT) — the MMCQ algorithm family, OKLCH-first quantization, and the six-role swatch layout shaped PaletteKit's direction. PaletteKit reimagines those ideas for iOS with a Metal compute histogram, Display P3 preservation, Swift 6 concurrency, and CGImageSource-based decoding, while keeping the algorithmic core compatible so outputs can be cross-verified against the reference.
MIT. See LICENSE.






