A Swift package that renders real-time glass effects using Metal shaders. Drop it onto any SwiftUI view to get frosted glass with refraction, chromatic dispersion, rim lighting, and device-reactive highlights. Multiple glass views can merge seamlessly into a single flowing surface using the goo system.
Runs on macOS 14+, iOS 17+, and visionOS 1+.
Add CrystalKit to your project via Swift Package Manager:
dependencies: [
.package(url: "https://github.com/omnideaco/CrystalKit.git", from: "0.2.0")
]Or in Xcode: File > Add Package Dependencies, paste the repo URL, and pick the version.
import SwiftUI
import CrystalKit
struct ContentView: View {
var body: some View {
Text("Hello, Glass")
.padding()
.crystalGlass()
}
}That's it. The modifier captures the backdrop behind the view, blurs it, and composites a glass material with refraction, rim lighting, and edge effects.
CrystalKit ships with four presets:
.crystalGlass(.regular) // Default everyday glass
.crystalGlass(.clear) // Transparent, subtle refraction
.crystalGlass(.subtle) // Barely-there frosting
.crystalGlass(.frosted) // Heavy blur, strong refractionOr build your own:
.crystalGlass(CrystalGlassStyle(
frost: 0.7, // 0 = clear, 1 = fully frosted
refraction: 0.4, // Edge warping strength
dispersion: 0.2, // Chromatic color separation
lightIntensity: 0.8 // Rim light brightness
))| Property | Range | Default | Description |
|---|---|---|---|
frost |
0–1 | 0.5 | Background blur intensity. 0 = clear, 1 = fully frosted. |
refraction |
0–1 | 0.35 | How much background content bends at the glass edges. |
dispersion |
0–1 | 0.15 | Chromatic color separation (rainbow fringing) at edges. |
depth |
0–1 | 0.0 | Magnification zoom through the glass lens. |
splay |
0–1 | 0.0 | Radial barrel distortion of the background. |
lightIntensity |
0–1 | 0.6 | Rim light brightness and edge extent. |
lightBanding |
0–1 | 0.5 | Rim light gradient falloff. 0 = sharp cutoff, 1 = soft fade. |
lightRotation |
0–1 | 0.6 | Light source angle. 0 = top, 0.25 = right, 0.5 = bottom, 0.75 = left. |
edgeWidth |
0–1 | 0.05 | How far refraction extends inward from shape edges. |
resonance |
Bool | false | Adaptive tinting — dark backgrounds tint lighter, light backgrounds tint darker. |
luminance |
Bool | false | Inner light/shadow driven by backdrop luminance. Bright regions glow, dark regions deepen. |
brillianceSources |
[SIMD2] | [] | World-space light positions for lens flare. See Brilliance. |
tintColor |
Color? | nil | Optional color overlay on the glass surface. |
tintOpacity |
0–1 | 0.0 | Tint blend amount. Only applies when tintColor is set. |
variant |
.regular / .clear | .regular | Glass material variant. Clear reduces light intensity to 70%. |
cornerRadius |
CGFloat? | nil | Override corner radius. nil = inferred from clipping shape. |
lightSource |
.fixed / .cursor(...) | .fixed | Light tracking mode. See Light Tracking. |
Glass clips to standard SwiftUI shapes, built-in descriptors, or any custom path via SDF textures:
// SwiftUI shapes
.crystalGlass(.regular, in: Capsule())
.crystalGlass(.regular, in: Circle())
.crystalGlass(.regular, in: Ellipse())
.crystalGlass(.regular, in: RoundedRectangle(cornerRadius: 16))
// Polygons and stars
.crystalGlass(.regular, shape: .polygon(sides: 6, cornerRadius: 4))
.crystalGlass(.regular, shape: .star(points: 5, innerRadius: 0.4))
// Per-corner radii with continuous smoothing
.crystalGlass(.regular, shape: .roundedRect(
cornerRadii: .init(topLeft: 20, topRight: 20, bottomRight: 8, bottomLeft: 8),
smoothing: 0.6
))
// Custom paths — provide a pre-computed SDF texture for any arbitrary shape
.crystalGlass(.regular, shape: .custom(sdfTexture: mySdfTexture, padding: 8))| Shape | Parameters | Description |
|---|---|---|
.roundedRect |
cornerRadii: CornerRadii, smoothing: CGFloat |
Per-corner radii with optional continuous corner smoothing (0–1). |
.capsule |
— | Pill shape (fully rounded ends). |
.circle |
— | Perfect circle. |
.ellipse |
— | Stretched circle. |
.polygon |
sides: Int, cornerRadius: CGFloat |
Regular polygon — triangle, pentagon, hexagon, etc. |
.star |
points: Int, innerRadius, outerRadius, cornerRadius, innerCornerRadius |
Star with control over inner/outer ratio and corner rounding. |
.custom |
sdfTexture: MTLTexture, padding: CGFloat |
Any arbitrary shape via a pre-computed SDF texture. |
The .custom case accepts a Metal SDF (signed distance field) texture, so you can render glass on any shape you can rasterize — bezier paths, boolean operations, hand-drawn outlines, anything.
Multiple glass views can organically merge into a single continuous glass surface — shapes that get close enough flow together like liquid mercury.
CrystalGooScope(radius: 80, style: .regular) {
HStack(spacing: 20) {
Circle()
.frame(width: 60, height: 60)
.crystalGlass()
.crystalGoo()
RoundedRectangle(cornerRadius: 12)
.frame(width: 100, height: 50)
.crystalGlass(.frosted)
.crystalGoo()
Text("Standalone")
.crystalGlass() // no .crystalGoo() — renders as normal glass
}
}CrystalGooScope watches all child views tagged with .crystalGoo(). It groups them by proximity using a union-find — any two opted-in views whose expanded frames (by radius) overlap are merged into one group. Each group is rendered as a single Metal layer. Inside that layer, shapes blend together using smooth-min SDF blending (the same technique used in shader-based metaballs).
Non-goo views (no .crystalGoo()) render as normal standalone glass and are unaffected by the scope.
| Parameter | Default | Description |
|---|---|---|
radius |
80 | Distance in points at which two goo views start merging. |
smoothK |
40 | Smooth-min blending radius. Larger values produce wider, rounder bridges. |
style |
.regular |
Glass style applied to the merged surface. |
effects |
[] |
Interaction effects for glass-to-glass physics. See Interactions. |
Use .crystallize() instead of .crystalGoo() to render a view through the goo pipeline's shared backdrop without merging it with neighbors:
Text("Solo")
.crystalGlass(.frosted)
.crystallize() // Shared backdrop, but doesn't merge with neighborsCrystalScope wraps a single glass view in the goo pipeline when you don't need merging:
CrystalScope {
Text("Smooth")
.crystalGlass()
.crystallize()
}When implementing draggable glass views inside a goo scope, report the drag offset so the Metal renderer can track the view's position in real time:
.crystalGooOffset(dragOffset)When glass views render at different z-layers, CrystalKit automatically composites them in multiple passes. Higher-layer glass refracts through lower-layer glass, creating a realistic layered depth effect. This happens automatically — no configuration needed.
CrystalStylesheet provides CSS-like theming across your app. Define a base glass style and let semantic roles (panel, sidebar, control bar, etc.) automatically apply appropriate variations:
let stylesheet = CrystalStylesheet()
stylesheet.base = CrystalGlassStyle(frost: 0.5, refraction: 0.3)
// Apply to your view tree
MyApp()
.crystalStylesheet(stylesheet)Then use roles instead of explicit styles:
// Automatically gets role-appropriate style from the stylesheet
.crystalGlass(.controlBar)
.crystalGlass(.sidebar, in: RoundedRectangle(cornerRadius: 12))
.crystalGlass(.overlay)| Role | Treatment |
|---|---|
.panel |
Reference — uses base style unchanged. |
.controlBar |
+frost, -refraction. Dense interactive strips. |
.sidebar |
+frost, -refraction. Persistent navigation. |
.tile |
-frost, +refraction. Small repeated elements. |
.overlay |
+frost, +depth. Floating popovers. |
Override any role explicitly:
stylesheet.overrides[.sidebar] = CrystalGlassStyle(frost: 0.9, refraction: 0.1)Or subclass CrystalStylesheet and override delta(for:) for full control over the cascade.
Add light sources to create lens flare effects — ghost orbs, spectral shimmer, and adaptive per-light tinting:
var style = CrystalGlassStyle.regular
style.brillianceSources = [
SIMD2<Float>(200, 100), // Light source at (200, 100) in view coordinates
SIMD2<Float>(400, 300), // Second light source
]
Text("Brilliant")
.crystalGlass(style)Brilliance responds to other style properties — frost softens the flare, depth modulates ghost intensity, and resonance enables adaptive per-light tinting from the backdrop. Up to 4 light sources per shape.
Glass views inside a CrystalGooScope can interact physically — attracting, bouncing, and changing material when they overlap:
CrystalGooScope(
radius: 80,
style: .regular,
effects: [
.springAttract(strength: 0.8), // Pull overlapping panels together
.bounce(strength: 0.5), // Impulse on collision
.autoFrost(intensity: 0.3), // Front panel frosts when overlapping
.promoteOnDrag(), // Dragged panel rises to top
]
) {
// Draggable glass views here
}| Effect | Description |
|---|---|
.springAttract(strength:range:) |
Spring force pulls overlapping panels together. |
.bounce(strength:range:) |
Velocity impulse when panels collide. |
.autoFrost(intensity:range:) |
Front panel frosts when overlapping another. |
.promoteOnDrag() |
Dragged panel rises above others in z-order. |
Effects are zero-overhead when the array is empty. They only run when interactions are declared.
Conform to CrystalInteractionEffect for custom physics:
struct MyEffect: CrystalInteractionEffect {
var interactionRange: CGFloat { 100 }
func evaluate(context: CrystalInteractionContext) -> CrystalInteractionResult {
// context gives you: distance, overlap, direction, closingSpeed, dt
// return: forces, impulses, style modifications, z-promotion
.init(forceA: .zero, forceB: .zero)
}
}Glass can react to cursor position (macOS), device tilt (iPhone), or Apple Pencil hover (iPad):
var style = CrystalGlassStyle.regular
style.lightSource = .cursor()
Text("Hover me")
.crystalGlass(style)The .cursor() case accepts two optional parameters:
| Parameter | Default | Description |
|---|---|---|
falloffRadius |
300 | Distance in points where light fades to baseIntensity. nil = angle-only. |
baseIntensity |
0.3 | Minimum light intensity when far away (0–1). |
- macOS: Tracks mouse cursor position.
- iPhone: Tracks device gravity vector (tilt the phone, light moves). Uses a One Euro Filter for smooth, jitter-free output.
- iPad: Tracks Apple Pencil or trackpad hover position. Enable with
.crystalHoverTracking().
// iPad hover
MyContent()
.crystalHoverTracking()No ARKit, no camera permissions. Tilt tracking uses CoreMotion (accelerometer), hover tracking uses SwiftUI's built-in .onContinuousHover.
CrystalKit samples the backdrop luminance and automatically sets the child view's foreground style to white (dark backgrounds) or black (light backgrounds). Read the value yourself via the environment:
@Environment(\.crystalGlassLuminance) var luminanceBy default, CrystalKit captures the content behind the view using CALayer.render. For apps that render their own content (canvas apps, games, custom backgrounds), you can supply the backdrop directly to avoid any capture overhead:
The easiest approach — render your background as a SwiftUI view, snapshot it to a CGImage, and hand it to CrystalKit:
@State private var provider = CrystalImageBackdropProvider()
var body: some View {
ZStack {
MyBackground()
.onGeometryChange(for: CGSize.self, of: \.size) { size in
let renderer = ImageRenderer(content:
MyBackground().frame(width: size.width, height: size.height)
)
renderer.scale = NSScreen.main?.backingScaleFactor ?? 2.0
if let image = renderer.cgImage {
provider?.update(with: image, pointSize: size)
}
}
MyGlassContent()
}
.crystalBackdrop(provider)
}For full control, conform to CrystalBackdropProvider:
class MyProvider: CrystalBackdropProvider {
func backdropTexture(for rect: CGRect, scale: CGFloat) -> MTLTexture? {
// Return your Metal texture cropped to rect
}
}
MyView()
.crystalBackdrop(myProvider)SDFTextureGenerator is the GPU compute pipeline that converts a rasterized mask texture into the SDF texture required by the .custom shape case and the goo group renderer.
// Create once — compiles the GPU kernels
let generator = SDFTextureGenerator(device: metalDevice)
// Call per-shape whenever the path changes
// maskTexture: any RGBA texture whose .a channel defines the shape boundary
let sdfTexture = generator?.generateSDF(from: maskTexture, commandBuffer: commandBuffer)
// Pass to CrystalKit
.crystalGlass(.regular, shape: .custom(sdfTexture: sdfTexture!, padding: 8))The returned .rgba16Float texture stores:
| Channel | Contents |
|---|---|
.r |
Signed distance in texels. Negative inside, positive outside, zero on the boundary. |
.a |
Smooth height field (Jacobi solution to Laplace's equation). Zero at the boundary, rising smoothly toward the interior center. Used for refraction normals — no medial axis seams. |
- Seed — identifies boundary pixels (alpha transitions in the mask).
- Flood — runs ceil(log2(max(W, H))) Jump Flood Algorithm passes to propagate nearest-boundary coordinates to every pixel.
- Distance extraction — converts Voronoi result to signed distance per pixel.
- Jacobi heat iteration — solves nabla-squared h = 0 inside the shape (boundary pinned at 0, seeded with interior SDF distances). Produces a smooth dome with no medial axis discontinuities.
- Compose — merges the signed distance (
.r) and height field (.a) into the final texture.
The number of Jacobi iterations scales automatically with texture size (64 for <=512 px, up to 256 for larger shapes).
| Platform | Minimum |
|---|---|
| macOS | 14.0 |
| iOS | 17.0 |
| visionOS | 1.0 |
| Swift | 6.2 |
Requires a Metal-capable GPU (all supported platforms have one).
Found a bug? Have a feature request, improvement idea, or refinement suggestion? Feel free to:
- Open an issue
- Start a discussion
- Email me directly at rcmjr@omnidea.co
MIT. See LICENSE for details.
