The surface above, in full:
import SurfaceForge
Surface(material: .gold, cornerRadius: 22, contentPadding: 20) {
VStack(alignment: .leading, spacing: 2) {
Text("MEMBER SINCE")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.tertiary)
Text("2019")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.secondary)
Spacer()
Text("GOLD")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.tertiary)
Text("4471 0982 3310")
.font(.system(size: 26, weight: .semibold))
.monospacedDigit()
}
}
.frame(width: 353, height: 220)Every line after Surface is plain SwiftUI. Size, placement, layout and spacing
are yours; the package only owns the surface and its light.
| Parameter | Default | |
|---|---|---|
material |
.gold |
What the surface is made of. |
cornerRadius |
22 |
Continuous, squircle corners. |
contentPadding |
20 |
Inset between your content and the surface's edge. |
Your content fills the surface's bounds first, and contentPadding insets it
after, so Spacer() and alignments reach the edges as you would expect.
Xcode. File → Add Package Dependencies, then paste:
https://github.com/AetherMaker/surface-forge
Package.swift.
.package(url: "https://github.com/AetherMaker/surface-forge", from: "0.4.0").gold .silver .roseGold .copper .brass .gunmetal
SurfaceMaterial.all // every built-in, in order. For a picker.
material.name // "Rose gold"
material.approximateColor // a flat Color for a swatch, not what the surface looks like
SurfaceMaterial.custom(tint: .init(red: 0.8, green: 0.9, blue: 1), name: "Platinum")A custom tint should be richer and more saturated than the metal you want.
The shader multiplies it by your content's own luminance, which both lifts and
desaturates it. Gold is stored as (1.00, 0.72, 0.22) and renders near
(214, 187, 114).
.surfaceLightOffset(0) // centred. -1 is the left edge, +1 the right
.surfaceGleam(1) // 0 is flat matte, 1 is the full reflectionBoth move all of a surface's lighting together, so the shading and the highlight never disagree about which side the light is on.
They propagate through the environment, and that is deliberate: two surfaces on screen are in the same room, so one lit from the left beside one lit from the right is a bug. Set either once, high in a tree.
.surfaceTiltSource(.deviceMotion) // follows the device
.surfaceTiltSource(.fixed(pitch: -15, roll: 20)) // a held anglePreviews and the Simulator have no motion data, so .deviceMotion holds the
resting reflection there. .fixed is how you see the material move without
hardware, and how you get a repeatable screenshot or test.
Every surface on screen reads the device once, through one shared source
that starts with the first and stops with the last. A ScrollView of them costs
what one costs.
Every material here is metallic, and metal keeps the lightness of your content and discards its colour. A blue element appears as the material's own colour at blue's brightness.
Use .primary, .secondary and .tertiary. They keep their relative hierarchy
through the material, so there is no colour API to learn.
Full-colour content is not supported yet. If you need a coloured logo on the surface, this cannot do it today.
Why a shader, and not a gradient
A moving gradient gets most of the way there and breaks in four places.
A gradient's band is always the same shape. Here the highlight is a specular lobe over a normal that varies per pixel across a cylindrical bow and three plane waves at irrational angles, so the band compresses and shimmers as it crosses.
A view cannot read what is behind it. The material multiplies your content's own luminance, so a light surface turns metal while dark text stays dark. Legibility falls out of the model instead of being hand-authored twice.
The gleam crosses your text. Behind the content it is occluded and reads as printed metallic art; in front it erases the text. Here it passes over and is damped by how dark each pixel is.
No SwiftUI primitive has a view vector, so a gradient's edges behave exactly like its middle. Fresnel needs one.
Demo app
Example/SurfaceForgeDemo.xcodeproj is one surface with a control for every
knob. It runs on a Simulator with no Apple account and no signing setup.
The same metal, worked differently:
Surface(material: .gold.finish(.brushed)) { … }Twelve finishes ship: polished (the default), brushed, pinstripe, carbon twill, topographic, basketweave, clous de Paris, knurling, sandblasted, sunburst, molten and hammered. Brushed and pinstripe take a direction:
.gold.finish(.brushed(angle: .degrees(90)))SurfaceFinish.all lists them for pickers, hasDirection says whether an
angle means anything, and aimed(at:) applies one where it does. A finish
never changes the metal itself: same tint, and text on the surface stays
legible under every pattern. Two retune the highlight the way the real work
would: sandblasted scatters it soft and wide, and molten, which turns the
surface itself to liquid, folds it into tongues of light that swirl as the
device tilts.
PatternedSurface is a separate card system for patterned identity cards. It
does not turn the entire card into metal. The card itself stays still by
default; only its pattern, broad background glow, and an explicitly selected
hero respond to tilt. Text and normal content stay sharp above the artwork.
let mark = PatternElement.systemSymbol("heart", weight: .medium)
PatternedSurface(
background: .identityPrismatic,
pattern: .identitySpiral(element: mark),
sheen: .identity
) {
HStack {
VStack(alignment: .leading) {
Text("ANDREAS").font(.headline.weight(.bold))
Text("Verified since February 2019")
Spacer()
Text("Trust is the cornerstone of our community.")
}
Spacer()
Image("profile")
.resizable()
.scaledToFill()
.frame(width: 88, height: 112)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
.surfaceHologram(.identityFoil)
}
}
.frame(width: 353, height: 220)Background colour can be as simple or as directed as the caller needs. A solid
colour is preserved exactly; automatic mode derives a perceptual, gamut-safe
three-stop colourway from one base colour; custom mode accepts SwiftUI's native
Gradient, including any number of stops.
.color(.indigo)
.automatic(
base: .indigo,
flow: .coolToWarm,
style: .linear(angle: .degrees(-45))
)
.linearGradient(
Gradient(colors: [.purple, .pink, .orange]),
angle: .degrees(-45)
)Automatic flows can be .coolToWarm, .warmToCool, or .neutral. Both
automatic and custom gradients support linear and radial geometry. Pattern ink
and sheen can remain relative to the rendered background or be overridden with
fixed colours independently.
For a card whose background can change at runtime, automatic pattern ink solves one related colour against every gradient stop and the sheen's brightest state. The result behaves like one printed pigment instead of switching between light and dark ink across the card. A fixed brand colour and the old per-pixel adaptation remain explicit alternatives.
Use .dark for restrained printed linework, .light for pale foil-like
artwork, or .bestContrast when legibility should decide automatically.
color: .automatic(
preference: .dark,
minimumContrast: 1.8,
saturationShift: -0.06
)
color: .fixed(brandInk)
color: .locallyAdaptive(minimumContrast: 3.2)The pattern element can be a SwiftUI Shape, Path, SF Symbol, direct SVG,
CGImage, or a loose image file in a Bundle. Its id is yours: change it
whenever supplied artwork changes, so the cached mask is invalidated correctly.
Grid, staggered, radial, and arc-length-spaced spiral layouts expose their
spacing, centre, phase, rotation, and orientation directly. A spiral's
radialSpacing is the gap between neighbouring strands whatever arms is set
to, and startRadius keeps the focus bare, where the arms crowd together.
let brandMark = try PatternElement.svg(
"""
<svg viewBox="0 0 24 24">
<path d="M12 2 L22 12 L12 22 L2 12 Z"
fill="none" stroke="black" stroke-width="1.8"/>
</svg>
""",
id: "brand-mark-v1"
)
// Or keep the SVG as an app-bundle resource.
let bundledMark = try PatternElement.svgResource(
named: "brand-mark.svg",
bundle: .main,
id: "brand-mark-v1"
)SVG is parsed once into the same cached vector mask as a Path. The importer
supports paths including arcs, the basic geometric elements, fills, strokes,
groups, and transforms. It rejects gradients, masks, text, images, <use>, and
dashed strokes rather than silently rendering the wrong mark.
The pattern reveal is its own moving, soft light. It does not make a hard visible/invisible split across the card. Its public controls are explicit:
reveal: .init(
restingVisibility: 0.01, // almost hidden at rest
tiltResponse: 0.94, // activates under a small wrist tilt
anchor: .center, // initial light position
travel: 0.30, // maximum movement toward the raised edge
spread: CGSize(width: 0.42, height: 0.42),
falloff: 2.4 // larger = a quieter outer field
)PatternedSurfaceMotion.artworkOnly // default: card outline stays still
PatternedSurfaceMotion.cardTilt(maximumAngle: .degrees(5))
// Exact, repeatable preview or visual-test state. This suppresses live motion.
.patternedSurfaceTilt(.init(x: 0.62, y: 0.34))The hero treatment is false-colour foil, not a rainbow hologram. With a portrait matte, dark source detail merges into the field around it, violet when the hero is cool and champagne when it is warm, so only light skin detail carries the face.
The light sits in one place, as it would for a card held under a lamp. The band always travels along the same axis: tilting toward the light carries it across the hero, tilting across that axis slides nothing, and tilting away leaves it parked off the edge, where it rests.
SurfaceHologram(palette: .identity, light: .fixed(.degrees(156)))
SurfaceHologram(palette: .identity, light: .followsGesture)The angle names where the band travels to, entering from the opposite edge.
0° carries it to the right, 90° to the top, and the default up and to the
left.
.followsGesture is the loose alternative: the band arrives from whichever way
the card is tilted. Nothing physical behaves that way, but it answers every
gesture, which suits a card that has to look alive at a glance.
Soft shoulders and a low-frequency warp stagger when nearby pixels change, so the cool state returns behind the band instead of leaving a hard stripe.
SurfaceFoilPalette sets the colourway. SurfaceHologram(arrivalDuration:)
sets roughly how long the band takes to settle, and sweepDuration caps how
fast a full pass can run. Small wrist movement is deliberately quiet: the
default finishes a pass only near a full gesture. A held card is completely
still unless idleMotion is above zero. Omit .surfaceHologram and the hero
keeps its original pixels.
The matte tells the foil where the subject ends. Draw one by hand, or let Vision find the subject:
let mask = try await SurfaceHologram.subjectMask(for: photo)Run it once, when the asset is added or uploaded, and store what it returns. It costs a machine-learning pass, so calling it while a card is on screen stalls the first frames and costs the same again every cold launch. On a 576x768 portrait it took 590 ms and landed within a pixel of a hand-drawn matte.
It throws noSubjectFound when the image holds nothing it recognises. Fall
back to .surfaceHologram(_:), which needs no matte at all.
iOS 17 and macOS 14. No dependencies. No bundled assets.
Motion comes from the device on iOS. On macOS a surface follows the pointer
instead, through surfacePointerTilt().
MIT
