How the iPhone Duo Fold Animation Works

iPhone Duo fold animation

From the engineering notebook of Zubair Hussain Shah. An article on sensor-driven interface rendering on foldable devices.

Not to be confused with the general fold-aware layout APIs in Jetpack WindowManager, which describe window geometry rather than animation.

The iPhone Duo fold animation is a user interface transition on Apple's iPhone Duo, the company's first foldable handset, announced on 9 September 2026 and shipping from 23 October 2026.[1] As the device is opened or closed, the wallpaper and interface progressively blur, darken and appear to pass through the moving half of the display before resolving back into focus. Unlike a conventional animation, which advances against a clock, the transition advances against the measured physical angle of the hinge, so it tracks the hand rather than a timer.

Within a day of the announcement, a developer published a working recreation on a Samsung Galaxy Z Fold 8, built as a standalone Android application that reads the hinge angle sensor, renders on both displays through the Presentation API and drives an AGSL runtime shader from the current hinge value.[2][5] The demonstration uses captured screenshots of the inner and outer home screens rather than the live launcher, a limitation the developer acknowledged and which press coverage repeated.[5][6]

This article describes the observable behaviour, a reconstruction of the mathematics that reproduces it, and working implementation patterns on Android, macOS and the web. No part of Apple's implementation is public, and nothing below should be read as a description of Apple's own shader code.

1. Hardware context

The iPhone Duo pairs a 7.6-inch inner Super Retina XDR panel with a nano-texture finish and a 5.4-inch outer panel covering roughly ninety percent of the iPhone 18 Pro screen area. Both run ProMotion and Always On, with peak brightness quoted at 3,000 nits. The device uses the A20 Pro, a six-core CPU with a seven-core GPU and a dual 16-core Neural Engine, cooled by a vapour chamber, and Apple claims up to thirty-five percent better sustained performance than the iPhone 17 Pro. A hinge assembled from more than one hundred components supports the inner display, and the two halves carry separate batteries. Pricing starts at $1,999, with pre-orders from 16 October 2026.[1]

Two hardware details matter for the animation specifically. The first is sustained GPU headroom, because a per-pixel blur across a 7.6-inch panel at 120 Hz is not a cheap effect to run for the length of a hinge movement. The second is the hinge itself. A transition that claims to track physical geometry is only convincing if the angle reported by the hardware is smooth and low-latency; a noisy or coarsely quantised sensor produces visible stepping that no amount of shader work will hide.

SpecificationiPhone Duo
Inner display7.6 in Super Retina XDR, nano-texture, ProMotion, Always On
Outer display5.4 in Super Retina XDR, ProMotion, Always On
Peak brightness3,000 nits
ChipA20 Pro, 6-core CPU, 7-core GPU, dual 16-core Neural Engine
Rear cameras48 MP Fusion Main with 2x optical telephoto, 48 MP Ultra Wide
Battery lifeUp to 31 h video on inner display, 44 h on outer, 24 h mixed
Charging50 percent in about 20 min wired, about 30 min wireless
SoftwareiOS 27.1 at launch
PriceFrom $1,999, 256 GB to 2 TB

2. Observable behaviour

From hands-on coverage and the launch material, the transition can be decomposed into five simultaneous effects. None of them is individually novel. The combination, and the fact that all five are parameterised by one continuous physical value, is what produces the impression of a single bending surface.

  1. Progressive blur concentrated near the fold, at its strongest around the midpoint of the movement.
  2. Darkening across the same band, which reads as a shadow cast into the crease.
  3. Partial transparency, so that the moving half appears to be something you are looking through rather than at.
  4. Perspective compression, with content narrowing toward the hinge as the panel rotates away from the viewer.
  5. A cross-dissolve between the content appropriate to the closed state and the content appropriate to the open state.

The crucial property is reversibility. Stopping the movement partway leaves the effect frozen at the matching intermediate state, and reversing direction reverses the effect. A time-based animation cannot do this without additional machinery, because its progress variable has no relationship to where the device physically is.

0 ms500 ms closedclosed open 1.01.0 Time driven Hinge driven user reverses here
Figure 1. A time-driven transition traverses its curve once and cannot be interrupted meaningfully. A hinge-driven transition is a function of position, so the same curve is walked forwards and backwards as the user moves the device.

3. The hinge angle as a control signal

3.1 Sensor availability

Android has exposed a hinge angle sensor since API level 30. The constant Sensor.TYPE_HINGE_ANGLE reports the angle between the two halves of a foldable device in degrees, delivered as an ordinary sensor event stream.[7] Range and rest values differ between devices, which is why production code should read them from the sensor object rather than assuming zero to one hundred and eighty.

On macOS the equivalent signal is the internal lid angle sensor, reachable as an IOHIDDevice with vendor identifier 0x05AC and product identifier 0x8104. The macTilt project polls it at 60 Hz and exposes configurable start and end angles, for example beginning the fold effect at 80 degrees and completing it at 3 degrees.[3] Apple does not document this device publicly, so the approach is reverse-engineered rather than supported.

3.2 Normalisation

Whatever the source, the raw angle is converted to a bounded progress value before it reaches any rendering code:

p = clamp( (θ − θclosed) / (θopen − θclosed), 0, 1 )

giving p = 0 when closed, p = 0.5 at the halfway point and p = 1 when fully open. Keeping normalisation in one place has a practical benefit beyond tidiness: the shader then contains no device-specific constants, and the same shader runs unchanged on hardware whose hinge reports a different range.

A detail easy to miss is smoothing. Raw hinge readings jitter, and a jittering uniform produces a visible shimmer in the blur band. A small exponential filter is usually enough:

p_smooth ← p_smooth + α · (p_raw − p_smooth),   α ≈ 0.25

Too much smoothing and the effect lags behind the hand, which destroys the illusion more thoroughly than jitter does. The value of α is worth tuning on real hardware rather than guessing.

4. Reconstructed shader model

Scope. The formulas in this section are a reconstruction. They reproduce the observable effect and are consistent with the developer's own description of the Android recreation, but they are not Apple's implementation, which has not been published.

Android's AGSL, the Android Graphics Shading Language, lets a RuntimeShader compute an output colour for every pixel inside the platform's rendering pipeline, with values supplied from application code as uniforms.[8] That is the mechanism that makes a continuously updated sensor value usable as an animation driver.

4.1 Hinge-centred mask

Place the hinge at normalised horizontal coordinate h, typically 0.5. For a pixel at normalised horizontal position x:

d = |x − h|     M(x) = 1 − smoothstep(0, w, d)

The parameter w controls the width of the transition band. Near the hinge M approaches 1; away from it M falls to 0. Every subsequent effect is multiplied by this mask, which is what keeps the distortion attached to the crease instead of washing over the whole panel.

4.2 Progressive blur

Blur strength is a function of progress, not a constant:

B(p) = Bmax · sin(π p)

which yields B(0) = 0, B(0.5) = Bmax and B(1) = 0. Closed is sharp, half open is softest, fully open is sharp again. Combined with the mask:

B(x, p) = M(x) · Bmax · sin(π p)

For calibration, the Three.js study of the same effect uses a maximum blur radius of 72 source pixels and applies darkening at twice the blur intensity, clamped to black.[4] That ratio is a useful starting point rather than a rule.

00.51 B_max B(p) = B_max · sin(π p) 0h1 1.0 M(x), fold mask width w
Figure 2. Left, blur strength against hinge progress. Right, the spatial mask that confines the effect to the fold band. The final blur applied to any pixel is the product of the two.

4.3 Dissolve and transparency

Part of the moving panel reads as translucent. Modelling opacity as

α(x, p) = 1 − k · M(x) · sin(π p)

with k = 0.65 gives roughly alpha 1.00 in untouched regions and alpha 0.35 inside the fold band at the midpoint. No transparent panel exists in the hardware. The effect is entirely a rendering illusion, and it works because the viewer has no independent evidence about what is behind the moving half.

4.4 Two-image interpolation

The Android recreation crossfades between a capture of the outer display and a capture of the inner one:[2]

C(x, y, p) = mix( Couter(x, y), Cinner(x, y), t )

The mix factor need not equal hinge progress. Passing it through cubic smoothstep

t = S(p) = 3p² − 2p³

gives zero derivative at both ends, which removes the mechanical quality of a linear crossfade. Perceptually the difference is small at the extremes and noticeable in the middle third of the movement.

4.5 Perspective compression

Blur alone reads as a filter applied on top of the interface. For the content to read as attached to a rotating surface, its geometry has to change too. A simplified projection term:

s(θ) = |cos θ|    x′ = h + (x − h) · s(θ)    x″ = x′ + k · sin θ

The first expression narrows the content toward the hinge as the surface turns away from the viewer. The second adds a lateral offset so that content appears to slide behind the fold rather than shrink in place. The Three.js study handles the same problem differently, rotating only the cover half while holding the rear-camera half fixed and keeping a front-view projection of the screen content during the fold.[4]

4.6 Lighting and specular edge

A real folded surface is not uniformly lit. Darkening the fold band and then laying a narrow highlight over it approximates curved glass:

L(x, p) = 1 − λ · M(x) · sin(π p)    Clit = C · L H(x) = exp( −(x − h)² / 2σ² )    Cfinal = Clit + q · H(x)

The result reads from the outside in as normal surface, dark crease, thin bright line. This is the step most amateur recreations omit, and its absence is usually why a technically correct blur still looks flat.

hinge sensor θ normalise → p ∈ [0,1] geometry transform transition progress AGSL RuntimeShader blur alpha lighting outer ↔ inner mix GPU output, per pixel
Figure 3. The full pipeline. One sensor value fans out into geometry and progress, both of which enter a single shader invocation that resolves blur, alpha, lighting and image mixing per pixel.

Combined model

p       = clamp(θ / 180, 0, 1)
M(x)    = 1 − smoothstep(0, w, |x − h|)
B(x,p)  = B_max · M(x) · sin(π p)
A(x,p)  = 1 − k · M(x) · sin(π p)
T(p)    = 3p² − 2p³
C       = (1 − T) · C_outer + T · C_inner
C_final = Blur( Transform(C, θ), B ) · A · L

5. Implementation

The following is a minimal but complete path from sensor to screen on Android 13 or newer. It is written to be readable rather than to be dropped into production unchanged.

5.1 Reading the sensor in Kotlin

HingeProgress.kt

import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow

/**
 * Emits normalised hinge progress in 0..1.
 * 0 = closed, 1 = fully open. Emits nothing if the device has no hinge.
 */
fun hingeProgress(context: Context, smoothing: Float = 0.25f): Flow<Float> = callbackFlow {
    val manager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
    val hinge = manager.getDefaultSensor(Sensor.TYPE_HINGE_ANGLE)

    if (hinge == null) { close(); return@callbackFlow }

    // Do not hardcode 0..180. Ask the sensor what it reports.
    val maxAngle = hinge.maximumRange.takeIf { it > 0f } ?: 180f
    var filtered = Float.NaN

    val listener = object : SensorEventListener {
        override fun onSensorChanged(event: SensorEvent) {
            val raw = (event.values[0] / maxAngle).coerceIn(0f, 1f)
            filtered = if (filtered.isNaN()) raw
                       else filtered + smoothing * (raw - filtered)
            trySend(filtered)
        }
        override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
    }

    manager.registerListener(listener, hinge, SensorManager.SENSOR_DELAY_GAME)
    awaitClose { manager.unregisterListener(listener) }
}

Two things are deliberate here. SENSOR_DELAY_GAME rather than SENSOR_DELAY_FASTEST, because the faster rate costs power without producing a visibly better result at 120 Hz. And maximumRange rather than a literal 180, because not every hinge reports the same span.

5.2 The AGSL shader

AGSL is close enough to GLSL fragment shading that the structure will look familiar, with the important difference that the entry point receives a coordinate and returns a colour, and inputs arrive as uniform declarations.[8]

fold.agsl

uniform shader outerImage;   // capture of the cover screen
uniform shader innerImage;   // capture of the inner screen
uniform float2 size;         // surface size in pixels
uniform float  progress;     // p, normalised hinge 0..1
uniform float  hingeX;       // h, normalised 0..1, usually 0.5
uniform float  bandWidth;    // w
uniform float  maxBlur;      // B_max, in pixels
uniform float  alphaK;       // k
uniform float  darkK;        // lambda
uniform float  gleamK;       // q

// cubic smoothstep easing, zero velocity at both ends
float ease(float t) { return t * t * (3.0 - 2.0 * t); }

// 9-tap separable-ish sample. Cheap, and good enough at these radii.
half4 blurSample(shader img, float2 uv, float radius) {
    if (radius < 0.5) return img.eval(uv);
    half4 sum = half4(0.0);
    float total = 0.0;
    for (int i = -4; i <= 4; i++) {
        float o = float(i) / 4.0;
        float wgt = exp(-o * o * 2.0);
        sum   += img.eval(uv + float2(o * radius, 0.0)) * half(wgt);
        total += wgt;
    }
    return sum / half(total);
}

half4 main(float2 fragCoord) {
    float2 uv = fragCoord;
    float  x  = fragCoord.x / size.x;

    // 1. hinge-centred mask
    float d    = abs(x - hingeX);
    float mask = 1.0 - smoothstep(0.0, bandWidth, d);

    // 2. fold envelope, peaks at p = 0.5
    float env = sin(3.14159265 * progress);

    // 3. perspective compression toward the hinge
    float theta = progress * 3.14159265;          // 0..pi across the movement
    float s     = abs(cos(theta * 0.5));
    float xp    = hingeX + (x - hingeX) * mix(1.0, s, mask);
    float2 warped = float2(xp * size.x, uv.y);

    // 4. blur, strongest in the band at mid fold
    float radius = maxBlur * mask * env;
    half4 outer = blurSample(outerImage, warped, radius);
    half4 inner = blurSample(innerImage, warped, radius);

    // 5. cross-dissolve with eased factor
    half4 color = mix(outer, inner, half(ease(progress)));

    // 6. lighting: darken the crease, then add a narrow gleam
    float light = 1.0 - darkK * mask * env;
    float sigma = bandWidth * 0.35;
    float gleam = exp(-(d * d) / (2.0 * sigma * sigma)) * gleamK * env;
    color.rgb = color.rgb * half(light) + half(gleam);

    // 7. partial transparency across the fold band
    color.a = color.a * half(1.0 - alphaK * mask * env);

    return color;
}

5.3 Driving it from Compose

FoldSurface.kt

@RequiresApi(Build.VERSION_CODES.TIRAMISU)
@Composable
fun FoldSurface(
    outer: ImageBitmap,
    inner: ImageBitmap,
    progress: Float,
    modifier: Modifier = Modifier
) {
    val shader = remember { RuntimeShader(FOLD_AGSL) }

    Box(
        modifier
            .fillMaxSize()
            .onSizeChanged { shader.setFloatUniform("size", it.width.toFloat(), it.height.toFloat()) }
            .graphicsLayer {
                shader.setFloatUniform("progress",  progress)
                shader.setFloatUniform("hingeX",    0.5f)
                shader.setFloatUniform("bandWidth", 0.18f)
                shader.setFloatUniform("maxBlur",   72f)   // matches the Three.js study
                shader.setFloatUniform("alphaK",    0.65f)
                shader.setFloatUniform("darkK",     0.45f)
                shader.setFloatUniform("gleamK",    0.12f)
                shader.setInputShader("outerImage", ImageShader(outer))
                shader.setInputShader("innerImage", ImageShader(inner))

                renderEffect = RenderEffect
                    .createRuntimeShaderEffect(shader, "outerImage")
                    .asComposeRenderEffect()
                clip = true
            }
    )
}

// collection site
val progress by hingeProgress(context).collectAsState(initial = 0f)
FoldSurface(outer = coverCapture, inner = innerCapture, progress = progress)

Uniform updates inside graphicsLayer are cheap; the block re-runs when progress changes and does not recompose the tree above it. Rebuilding the RuntimeShader on every frame, by contrast, is the single most common performance mistake in this kind of code, which is why it sits behind remember.

5.4 Second display with Presentation

Android's Presentation class attaches content to a secondary display, and recent releases extend it to eligible built-in displays, which is what makes a foldable's cover panel addressable from an application.[9]

class FoldPresentation(
    context: Context,
    display: Display,
    private val progress: StateFlow<Float>
) : Presentation(context, display) {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(ComposeView(context).apply {
            setContent {
                val p by progress.collectAsState()
                FoldSurface(outer = coverCapture, inner = innerCapture, progress = p)
            }
        })
    }
}

// pick the display that is not the one we are already on
val dm = getSystemService(DisplayManager::class.java)
dm.displays
  .firstOrNull { it.displayId != windowManager.defaultDisplay.displayId }
  ?.let { FoldPresentation(this, it, progressFlow).show() }

Both surfaces read the same StateFlow, so there is exactly one progress value in the system. Two independently animated screens would drift apart within a few frames, and the drift is obvious to anyone looking at the device edge-on.

5.5 macOS and web equivalents

The same structure transfers. On macOS, macTilt polls the lid angle sensor at 60 Hz, captures the desktop with ScreenCaptureKit and renders the fold with Metal Shading Language, with a SwiftUI control panel for the trigger angles.[3] On the web there is no hinge to read, so the browser-based Three.js study substitutes a slider or a playback control for the sensor and keeps everything downstream of that identical.[4] The lesson is that only the first stage of the pipeline is platform-specific.

StageAndroidmacOSWeb
Angle sourceTYPE_HINGE_ANGLEIOHIDDevice 0x05AC / 0x8104slider or scroll position
Content capturescreenshots or window contentScreenCaptureKitDOM or texture
ShadingAGSL RuntimeShaderMetal Shading LanguageGLSL through Three.js
Second surfacePresentationper-screen NSWindowsecond canvas
Update ratesensor events, ~60 to 120 Hz60 Hz pollinganimation frame

6. Performance notes

A naive implementation samples the source image nine times per pixel per frame for a full-screen blur, on both displays, for as long as the hinge is moving. On a large inner panel that is a considerable amount of texture bandwidth. Several mitigations are standard.

  • Only shade the band. Outside the mask the shader can return the source sample directly, which the early return in the code above already does when the radius falls below half a pixel.
  • Downsample before blurring. Blurring a half-resolution copy and scaling back up is visually indistinguishable at these radii and roughly quarters the work.
  • Stop when the hinge stops. If p has not changed by more than a small epsilon, skip the frame entirely rather than redrawing an identical image.
  • Cache the captures. Re-capturing the inner and outer content on every frame defeats the point; capture once when the movement begins.

7. Limitations of the recreation

Coverage of the Galaxy Z Fold 8 demonstration frequently described it as Apple's feature arriving on Samsung hardware. That framing is inaccurate. The demonstration is an application that animates two captured images with a blur effect, and as reported, it cannot be built into SystemUI in that form.[5] Independent coverage made the same point about it being a demo rather than a system-wide capability.[6]

Since it is an animation of two screenshots with a blur effect, it cannot be built into the SystemUI. Characterisation of the demonstration's limits, as reported by 9to5Google[5]

The gap between demo and platform feature is not a matter of polish. A system-wide version requires every application window to hand its content to the same compositing stage at the same progress value, which is access only the platform owner can grant itself. That is the reason the effect shipped first as an Apple feature rather than as an Android application, and it is also why the honest description of the recreation is that it reproduces the appearance, not the integration.

8. The viewing-angle problem

The developer noted that the illusion is most convincing viewed straight on, and that the novelty gives way to mild irritation with repeated use. Both observations point at the same structural gap in the model. The shader knows the hinge angle. It does not know where the viewer's eyes are.

Image = f( θhinge, θviewer, x, y )

At θviewer = 0 the simulated perspective and the physical perspective agree and the effect holds. Off-axis they diverge, because the shader continues to draw a fold as seen head-on while the eye sees the panel obliquely. This is why a mathematically sound effect can look subtly wrong from the side, and it is a property of the approach rather than a bug in any particular implementation.

θ_viewer = 0 geometry agrees θ_viewer ≠ 0 geometry diverges
Figure 4. The shader simulates one fixed viewpoint. Closing the gap would require face tracking and a per-viewer projection, a cost that is difficult to justify for a wallpaper transition.

Whether that cost is ever worth paying is a product question rather than a technical one. Head-tracked rendering exists and is well understood; the objection is power draw and the awkwardness of a wallpaper effect that needs the front camera running.

9. Video

The clip below shows the transition as it actually runs. The original Galaxy Z Fold 8 demonstration is hosted on Reddit by its creator and is not reproduced here; the links point to the source instead.

Figure 5. The fold transition recorded on device, 720 by 1280 at 30 fps, 11 seconds. Watch the band at the crease rather than the whole screen: the blur rises to a maximum around the midpoint and falls away again as the panel reaches either rest position, which is the sin(πp) envelope described in section 4.2.

The clip is portrait because the effect is best judged at the device's own proportions. On a wide screen a full-width version of a 9:16 recording is mostly empty space on either side, so the player above is capped at roughly phone width and centred. If you later record a landscape version, add the class wide to the figure and the same markup switches to 16:9.

10. See also

11. References

  1. "Apple unveils iPhone Duo". Apple Newsroom. 9 September 2026. Retrieved 11 September 2026.
  2. "Tried to recreate the iPhone Duo animation on my Fold". r/GalaxyFold. 9 September 2026. Includes the developer's own description of the sensor, Presentation and AGSL approach.
  3. lqSky7, "iphone-duo-macos-animation (macTilt)". GitHub. Swift, Metal Shading Language, ScreenCaptureKit, 60 Hz lid angle polling.
  4. chuspeeism, "iphone-duo". GitHub. Browser study of foldable screen transitions using Three.js, 72-pixel maximum blur radius. Live build at iphone-duo-tawny.vercel.app.
  5. "Someone recreated the iPhone Duo's mesmerizing open animation on the Galaxy Z Fold 8". 9to5Google. 10 September 2026.
  6. "iPhone Duo cover animation could be recreated on the Samsung Galaxy Z Fold 8, but there are limitations". Sportskeeda Tech. September 2026.
  7. "Sensor: TYPE_HINGE_ANGLE". Android Developers reference. Available since API level 30.
  8. "AGSL, Android Graphics Shading Language". Android Developers. Per-pixel colour computation via RuntimeShader, with uniforms supplied from application code.
  9. "Presentation". Android Developers reference. Placing content on a secondary display.
  10. "iPhone Duo vs Galaxy Z Fold 8: User recreates Apple's unfolding animation on Samsung foldable". Business Today. 11 September 2026.
  11. "Reddit User Recreates iPhone Duo Fold Animation on Galaxy Z Fold 8". The Mac Observer. September 2026.
  12. "Someone brought iPhone Duo animations to the Galaxy Z Fold 8". Sammy Fans. 10 September 2026.
  13. "This Open-Source Mac App Brings the iPhone Duo's Fold Animation to MacBooks". Smartprix. September 2026.
  14. "Apple Unfolds the iPhone Duo". TidBITS. 11 September 2026.
  15. "Everything Apple announced at the foldable iPhone Duo launch". Engadget. September 2026.

12. External links

Comments

Popular posts from this blog

Explore - IT

GTA 6 Map Leak Explained: Vice City, Leonida, CyberLeek Claims & What’s Confirmed

Cursor Origin vs GitHub: Is Cursor’s New Git Hosting a Real GitHub Alternative in 2026?