Skip to content

omnideaco/CrystalKit

Repository files navigation

CrystalKit

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.

CrystalKit glass effect demo

Runs on macOS 14+, iOS 17+, and visionOS 1+.

Installation

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.

Quick Start

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.

Styles

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 refraction

Or 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
))

Style Properties

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.

Shapes

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 Descriptors

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.

Glass Groups (Goo)

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
    }
}

How it works

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.

CrystalGooScope parameters

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.

Crystallize

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 neighbors

CrystalScope

CrystalScope wraps a single glass view in the goo pipeline when you don't need merging:

CrystalScope {
    Text("Smooth")
        .crystalGlass()
        .crystallize()
}

Drag offset

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)

Multi-pass glass-on-glass

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.

Stylesheet (Role-Based Theming)

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)

Built-in roles

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.

Brilliance (Lens Flare)

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.

Interaction Effects

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
}

Built-in effects

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.

Custom effects

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)
    }
}

Light Tracking

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).

Platform behavior

  • 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.

Background Luminance

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 luminance

Backdrop Provider

By 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:

CrystalImageBackdropProvider (recommended)

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)
}

Custom 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)

SDF Texture Generator

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.

How the pipeline works

  1. Seed — identifies boundary pixels (alpha transitions in the mask).
  2. Flood — runs ceil(log2(max(W, H))) Jump Flood Algorithm passes to propagate nearest-boundary coordinates to every pixel.
  3. Distance extraction — converts Voronoi result to signed distance per pixel.
  4. 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.
  5. 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).

Requirements

Platform Minimum
macOS 14.0
iOS 17.0
visionOS 1.0
Swift 6.2

Requires a Metal-capable GPU (all supported platforms have one).

Feedback

Found a bug? Have a feature request, improvement idea, or refinement suggestion? Feel free to:

License

MIT. See LICENSE for details.

About

Real-time liquid glass effects for SwiftUI — frosted blur, refraction, rim lighting, and cursor/gaze-reactive highlights. Powered by Metal.

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages