Skip to content

Holographic Card

A card with a holographic foil whose colors, glints and glare shift as you tilt it by dragging, drawn by a Metal shader.

Holographic Card, playing
  • Cards
  • iOS 18.0+
  • advanced
  • Metal shader
Download .swiftDownload .metal
Origin
Original work
Found by
holographic · foil · shader · metal · iridescent · rainbow · card · tilt · trading card · color effect · holográfico · holograma · shader · metal · iridiscente · arcoíris · tarjeta · inclinación · cromo · efecto de color

A card with a holographic foil that shifts as you tilt it by dragging, drawn by a Metal shader.

A shader draws what SwiftUI draws itself: a view backed by UIKit, such as a map, shows a placeholder.

With Reduce Motion on, the card stays flat and the foil stays still.

struct HolographicCardDemo: View {
var body: some View {
ZStack {
LinearGradient(
colors: [Color(red: 0.04, green: 0.04, blue: 0.09), Color(red: 0.14, green: 0.09, blue: 0.27)],
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea()
HolographicCard {
card
}
.padding(.horizontal, 44)
}
}
private var card: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: "sparkles")
.font(.title)
.accessibilityHidden(true)
Spacer()
Text("No. 001")
.font(.footnote.monospaced())
.opacity(0.7)
}
Spacer()
Text("Prism Pass")
.font(.system(.largeTitle, design: .rounded, weight: .bold))
Text("Drag to tilt the card")
.font(.subheadline)
.opacity(0.8)
}
.foregroundStyle(.white)
.padding(24)
.frame(maxWidth: .infinity, alignment: .leading)
.aspectRatio(0.68, contentMode: .fit)
.background(
LinearGradient(
colors: [
Color(red: 0.1, green: 0.12, blue: 0.3),
Color(red: 0.32, green: 0.12, blue: 0.42),
Color(red: 0.06, green: 0.06, blue: 0.14),
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.accessibilityElement(children: .combine)
}
}
ParameterTypeDefaultDescription
maxAngleDouble16The degrees the card leans at the end of a drag.
cornerRadiusCGFloat28
intensityDouble1How much foil the card has, from 0 for none to 1.
content() -> ContentRequired

Copy both files into your project: the Swift file and the Metal shader it draws with. Or download HolographicCard.swift and HolographicCard.metal.

HolographicCard.swift
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
nonisolated enum HolographicCardMath {
/// How far the card leans for a drag, from -1 to 1 on each axis.
/// Dragging half the card's size leans it all the way.
static func tilt(for translation: CGSize, in size: CGSize) -> CGSize {
guard size.width > 0, size.height > 0 else { return .zero }
return CGSize(
width: min(max(translation.width / (size.width / 2), -1), 1),
height: min(max(translation.height / (size.height / 2), -1), 1)
)
}
/// The degrees the card turns around its vertical axis for a lean from -1 to 1.
/// `rotation3DEffect` mirrors in a right-to-left layout and a drag does not, so the turn is reversed there.
static func yaw(for lean: Double, maxAngle: Double, layoutDirection: LayoutDirection) -> Double {
let turn = lean * maxAngle
return layoutDirection == .rightToLeft ? -turn : turn
}
/// The function in `HolographicCard.metal`, with its arguments in the order it declares them.
static func shader(size: CGSize, tilt: CGSize, intensity: Double) -> Shader {
ShaderLibrary.HolographicCardFoil(.float2(size), .float2(tilt), .float(intensity))
}
}
/// A card with a holographic foil that shifts as you tilt it by dragging, drawn by a Metal shader.
///
/// - Important: Copy two files into the same target: `HolographicCard.swift` and `HolographicCard.metal`.
///
/// A shader draws what SwiftUI draws itself: a view backed by UIKit, such as a map, shows a placeholder.
///
/// With Reduce Motion on, the card stays flat and the foil stays still.
struct HolographicCard<Content: View>: View {
/// The degrees the card leans at the end of a drag.
var maxAngle: Double = 16
var cornerRadius: CGFloat = 28
/// How much foil the card has, from 0 for none to 1.
var intensity: Double = 1
@ViewBuilder var content: () -> Content
@GestureState private var tilt = CGSize.zero
@State private var size = CGSize.zero
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
content()
.onGeometryChange(for: CGSize.self) { $0.size } action: { size = $0 }
.modifier(
HolographicCardSurface(
tilt: reduceMotion ? .zero : tilt,
size: size,
maxAngle: maxAngle,
cornerRadius: cornerRadius,
intensity: intensity
)
)
.gesture(drag)
.animation(.spring(duration: 0.5, bounce: 0.3), value: tilt)
}
private var drag: some Gesture {
DragGesture(coordinateSpace: .global)
.updating($tilt) { value, tilt, _ in
tilt = HolographicCardMath.tilt(for: value.translation, in: size)
}
}
}
/// `Animatable`, so the foil follows the same spring as the lean: a shader's arguments do not animate by themselves.
private struct HolographicCardSurface: ViewModifier, Animatable {
var tilt: CGSize
let size: CGSize
let maxAngle: Double
let cornerRadius: CGFloat
let intensity: Double
@Environment(\.layoutDirection) private var layoutDirection
nonisolated var animatableData: CGSize.AnimatableData {
get { tilt.animatableData }
set { tilt.animatableData = newValue }
}
func body(content: Content) -> some View {
content
.colorEffect(HolographicCardMath.shader(size: size, tilt: tilt, intensity: intensity))
.clipShape(.rect(cornerRadius: cornerRadius))
.shadow(color: .black.opacity(0.4), radius: 24, y: 16)
.rotation3DEffect(.degrees(-tilt.height * maxAngle), axis: (x: 1, y: 0, z: 0), perspective: 0.6)
.rotation3DEffect(
.degrees(HolographicCardMath.yaw(for: tilt.width, maxAngle: maxAngle, layoutDirection: layoutDirection)),
axis: (x: 0, y: 1, z: 0),
perspective: 0.6
)
}
}
// MARK: - Demo
struct HolographicCardDemo: View {
var body: some View {
ZStack {
LinearGradient(
colors: [Color(red: 0.04, green: 0.04, blue: 0.09), Color(red: 0.14, green: 0.09, blue: 0.27)],
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea()
HolographicCard {
card
}
.padding(.horizontal, 44)
}
}
private var card: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: "sparkles")
.font(.title)
.accessibilityHidden(true)
Spacer()
Text("No. 001")
.font(.footnote.monospaced())
.opacity(0.7)
}
Spacer()
Text("Prism Pass")
.font(.system(.largeTitle, design: .rounded, weight: .bold))
Text("Drag to tilt the card")
.font(.subheadline)
.opacity(0.8)
}
.foregroundStyle(.white)
.padding(24)
.frame(maxWidth: .infinity, alignment: .leading)
.aspectRatio(0.68, contentMode: .fit)
.background(
LinearGradient(
colors: [
Color(red: 0.1, green: 0.12, blue: 0.3),
Color(red: 0.32, green: 0.12, blue: 0.42),
Color(red: 0.06, green: 0.06, blue: 0.14),
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.accessibilityElement(children: .combine)
}
}
#Preview {
HolographicCardDemo()
}
HolographicCard.metal
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
#include <metal_stdlib>
#include <SwiftUI/SwiftUI_Metal.h>
using namespace metal;
// A repeatable pseudo-random number from 0 to 1 for each cell of a grid.
static float HolographicCardRandom(float2 cell) {
uint2 seed = uint2(int2(cell));
uint mixed = (seed.x * 0x9E3779B1u) ^ (seed.y * 0x85EBCA77u);
mixed ^= mixed >> 15;
mixed *= 0x2C1B3C6Du;
mixed ^= mixed >> 12;
return float(mixed & 0xFFFFu) / 65535.0;
}
// For SwiftUI's `colorEffect`. Adds a holographic foil to each pixel of a view
// of `size` points. `tilt` runs from -1 to 1 on each axis, and moves the colors
// and the glare as a real card does when it turns under a light.
[[ stitchable ]] half4 HolographicCardFoil(float2 position, half4 color, float2 size, float2 tilt, float intensity) {
if (color.a <= 0.0) {
return color;
}
float2 fromCenter = position / max(size, float2(1.0)) - 0.5;
// A diffraction foil changes hue with the viewing angle, so its bands of
// color slide across the card as it tilts.
float angle = dot(fromCenter, float2(0.9, 0.5)) * 1.4 + dot(tilt, float2(0.8, 0.6));
float3 spectrum = 0.5 + 0.5 * cos(6.2831853 * (angle + float3(0.0, 0.33, 0.67)));
// Specks of glitter, one in each cell of a grid and somewhere off its
// center. Each catches the light at its own angle, so they twinkle in turn.
float2 grid = position / 5.0;
float2 cell = floor(grid);
float2 speck = 0.5 + 0.56 * (float2(HolographicCardRandom(cell + 17.0), HolographicCardRandom(cell + 43.0)) - 0.5);
float shape = saturate(1.0 - length(grid - cell - speck) / 0.22);
float facing = 0.5 + 0.5 * cos(6.2831853 * (HolographicCardRandom(cell) + angle * 2.0));
float glint = smoothstep(0.55, 1.0, facing) * shape * shape;
// The glare of a light above the card slides against the tilt.
float2 fromGlare = fromCenter + tilt * 0.35;
float glare = exp(-6.0 * dot(fromGlare, fromGlare));
float3 foil = intensity * (spectrum * (0.14 + 0.4 * glare) + glint * (0.3 + 0.7 * glare));
// Screen the foil over the pixel, so it only ever adds light. Colors come
// premultiplied by their alpha.
float3 base = float3(color.rgb) / color.a;
float3 lit = 1.0 - (1.0 - base) * (1.0 - saturate(foil));
return half4(half3(lit * color.a), color.a);
}