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. 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 / Varying Type Purpose uPeelAmount float Overall peel strength (0 = flat, 1 = fully peeled). uPeelAngle float Peel direction in degrees. vUv vec2 UV coordinates for texture mapping. vWorldPos vec3 Vertex position in world space. vNormal vec3 Transformed normal for lighting. vAOIntensity float Distance moved by vertex, used to darken lifted areas. The shader goes through the following simple steps: Compute vector from hinge to current vertex. Calculate the peel factor and angle. Define the rotation axis and apply Rodrigues’ rotation formula to rotate the vertex around that axis. Apply the same rotation to the normal. Calculate a fake ambient occlusion term. The Rodrigues rotation formula is a compact way to rotate a 3D vector around an arbitrary axis. It takes a vector \(\mathbf{v}\), a unit axis of rotation \(\mathbf{k}\), and an angle \(\theta\) and returns the rotated vector \(\mathbf{v}_{rot}\) \[\mathbf{v}_{\text{rot}} = \mathbf{v}\cos\theta + (\mathbf{k}\times\mathbf{v})\sin\theta + \mathbf{k}(\mathbf{k}\cdot\mathbf{v})(1-\cos\theta)\] \[\mathbf{v}_{\text{rot}} = \mathbf{v}\cos\theta + (\mathbf{k}\times\mathbf{v})\sin\theta + \mathbf{k}(\mathbf{k}\cdot\mathbf{v})(1-\cos\theta)\] 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. Uniform Type Purpose map sampler2D Sticker albedo + alpha. envMap2D sampler2D Environment map for reflections. uCameraPos vec3 Camera position for view vector. uAlphaCutoff float Discard pixels below this alpha. uFlakesEnabled float Toggle foil flakes. uFlakeSize float Size of flakes. uFlakeReduction float Randomness threshold for flakes. uFlakeThreshold float Brightness threshold to show flakes. uFlakeBrightness float Base brightness of flakes. uMetalness float PBR-like metal reflectivity control. uRoughness float Controls reflection sharpness. uEnvIntensity float Scales environment contribution. uMetalmask float Mask controlling metallic regions. uIridescence float Strength of angle-dependent rainbow effect. uIriMin , uIriRange float Range for simulated film thickness. uPeelAmount , uPeelAngle float Peel geometry info for shading. This is how this works: Alpha cutoff to discard transparent pixels early. Back-face shading to render the rear surface as plain white or darkened, depending on peel. 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. Iridescence (thin-film approximation) is calculated using sine-based waves to shift hue by view angle. Environment reflections are modulated by Fresnel. 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.