Part 2 of three. Part 1 covers what the app is and the tech stack. This one goes deep on two features. Part 3 is the full end-to-end build.


Two things people ask about most: the peel-off grab (sticker lifts with a shadow when you touch it) and the holographic shimmer (tilt your phone and stickers glimmer like foil cards). Both took more work than I expected.

Feature 1: The peel-off grab

When you touch a sticker, I wanted it to feel like peeling vinyl off a sheet. That means several things have to happen at once.

What happens when you grab a sticker

Four properties animate simultaneously the moment your finger touches down:

  1. The sticker scales up to 1.08x. It's coming toward you.
  2. It tilts -6 degrees on the X axis. The top edge lifts first, like peeling from the top.
  3. It translates to 8 pixels. Physical lift.
  4. The drop shadow grows larger and shifts downward. Farther from the surface means a bigger, softer shadow.

When you release, everything reverses.

The gesture system: three pointerInput blocks

Each sticker needs drag, pinch, rotate, single-tap, and double-tap. Compose doesn't have one detector that does all of these, so each DraggableSticker composable chains three separate pointerInput blocks.

The first block handles drag, pinch, and rotate:

.pointerInput(Unit) {
    detectTransformGestures { _, pan, zoom, gestureRotation ->
        if (!isDragging) {
            isDragging = true
            haptics.perform(HapticType.LightTap)
        }
        pinchScale = (pinchScale * zoom).coerceIn(0.5f, 3f)
        rotation += gestureRotation
        offset += pan
    }
}

detectTransformGestures gives you deltas each frame: how far the centroid moved (pan), how much fingers spread (zoom as a multiplier), and the angle change between two fingers (gestureRotation). The zoom multiplier means you multiply the existing scale, not set it. 1.1 means "10% bigger than before."

The second block detects the drop (all fingers lifted):

.pointerInput(Unit) {
    awaitPointerEventScope {
        while (true) {
            val event = awaitPointerEvent()
            if (event.changes.all { !it.pressed } && isDragging) {
                isDragging = false
                haptics.perform(HapticType.MediumImpact)
                onTransformChanged(offset.x, offset.y, pinchScale, rotation)
            }
        }
    }
}

Why? detectTransformGestures fires during the gesture but has no onGestureEnd callback. This raw pointer watcher fills that gap. It checks if every pointer has lifted while we were dragging. That's the "drop" moment, which triggers the heavier haptic and persists the new position.

The third block handles taps:

.pointerInput(Unit) {
    detectTapGestures(
        onTap = {
            haptics.perform(HapticType.SelectionClick)
            onTapped()
        },
        onDoubleTap = {
            haptics.perform(HapticType.MediumImpact)
            isZoomedIn = !isZoomedIn
        },
    )
}

Tap detection needs its own block because it watches for a quick down-then-up without movement. If you mixed it into the transform handler, every drag would also register as a tap.

Why don't these conflict? Each pointerInput block runs in its own coroutine. They process the same event stream concurrently. The transform handler waits for movement or a second finger before claiming the gesture. The tap handler waits for a quick lift. If you drag, transforms win. If you tap, taps win. Block 2 is passive: it never consumes events, just watches.

Spring physics: why not tween

Every animation in the peel effect uses spring() instead of a duration-based tween(). The difference matters.

A tween(300.millis) has a fixed timeline. If you interrupt it (grab a sticker mid-bounce from a previous drop), the animation restarts awkwardly. Springs are velocity-aware. When interrupted, they pick up the current position and velocity and continue naturally. You can grab a bouncing sticker and it doesn't stutter.

Compose's spring() takes two parameters:

  • dampingRatio: below 1.0 = bouncy (undershoots target, oscillates). 1.0 = fastest path without overshoot. Above 1.0 = sluggish.
  • stiffness: how fast it responds. Higher = snappier.

The peel-off effect uses four springs with slightly different parameters:

// Scale: bouncy pop
val peelScale by animateFloatAsState(
    targetValue = if (isDragging) 1.08f else 1f,
    animationSpec = spring(dampingRatio = 0.55f, stiffness = 300f),
)

// Tilt: slightly bouncier, slightly slower
val peelRotationX by animateFloatAsState(
    targetValue = if (isDragging) -6f else 0f,
    animationSpec = spring(dampingRatio = 0.5f, stiffness = 250f),
)

// Lift: less bouncy, smooth
val peelTranslateY by animateFloatAsState(
    targetValue = if (isDragging) -8f else 0f,
    animationSpec = spring(dampingRatio = 0.6f, stiffness = 300f),
)

// Shadow: nearly critically damped, no bounce
val liftFraction by animateFloatAsState(
    targetValue = if (isDragging) 1f else 0f,
    animationSpec = spring(dampingRatio = 0.7f, stiffness = 200f),
)

The staggered damping is intentional. Scale (0.55) overshoots more than tilt (0.50), which overshoots more than translate (0.60). They all start at the same instant but settle differently. The sticker pops up, then tilts, then the lift smooths out. If you're not looking for it you won't notice the stagger consciously, but the motion feels less robotic than if everything animated identically.

Applying the transforms

These four values feed into graphicsLayer:

.graphicsLayer {
    scaleX = combinedScale * peelScale
    scaleY = combinedScale * peelScale
    rotationZ = rotation           // user's pinch rotation
    rotationX = peelRotationX      // peel tilt
    translationY = peelTranslateY  // peel lift
    cameraDistance = 12f * density
}

cameraDistance = 12f * density matters. rotationX tilts the sticker in 3D space. Without setting the camera distance, the default perspective makes the tilt look warped and flat. Pushing the camera back gives a subtler, more physical-looking tilt.

The dynamic shadow

The shadow is what makes the peel effect actually convincing. It's driven by liftFraction (0 when resting, 1 when fully lifted):

val shadowAlpha = 0.06f + liftFraction * 0.06f   // darker when lifted
val shadowSpread = outlinePx + (1.dp + liftFraction * 3.dp)  // wider when lifted
val shadowOffsetY = 1.dp + liftFraction * 3.dp    // shifts down when lifted

Real contact shadows work this way. An object resting on a surface casts a tight, dark shadow. Lift it and the shadow gets softer, wider, and offsets downward. The interpolation from liftFraction handles the transition smoothly.


Feature 2: The holographic shimmer

Tilt your phone and every sticker glimmers with an iridescent effect. Building this meant understanding some actual optics.

The three layers

Real holographic foil gets its look from three optical phenomena happening at once. I simulate all three and composite them.

First, thin-film iridescence. When light hits a thin transparent film (soap bubble, oil slick, holographic foil), some reflects off the top surface and some off the bottom. These two reflected waves interfere. At certain angles, specific wavelengths add up (constructive interference) and others cancel (destructive interference). Tilting the surface changes which wavelengths survive. That's why you see shifting rainbow bands.

In code, I approximate this with phase-shifted cosine waves:

vec3 iridescence(float t) {
    float phase = t * 6.2832;  // 2*PI
    return vec3(
        0.7 + 0.3 * cos(phase + 0.0),     // red
        0.7 + 0.3 * cos(phase + 2.094),    // green, 120° offset
        0.75 + 0.25 * cos(phase + 4.189)   // blue, 240° offset
    );
}

Each color channel peaks at a different value of t. As t varies across the sticker (driven by tilt and pixel position), you get a color sweep through lavender, sky blue, mint, cream, blush. The 0.7 + 0.3*cos() range keeps everything pastel. Real holographic foil is desaturated, not neon.

Second, specular reflection. A bright spot where light bounces directly toward your eye. On holographic foil this is a white glint that slides around as you tilt.

float2 specCenter = float2(0.5 + roll * 0.4, 0.5 + pitch * 0.4);
float dist = distance(uv, specCenter);
float specular = exp(-dist * dist * 8.0);

Gaussian falloff centered on a tilt-driven point. Bright in the middle, fades smoothly.

Third, Fresnel edge glow. Surfaces are more reflective at grazing angles. Look straight down at water and you see through it. Look across at a shallow angle and it's a mirror. On stickers, the edges glow slightly brighter.

float edgeDist = distance(uv, float2(0.5, 0.5)) * 2.0;
float fresnel = pow(clamp(edgeDist, 0.0, 1.0), 2.5);

Distance from center approximates viewing angle. The pow(_, 2.5) exponent concentrates the effect at the edges.

The AGSL shader (Android 13+)

On Android API 33+, all three layers run in one AGSL shader on the GPU:

uniform float2 resolution;
uniform float2 tilt;  // roll, pitch, each -1..1

half4 main(float2 fragCoord) {
    float2 uv = fragCoord / resolution;
    float roll = tilt.x;
    float pitch = tilt.y;
    float tiltMag = clamp(length(tilt), 0.0, 1.0);

    // Iridescent sweep, angle rotates with roll
    float angle = radians(45.0 + roll * 45.0);
    float2 dir = float2(cos(angle), sin(angle));
    float gradientT = dot(uv - 0.5, dir) + 0.5 + pitch * 0.3;
    vec3 iriColor = iridescence(gradientT);
    float iriAlpha = 0.12 + tiltMag * 0.06;

    // Specular glint, tracks tilt position
    float2 specCenter = float2(0.5 + roll * 0.4, 0.5 + pitch * 0.4);
    float dist = distance(uv, specCenter);
    float specular = exp(-dist * dist * 8.0);
    float specAlpha = specular * 0.25;

    // Fresnel edge glow
    float edgeDist = distance(uv, float2(0.5, 0.5)) * 2.0;
    float fresnel = pow(clamp(edgeDist, 0.0, 1.0), 2.5);
    float fresnelAlpha = fresnel * (0.03 + tiltMag * 0.06);

    // Composite
    vec3 color = iriColor * iriAlpha
               + vec3(1.0) * specAlpha
               + vec3(1.0) * fresnelAlpha;
    float alpha = iriAlpha + specAlpha + fresnelAlpha;

    return half4(half3(color), half(clamp(alpha, 0.0, 0.45)));
}

The clamp(alpha, 0.0, 0.45) cap prevents the overlay from washing out the sticker content. Where all three layers overlap, combined alpha could hit near 1.0 without the cap.

The gradient direction rotates with roll. At rest it runs diagonal (45 degrees). Tilt left and it goes horizontal. Tilt right and it goes vertical. dot(uv - 0.5, dir) projects pixel position onto the direction vector. pitch * 0.3 shifts the gradient based on forward/back tilt.

The fallback (iOS + older Android)

No AGSL on iOS or Android below 33. The fallback uses three Compose ShaderBrush subclasses that produce the same visual with LinearGradientShader and RadialGradientShader:

class HolographicFallbackNode(
    override var tiltState: State<TiltData>,
) : HolographicBaseNode() {
    override fun ContentDrawScope.draw() {
        drawContent()

        val tilt = tiltState.value
        val roll = tilt.roll.coerceIn(-1f, 1f)
        val pitch = tilt.pitch.coerceIn(-1f, 1f)

        // Layer 1: Iridescent gradient
        drawRect(
            brush = IridescentBrush(angleDeg = 45f + roll * 45f,
                offset = Offset(roll * size.width * 0.4f, pitch * size.height * 0.4f)),
            alpha = 0.12f + tiltMagnitude * 0.06f,
            blendMode = BlendMode.SrcAtop,
        )

        // Layer 2: Specular glint
        drawRect(
            brush = SpecularBrush(0.5f + roll * 0.4f, 0.5f + pitch * 0.4f),
            alpha = 0.25f,
            blendMode = BlendMode.Screen,
        )

        // Layer 3: Fresnel edge glow
        drawRect(
            brush = FresnelBrush(intensity = fresnelAlpha * 2.5f),
            alpha = fresnelAlpha,
            blendMode = BlendMode.SrcAtop,
        )
    }
}

Three draw calls instead of one GPU pass. The AGSL version is smoother during fast tilts, but the fallback looks good in practice.

How the shader gets wired into Compose

The holographic effect is a single modifier call: .holographicShine(tiltState). Under the hood it uses Compose's modifier node system:

fun Modifier.holographicShine(tiltState: State<TiltData>): Modifier =
    this then HolographicShineElement(tiltState)

private data class HolographicShineElement(
    val tiltState: State<TiltData>,
) : ModifierNodeElement<HolographicBaseNode>() {
    override fun create(): HolographicBaseNode = createHolographicNode(tiltState)
    override fun update(node: HolographicBaseNode) {
        node.tiltState = tiltState
    }
}

createHolographicNode() is an expect/actual function. Android returns the AGSL node on API 33+ and the fallback on older versions. iOS always returns the fallback.

The node reads tiltState during drawing. When the tilt value changes, Compose invalidates the draw pass for that node only. No recomposition of the composable tree. This matters because the tilt sensor fires 30 times per second on every sticker simultaneously.


Next up

Part 3 covers everything else: the die-cut outline rendering technique, tilt sensor bridging across platforms, the haptic feedback system, DataStore persistence with debounced saves, and the full expect/actual architecture.

Read Part 3: The full end-to-end build


Built with Kotlin 2.1 and Compose Multiplatform 1.7.