In this post, I’ll walk you through how to create a custom shader in Three.js that simulates the look of a foil sticker, complete with angle-dependent iridescence and sparkling metallic flakes. The goal is to capture that premium, holographic effect you see on collectible stickers, trading cards, and high-end packaging, but to render it in real time directly in the browser.

Foil sticker animation

Iridescence

If you’ve ever tilted a holographic sticker or watched sunlight catch on a soap bubble, you’ve seen iridescence in action. In the real world, this rainbow shimmer comes from thin-film interference. When light waves bounce between layers of a surface, some wavelengths are reinforced while others cancel out, causing colors to shift depending on your viewing angle.

In real-time computer graphics, we don’t need to simulate the exact physics. Instead, we can approximate this by mapping view angle to hue, as the surface tilts relative to the camera, its color smoothly shifts through a spectrum. This gives that dynamic, “alive” quality you expect from foil stickers.

Foil Flakes

Alongside the shifting colors, there’s another key detail: foil flakes. Real metallic foils have tiny reflective particles embedded in them, creating hundreds of bright, sharp highlights that twinkle as you move. These aren’t smooth reflections but randomized sparkles, giving the surface its tactile, premium feel.

To replicate this in a shader, we’ll introduce procedural noise to generate small random patches of brightness across the surface. When combined with lighting, they look like metallic specks catching the light. Together, angular hue shifts and flake sparkles create a convincing illusion of printed holographic foil without expensive rendering tricks.

Implementation

This implementation simulates a peeling, iridescent sticker with foil flakes using Three.js. While I will borrow concepts such as metalness, roughness, and Fresnel from Physically Based Rendering (PBR), this shader is not physically based. The goal is to create a visually plausible, artistic effect.

Below is a live demo of the shader, where you can modify its parameters and experiment with different configurations. Use your mouse to rotate the sticker around and see how the material reacts to the lighting.

Vertex Shader

The vertex shader handles the peel geometry and passes useful information to the fragment shader.

Uniform / VaryingTypePurpose
uPeelAmountfloatOverall peel strength (0 = flat, 1 = fully peeled).
uPeelAnglefloatPeel direction in degrees.
vUvvec2UV coordinates for texture mapping.
vWorldPosvec3Vertex position in world space.
vNormalvec3Transformed normal for lighting.
vAOIntensityfloatDistance moved by vertex, used to darken lifted areas.

The shader goes through the following simple steps:

  1. Compute vector from hinge to current vertex.
  2. Calculate the peel factor and angle.
  3. Define the rotation axis and apply Rodrigues’ rotation formula to rotate the vertex around that axis.
  4. Apply the same rotation to the normal.
  5. Calculate a fake ambient occlusion term.

Here’s the full vertex shader code:

uniform float uPeelAmount;  // Strength of peel (0.0 → no peel, 1.0 → full peel)
uniform float uPeelAngle;   // Peel angle in degrees (converted to radians in shader)
varying vec2  vUv;          // UV coordinates
varying vec3  vWorldPos;    // Vertex position in world space
varying vec3  vNormal;      // Transformed vertex normal
varying float vAOIntensity; // Ambient occlusion or peel intensity factor

void main() {
    vUv = vec2(uv.x, 1.0 - uv.y);
    vec3 pos = position;

    // Define hinge point for peel
    vec3 hinge = vec3(0.0, 0.0, 0.0);

    // Vector from hinge to current vertex
    vec3 toVertex = pos - hinge;

    // Peel factor calculation
    // Interpolates peel strength diagonally
    // (bottom-left → top-right)
    float peelFactor = (uv.x + uv.y) * 0.5;

    // Convert peel angle to radians
    // Final angle is scaled by peelAmount
    // and per-vertex peelFactor
    float radAngle = radians(uPeelAngle);
    float angle = radAngle * uPeelAmount * peelFactor;

    // Define rotation axis for peel
    // Diagonal axis pointing from top-left 
    // to bottom-right
    vec3 axis = normalize(vec3(-1.0, 1.0, 0.0));
    float cosA = cos(angle);
    float sinA = sin(angle);

    // Apply Rodrigues' rotation formula
    // Rotates the vertex around the diagonal axis
    vec3 rotated = toVertex * cosA +
                   cross(axis, toVertex) * sinA +
                   axis * dot(axis, toVertex) * (1.0 - cosA);

    // Update vertex position after rotation
    pos = hinge + rotated;

    // Rotate vertex normal the same way to
    // ensure lighting matches the peeled
    // geometry
    vec3 rotatedNormal = normal * cosA +
                         cross(axis, normal) * sinA +
                         axis * dot(axis, normal) * (1.0 - cosA);

    // Transform normal into view space
    vNormal = normalize(normalMatrix * rotatedNormal);

    // Transform vertex to world space
    vec4 worldPos = modelMatrix * vec4(pos, 1.0);
    vWorldPos = worldPos.xyz;

    // Ambient Occlusion term based on distance moved
    // from original vertex position
    vAOIntensity = length(toVertex - rotated);

    // Final projection
    gl_Position = projectionMatrix * viewMatrix * worldPos;
}

Fragment Shader

The fragment shader handles all lighting, reflections, iridescence, and foil flakes. It layers procedural effects to create a rich, dynamic look.

UniformTypePurpose
mapsampler2DSticker albedo + alpha.
envMap2Dsampler2DEnvironment map for reflections.
uCameraPosvec3Camera position for view vector.
uAlphaCutofffloatDiscard pixels below this alpha.
uFlakesEnabledfloatToggle foil flakes.
uFlakeSizefloatSize of flakes.
uFlakeReductionfloatRandomness threshold for flakes.
uFlakeThresholdfloatBrightness threshold to show flakes.
uFlakeBrightnessfloatBase brightness of flakes.
uMetalnessfloatPBR-like metal reflectivity control.
uRoughnessfloatControls reflection sharpness.
uEnvIntensityfloatScales environment contribution.
uMetalmaskfloatMask controlling metallic regions.
uIridescencefloatStrength of angle-dependent rainbow effect.
uIriMin, uIriRangefloatRange for simulated film thickness.
uPeelAmount, uPeelAnglefloatPeel geometry info for shading.

This is how this works:

  1. Alpha cutoff to discard transparent pixels early.
  2. Back-face shading to render the rear surface as plain white or darkened, depending on peel.
  3. Foil flakes are computed using procedural noise. Normals are perturbed slightly to create sparkle variation. The environment map is sampled to get an iridescent tint.
  4. Iridescence (thin-film approximation) is calculated using sine-based waves to shift hue by view angle.
  5. Environment reflections are modulated by Fresnel.
  6. Final shading combines diffuse base, reflections, iridescence, and flakes.

Here’s the full vertex shader code:

precision highp float;

#define PI  3.14159265

varying vec2 vUv;
varying vec3 vNormal;
varying vec3 vWorldPos;
varying float vAOIntensity;

uniform sampler2D map;      // sticker albedo + alpha
uniform sampler2D envMap2D; // LDR equirectangular environment

uniform vec3  uCameraPos;
uniform float uAlphaCutoff;
uniform float uMaxMip;
uniform float uFlakesEnabled;
uniform float uFlakeSize;
uniform float uFlakeReduction;
uniform float uFlakeThreshold;
uniform float uFlakeBrightness;
uniform float uPeelAmount;
uniform float uPeelAngle;
uniform float uMetalness;
uniform float uRoughness;
uniform float uEnvIntensity;
uniform float uMetalmask;
uniform float uIridescence;
uniform float uIriMin;
uniform float uIriRange;

float hash(vec2 p) {
    return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
}

// Map 3D dir to 2D equirect UV
vec2 dirToEquirectUv(vec3 dir) {
    dir = normalize(dir);
    float phi = atan(dir.z, dir.x);
    float theta = acos(clamp(dir.y, -1.0, 1.0));
    return vec2((phi + 3.14159265) / (2.0 * 3.14159265), theta / 3.14159265);
}

vec3 sampleEnvRough(vec3 R, float roughness) {
    vec2 uv = dirToEquirectUv(R);

    // Map roughness to LOD level
    float lod = roughness * uMaxMip;
    vec3 color = texture2DLodEXT(envMap2D, uv, lod).rgb;

    return color;
}

// Iridescence / thin-film color
vec3 iridescenceColor(float cosTheta) {
    float thickness = uIriMin + uIriRange * (1.0 - cosTheta);
    float phase = 6.28318 * thickness * 0.01; // scaled for visuals
    vec3 rainbow = 0.5 + 0.5 * vec3(sin(phase), sin(phase + 2.094), sin(phase + 4.188));
    return mix(vec3(1.0), rainbow, uIridescence);
}

// Convert RGB to perceived luminance (Rec.709)
float luminance(vec3 color) {
    return dot(color, vec3(0.2126, 0.7152, 0.0722));
}

void main() {

    vec4 base = texture2D(map, vUv);
    if(base.a < uAlphaCutoff)
        discard;

    if(!gl_FrontFacing) {
        float col = 1.0;
        if(uPeelAngle > 0.0) {
            col = mix(1.0, 0.2, vAOIntensity);
        }
        // Render back side as white
        gl_FragColor = vec4(vec3(col), base.a);
        return;
    }

    vec3 N = normalize(vNormal);
    vec3 V = normalize(uCameraPos - vWorldPos);
    vec3 R = reflect(-V, N);

    // Ambient occlusion / peel shadow
    float peelShadow = 0.0;

    if(uPeelAngle < 0.0) {
        peelShadow = smoothstep(0.0, 0.3, vAOIntensity);
        base.rgb *= mix(1.0, 0.3, peelShadow);
    }

    // Flakes
    float flakeIntensity = 0.0;
    vec3 flakeEnv = vec3(0.0);

    float brightness = luminance(base.rgb);

    if(uFlakesEnabled > 0.5) {
        // Procedural flake mask
        float flake = hash(floor(vUv * uFlakeSize));
        float flakeMask = smoothstep(uFlakeReduction, 1.0, flake);

        // Base brightness influence
        float flakeBoost = smoothstep(uFlakeThreshold, 1.0, brightness);

        // Perturbed flake normal
        float angleOffset = (hash(vec2(flake, flake + 3.0)) - 0.5) * 0.25;
        vec3 perturbedNormal = normalize(N + vec3(angleOffset, 0.0, angleOffset));

        // Reflection for sparkle
        vec3 PR = reflect(-V, perturbedNormal);

        // Dynamic flicker factor (only brightens, never darkens)
        float flakePhase = hash(floor(vUv * uFlakeSize) + floor(PR.xy * 15.0));
        float phaseMod = mix(1.0, 1.8, flakePhase);
        
        // Core sparkle factor (glimmer preserved)
        float flakeSpec = pow(clamp(dot(perturbedNormal, V) * 0.5 + 0.5, 0.0, 1.0), 8.0);
        flakeSpec = max(flakeSpec, 0.15); // always visible

        // Environment tint (never too dark, controlled by uniform)
        float flakeRough = clamp(uRoughness * 0.4, 0.0, 1.0);
        flakeEnv = sampleEnvRough(PR, flakeRough) * mix(0.9, 1.2, brightness);
        flakeEnv = max(flakeEnv, vec3(uFlakeBrightness));

        vec3 flakeIri = iridescenceColor(dot(perturbedNormal, V));
        flakeEnv *= mix(vec3(1.0), flakeIri, 0.9);

        // Final intensity
        flakeIntensity = flakeMask * flakeBoost * flakeSpec * phaseMod * 18.0;
        flakeIntensity = clamp(flakeIntensity, 0.0, 1.0);
    }

    // Final roughness modulation
    float finalRough = clamp(mix(uRoughness, 1.0, flakeIntensity), 0.0, 1.0);

    // Environment reflection
    vec3 env = sampleEnvRough(R, finalRough) * uEnvIntensity;

    // Blend in flake environment contribution
    env = mix(env, flakeEnv, clamp(flakeIntensity, 0.0, 1.0));

    // Fresnel term
    float cosTheta = clamp(dot(N, V), 0.0, 1.0);
    float F0 = mix(0.04, 1.0, uMetalness);
    float fres = F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0);

    // Iridescence
    float metalicMask = mix(uMetalmask, 1.0, brightness);
    vec3 iriCol = iridescenceColor(cosTheta) * metalicMask;

    // Final color
    vec3 diffuse = base.rgb * (1.0 - uMetalness);
    vec3 spec = env * fres * iriCol * (1.0 - finalRough * 0.85);
    vec3 color = diffuse + spec;

    gl_FragColor = vec4(color, base.a);
}

Licensing

The code in this page is licensed under Creative Commons Attribution-NonCommercial 4.0 International (CC BY-NC 4.0). Feel free to share and adapt the code for non-commercial purposes with proper attribution. If you wish to use the code commercially, please contact me for a separate license agreement.