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.
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.