When the iPhone Duo was announced, there was no simulator for it. So we built one: a small Duo mini OS running full screen on an iPad. The moment people asked about most was the fold. Tilt the iPad and the left half of the home screen frosts over, dims and leans away on a soft hinge, the way a book-fold display looks as it closes. Level it out and it snaps back.
There is no folding hardware here. The screen stays flat. This is part 1 of 2: how the fold is faked on the surface, driven by the gyroscope. Part 2 rebuilds the rest of the OS around it and builds Mail on top.
The AI way
How the prompt came together
It wasn’t one prompt. The first days went in circles over the same mistake, putting the effects on the image instead of the screen. These are the prompts that changed direction, with typos fixed:
- “Here is what we are going to build using the gyroscope for iPad. Before writing any code, tell me what you can see.”
- “Can you add a visualiser for the progressive blur, and sliders for the blur values, so I can tune them?”
- “We need to make all the effects on the screen, not on the image.”
- “The blur is not on the image but on the screen surface. The blur near the edge of the left screen comes first as the screen moves, so it doesn’t show that the screen is empty, and by the time it goes above 45 degrees it reaches the middle.”
- “Instead of having multiple sliders, tie all of them to one slider.”
- “I’d like it to go to a max of 85% and then stick with the same blur and everything.”
The hard way
Or build it yourself. This is everything the prompt asks for, one piece at a time, and why each piece is there.
What we’re building
- A tilt reading from CoreMotion’s gravity vector, smoothed and normalised to −1…1
- A fold amount from 0 to 1, mapped from the iPad’s long axis in landscape
- The home screen split at the centre crease, with the left half hinged back in 3D
- A progressive blur, heaviest at the outer edge and sharp at the crease, using the same filter iOS uses for its own gradient blurs
- A frost and scrim that sweep toward the crease ahead of the hinge, so the fold never shows an empty gap
Requirements: Xcode 26 or later, an iOS 26 target, and a real iPad to feel it. The fold is driven by the gyroscope, and the Simulator has no gyroscope, so there it rests on the flat home screen.
Fake it on the surface
The obvious approach is to hinge the whole screen like a real page. It falls apart straight away: there is no second display behind it, so any real rotation shows a black void where the page used to be. A tilting picture doesn’t read as folding.
What sells it is everything that happens on the surface. Three layers sit over a home screen that never moves:
- A gentle rotation3DEffect leans the left half back from the centre crease.
- A progressive blur frosts that half: heaviest at the outer edge, easing to sharp at the crease.
- A dark scrim rides along, so the folding side reads as glass catching shadow.
It didn’t start there. The first draft rotated the wallpaper image itself with the gyroscope, with a visualiser for the blur stops and sliders to tune them in the Simulator. It took a few days to see the mistake: the image was never the thing folding. The screen was.
The trick is that the blur leads the tilt. As the fold deepens, the frost sweeps inward toward the crease ahead of the geometry, so the receding edge is already soft by the time it would look wrong.
Every screenshot below is the iPad Simulator, built up one step at a time. The Simulator has no gyroscope, so the fold is pinned with launch arguments. Run the repo with -DuoStep 3 -DuoFold 0.5, for example, to see any step for yourself.
The canvas
Start with a home screen that never moves. It’s laid out on a fixed 1194 × 834 design canvas and scaled to cover the display, not fit it. Fitting would letterbox the canvas and leave a sharp gutter right beside where the frost will be.
| 1 | GeometryReader { geo in |
| 2 | // Cover, not fit: the canvas fills the display edge to edge. |
| 3 | let scale = max(geo.size.width / design.width, geo.size.height / design.height) |
| 4 | canvas() |
| 5 | .frame(width: design.width, height: design.height) |
| 6 | .scaleEffect(scale) |
| 7 | .frame(width: geo.size.width, height: geo.size.height) |
| 8 | } |
| 9 | .ignoresSafeArea() |
| 10 | .statusBarHidden() |
| 11 | .persistentSystemOverlays(.hidden) |

Split at the crease
Each half of the screen is the full home screen canvas, clipped to one side. Rendering both halves from the same canvas means they line up perfectly when the fold is flat: no seam at the crease.
| 1 | private func leaf(_ side: Leaf, gridShiftX: CGFloat) -> some View { |
| 2 | canvas(gridShiftX: gridShiftX) |
| 3 | .frame(width: design.width / 2, height: design.height, |
| 4 | alignment: side == .left ? .leading : .trailing) |
| 5 | .clipped() |
| 6 | } |
On screen, nothing changes. That’s the test: if you can see a line at the crease here, the two halves aren’t being drawn from the same layout.
The hinge
Now rotate the left half about its trailing edge, the crease, with a little perspective. Perspective is 0.55: enough to read as depth without the far edge ballooning. Behind it goes a copy of the wallpaper blurred at 60 points and dimmed with 45% black, so the space the half leaves as it leans never reads as pure black.
| 1 | ZStack(alignment: .topLeading) { |
| 2 | // Dim, out-of-focus backdrop for the space the left half leaves. |
| 3 | Wallpaper() |
| 4 | .frame(width: design.width, height: design.height) |
| 5 | .blur(radius: 60, opaque: true) |
| 6 | .overlay(Color.black.opacity(0.45)) |
| 7 | |
| 8 | HStack(spacing: 0) { |
| 9 | leaf(.left, gridShiftX: slide) |
| 10 | .rotation3DEffect(.degrees(-angle), axis: (x: 0, y: 1, z: 0), |
| 11 | anchor: .trailing, perspective: 0.55) |
| 12 | leaf(.right, gridShiftX: slide) |
| 13 | } |
| 14 | } |

One small detail: the right-hand group of widgets and icons nudges right by up to 24 points as the fold deepens. It’s barely visible on its own, but it makes the flat half feel like it’s being pushed open rather than standing still.
The progressive blur
The variable blur filter
SwiftUI’s .blur is uniform: one radius for the whole view. The fold needs a blur that’s heavy at one edge and fades to nothing, the same gradient frost iOS draws behind the status bar and the keyboard. That’s Core Animation’s variableBlur filter. It’s private, so it isn’t in any public header, but it can be created by name and attached to the backdrop layer of a UIVisualEffectView.
| 1 | final class VariableBlurUIView: UIVisualEffectView { |
| 2 | private var lastRadius: CGFloat = 0 |
| 3 | private var lastMask: CGImage? |
| 4 | |
| 5 | init() { |
| 6 | super.init(effect: UIBlurEffect(style: .regular)) |
| 7 | // Drop the tint/vibrancy the effect adds — we want only the blur. |
| 8 | subviews.dropFirst().forEach { $0.alpha = 0 } |
| 9 | isUserInteractionEnabled = false |
| 10 | } |
| 11 | required init?(coder: NSCoder) { fatalError("init(coder:) is unavailable") } |
| 12 | |
| 13 | func update(maxBlurRadius: CGFloat, mask: CGImage?) { |
| 14 | lastRadius = maxBlurRadius |
| 15 | lastMask = mask |
| 16 | applyFilter() |
| 17 | } |
| 18 | |
| 19 | // The system can drop `layer.filters` when the view changes windows — reassert. |
| 20 | override func didMoveToWindow() { |
| 21 | super.didMoveToWindow() |
| 22 | applyFilter() |
| 23 | } |
| 24 | |
| 25 | private func applyFilter() { |
| 26 | guard let backdrop = subviews.first?.layer, let mask = lastMask else { return } |
| 27 | guard let filterClass = NSClassFromString("CAFilter") as? NSObject.Type, |
| 28 | let filter = filterClass |
| 29 | .perform(NSSelectorFromString("filterWithType:"), with: "variableBlur")? |
| 30 | .takeUnretainedValue() as? NSObject |
| 31 | else { return } |
| 32 | filter.setValue(NSNumber(value: Double(lastRadius)), forKey: "inputRadius") |
| 33 | filter.setValue(mask, forKey: "inputMaskImage") |
| 34 | filter.setValue(true, forKey: "inputNormalizeEdges") |
| 35 | backdrop.filters = [filter] |
| 36 | } |
| 37 | } |
A mask made of alpha
The catch that cost an afternoon: the filter reads the mask’s alpha, not its brightness. A grey gradient blurs the whole screen evenly. What you need is a white image whose alpha fades from opaque, full blur, to clear, sharp. The mask is only two pixels tall, because the sweep is purely horizontal; the filter stretches it over the layer. Raising the falloff to a power of 1.6 gives the frost a soft tail instead of a hard line.
| 1 | static func mask(front: CGFloat, fromRight: Bool = false, width: Int = 512) -> CGImage? { |
| 2 | let f = Double(min(max(front, 0.004), 1)) |
| 3 | let height = 2 |
| 4 | var bytes = [UInt8](repeating: 0, count: width * height * 4) |
| 5 | for x in 0..<width { |
| 6 | let t = Double(x) / Double(width - 1) |
| 7 | let u = fromRight ? (1 - t) : t // distance from the heavy (free) edge |
| 8 | let a = u < f ? pow(1 - u / f, 1.6) : 0 |
| 9 | let v = UInt8(max(0, min(1, a)) * 255) // premultiplied white → RGB == A |
| 10 | for y in 0..<height { |
| 11 | let i = (y * width + x) * 4 |
| 12 | bytes[i] = v; bytes[i + 1] = v; bytes[i + 2] = v; bytes[i + 3] = v |
| 13 | } |
| 14 | } |
| 15 | let cs = CGColorSpaceCreateDeviceRGB() |
| 16 | guard let provider = CGDataProvider(data: Data(bytes) as CFData) else { return nil } |
| 17 | return CGImage(width: width, height: height, bitsPerComponent: 8, bitsPerPixel: 32, |
| 18 | bytesPerRow: width * 4, space: cs, |
| 19 | bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue), |
| 20 | provider: provider, decode: nil, shouldInterpolate: true, |
| 21 | intent: .defaultIntent) |
| 22 | } |

One warning: variableBlur is a private API. That’s fine for a concept or a prototype on your own device, but App Review will reject it in a shipping app.
The scrim
A blur on its own looks like fog. A dark gradient riding with it, heaviest at the outer edge, makes the folding side read as glass catching shadow. It shares the blur’s front, so the two move as one surface.
| 1 | // Inside FoldFrost, over the blur: dark at the outer edge, clear by the front. |
| 2 | LinearGradient( |
| 3 | stops: [ |
| 4 | .init(color: .black.opacity(dark), location: 0), |
| 5 | .init(color: .black.opacity(dark * 0.55), location: front * 0.55), |
| 6 | .init(color: .clear, location: max(front, 0.001)) |
| 7 | ], |
| 8 | startPoint: fromRight ? .trailing : .leading, |
| 9 | endPoint: fromRight ? .leading : .trailing |
| 10 | ) |
Blur alone
Blur and scrim
All of it together
Put the hinge, the blur and the scrim together, all driven by one fold value from 0 (flat) to 1.
| 1 | private func foldedCanvas(fold: Double) -> some View { |
| 2 | let f = min(max(fold, 0), maxFold) |
| 3 | let slide = CGFloat(gridSlide) * CGFloat(f) |
| 4 | let angle = leftFoldAngle * f |
| 5 | return ZStack(alignment: .topLeading) { |
| 6 | Wallpaper() |
| 7 | .frame(width: design.width, height: design.height) |
| 8 | .blur(radius: 60, opaque: true) |
| 9 | .overlay(Color.black.opacity(0.45)) |
| 10 | |
| 11 | HStack(spacing: 0) { |
| 12 | leaf(.left, gridShiftX: slide) |
| 13 | .rotation3DEffect(.degrees(-angle), axis: (x: 0, y: 1, z: 0), |
| 14 | anchor: .trailing, perspective: 0.55) |
| 15 | leaf(.right, gridShiftX: slide) |
| 16 | } |
| 17 | .frame(width: design.width, height: design.height) |
| 18 | |
| 19 | FoldFrost(fold: f, size: design, maxBlur: maxBlur, |
| 20 | darkStrength: darkStrength, sweepAt: sweepAt) |
| 21 | } |
| 22 | .frame(width: design.width, height: design.height, alignment: .topLeading) |
| 23 | } |
If the frost only followed the hinge
With the frost leading it
The left frame is a what-if, not a stage the project went through: it’s what you get if the frost only keeps pace with the hinge. The half that recedes stays sharp, and your eye reads it as a tilted picture again. That’s why the frost has to lead.
A frost that leads the fold
FoldFrost turns one fold value into the blur radius, the scrim and the position of the frost’s front edge. Two curves do the work. bite raises the fold to the power of attack, 0.4, so the frost arrives early: most of the blur is already there in the first few degrees of tilt. front moves the sharp edge from the outside of the screen toward the crease, and reaches it when the fold is halfway, at sweepAt. The hinge is still only at around 45° by then, so the frost is always ahead of it.
| 1 | struct FoldFrost: View { |
| 2 | var fold: Double |
| 3 | var edge: HorizontalEdge = .leading |
| 4 | var size: CGSize |
| 5 | var maxBlur: Double = 90 |
| 6 | var darkStrength: Double = 0.60 |
| 7 | var sweepAt: Double = 0.5 |
| 8 | var attack: Double = 0.4 // < 1 front-loads the frost so most of it arrives early |
| 9 | |
| 10 | var body: some View { |
| 11 | let f = max(fold, 0) |
| 12 | let bite = pow(f, attack) // front-loaded attack |
| 13 | let front = CGFloat(min(f / max(sweepAt, 0.05), 1) * 0.5) // 0.5 = the full half-width |
| 14 | let radius = CGFloat(maxBlur * bite) |
| 15 | let dark = darkStrength * bite |
| 16 | let fromRight = edge == .trailing |
| 17 | ZStack(alignment: fromRight ? .topTrailing : .topLeading) { |
| 18 | if radius > 0.5 { |
| 19 | VariableBlur(maxBlurRadius: radius, front: min(front * 2, 1), fromRight: fromRight) |
| 20 | .frame(width: size.width / 2, height: size.height) |
| 21 | } |
| 22 | |
| 23 | LinearGradient( |
| 24 | stops: [ |
| 25 | .init(color: .black.opacity(dark), location: 0), |
| 26 | .init(color: .black.opacity(dark * 0.55), location: front * 0.55), |
| 27 | .init(color: .clear, location: max(front, 0.001)) |
| 28 | ], |
| 29 | startPoint: fromRight ? .trailing : .leading, |
| 30 | endPoint: fromRight ? .leading : .trailing |
| 31 | ) |
| 32 | } |
| 33 | .frame(width: size.width, height: size.height, |
| 34 | alignment: fromRight ? .topTrailing : .topLeading) |
| 35 | .allowsHitTesting(false) |
| 36 | } |
| 37 | } |
Fold 0.15
Fold 0.85, the cap
The blur view is clipped to its own half and left out entirely at rest, so the flat side is never under an effect layer and a level iPad costs nothing extra. The scrim uses the same front, which keeps the darkening and the frost moving as one surface.
The feel is in the numbers
Every value that shapes the fold sits at the top of iPadDuoView. They’re the part worth spending time on:
- Hinge: 80° at full fold, so the half goes almost edge-on.
- Cap: the fold stops at 0.85 and holds there, the best-looking pose.
- Blur: up to 90 points at the outer edge.
- Scrim: up to 60% black at the outer edge.
- Lead: the frost reaches the crease at half the fold, about 45°.
- Nudge: the right-hand icons and widgets slide up to 24 points.
- Gain: −1.6, from tilt to fold, with the sign setting the direction.
| 1 | private let maxBlur: Double = 90 // max variable-blur radius (pt) at full fold |
| 2 | private let darkStrength: Double = 0.60 // max scrim opacity at the outer edge |
| 3 | private let sweepAt: Double = 0.5 // fold fraction at which the frost front reaches centre (~45°) |
| 4 | private let gridSlide: Double = 24 // how far the icon grid nudges right at full fold, pt |
| 5 | private let leftFoldAngle: Double = 80 // left panel's fold about the crease at full fold, degrees |
| 6 | private let maxFold: Double = 0.85 // fold caps here and holds |
| 7 | private let gyroGain: Double = -1.6 // gyro sensitivity + direction (signed) |
First pass: 35°, about half the blur
Final: 80° hinge, 90 pt blur
The first pass was a shy 35° hinge with a light frost, and it didn’t sell the fold at all. Matching the reference meant pushing the hinge almost edge-on to 80°, roughly doubling the blur, and sweeping the frost across the whole half. Then the fold is capped at 85%, so it settles on its best-looking pose instead of collapsing to a sliver.
Drive it with the gyroscope
Everything so far ran on a fixed fold. On a real iPad, the fold comes from how far you tilt it.
Reading the tilt
MotionManager wraps CoreMotion. It reads the gravity vector rather than Euler angles: gravity’s components are already normalised, stable, and free of the gimbal-lock jumps you get from attitude. asin turns a gravity component back into a true angle, so the mapping doesn’t compress near 90°. Updates arrive 60 times a second, a full 90° tilt maps to 1, and a low-pass filter with a factor of 0.12 takes out the sensor noise.
| 1 | @Observable |
| 2 | final class MotionManager { |
| 3 | /// Smoothed, normalized gravity components (~-1...1) in the device's fixed |
| 4 | /// frame. `gx` is the device's short axis, `gy` its long axis. |
| 5 | var gx: Double = 0 |
| 6 | var gy: Double = 0 |
| 7 | |
| 8 | /// True once real device-motion updates are flowing. |
| 9 | private(set) var isUsingGyro = false |
| 10 | |
| 11 | private let manager = CMMotionManager() |
| 12 | private let maxTiltAngle = Double.pi / 2 |
| 13 | private let smoothing = 0.12 |
| 14 | |
| 15 | func start() { |
| 16 | guard manager.isDeviceMotionAvailable else { |
| 17 | isUsingGyro = false |
| 18 | return |
| 19 | } |
| 20 | isUsingGyro = true |
| 21 | manager.deviceMotionUpdateInterval = 1.0 / 60.0 |
| 22 | manager.startDeviceMotionUpdates(to: .main) { [weak self] motion, _ in |
| 23 | guard let self, let gravity = motion?.gravity else { return } |
| 24 | |
| 25 | let tx = asin(gravity.x.clamped()) / self.maxTiltAngle |
| 26 | let ty = asin(gravity.y.clamped()) / self.maxTiltAngle |
| 27 | |
| 28 | self.gx += (tx - self.gx) * self.smoothing |
| 29 | self.gy += (ty - self.gy) * self.smoothing |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | func stop() { |
| 34 | manager.stopDeviceMotionUpdates() |
| 35 | isUsingGyro = false |
| 36 | } |
| 37 | } |
isUsingGyro matters more than it looks. In the Simulator it stays false, and the view falls back to a fold of zero: the clean home screen, which is exactly what you want while laying things out.
Turning tilt into a fold
The app is locked to landscape, so the roll that folds the screen is the device’s long axis, gy. The two landscape orientations are mirror images of each other, so the sign flips for landscapeLeft. A signed gain of −1.6 sets both sensitivity and direction, and subtracting 0.03 before clamping gives a deadzone: a level iPad stays perfectly still.
| 1 | private func currentFold() -> Double { |
| 2 | motion.isUsingGyro ? gyroFold() : 0 |
| 3 | } |
| 4 | |
| 5 | private func gyroFold() -> Double { |
| 6 | let roll = (interfaceOrientation == .landscapeLeft) ? -motion.gy : motion.gy |
| 7 | return min(max(roll * gyroGain - 0.03, 0), 1) |
| 8 | } |
| 9 | |
| 10 | private var interfaceOrientation: UIInterfaceOrientation { |
| 11 | UIApplication.shared.connectedScenes |
| 12 | .compactMap { $0 as? UIWindowScene } |
| 13 | .first?.effectiveGeometry.interfaceOrientation ?? .landscapeRight |
| 14 | } |
Driving it every frame
A TimelineView redraws the canvas every frame from the latest tilt. The visual layer ignores touches; app launches go through transparent hit targets placed over the icons.
| 1 | GeometryReader { geo in |
| 2 | let scale = max(geo.size.width / design.width, geo.size.height / design.height) |
| 3 | ZStack { |
| 4 | TimelineView(.animation) { _ in |
| 5 | let fold = currentFold() |
| 6 | foldedCanvas(fold: fold) |
| 7 | .frame(width: design.width, height: design.height) |
| 8 | .scaleEffect(scale) |
| 9 | .frame(width: geo.size.width, height: geo.size.height) |
| 10 | } |
| 11 | .allowsHitTesting(false) |
| 12 | } |
| 13 | } |
| 14 | .background(.black) |
| 15 | .ignoresSafeArea() |
| 16 | .statusBarHidden() |
| 17 | .persistentSystemOverlays(.hidden) |
| 18 | .onAppear { motion.start(); UIApplication.shared.isIdleTimerDisabled = true } |
| 19 | .onDisappear { motion.stop(); UIApplication.shared.isIdleTimerDisabled = false } |
Hiding the status bar and the persistent system overlays is what makes the iPad disappear: no iPad clock, no home indicator, just the Duo. Keeping the screen awake stops it dimming mid-demo while you hold it and tilt.
Takeaways
- When the hardware can’t do it, sell the effect on the surface. A flat screen with the right blur reads as a fold; a rotated screen reads as a picture.
- Read tilt from the gravity vector and smooth it. It’s stable and avoids gimbal-lock jumps.
- Let the blur lead the geometry, so the receding edge is soft before it can look wrong.
- variableBlur reads the mask’s alpha, not its brightness.
- Put every value that shapes the feel in one place, then tune on a real device.
The full project, including the home screen layout and the motion manager, is open source under the MIT license. The icons and wallpaper are placeholders that resemble Apple artwork; swap in your own before doing anything beyond experimenting.