Light crossing into a different medium has to bend, because it has to stay in step with itself. Tilt it far enough and it stops crossing at all: the surface becomes a perfect mirror. That is the whole reason fibre optics work.
EXP-027
Move the cursor over the canvas — it is part of the simulation.
Drag your finger across the canvas — it is part of the simulation.
What to look for
Move the cursor above the surface: the beam follows it, so you can walk the angle up by hand. Watch the reflected ray brighten as you approach 41.8° and the transmitted one flatten out and vanish. Then set n above to 1 and n below to 1.5 and try again: going into the denser medium, total internal reflection is impossible at any angle.
The maths
n₁ sin θ₁ = n₂ sin θ₂
Snell. It is not a rule about bending, it is a statement that the component of the wave along the surface has to match on both sides, otherwise the crests would not line up at the boundary.
θ_c = arcsin(n₂/n₁)
When n₁ > n₂ there is an angle past which the equation asks for sin θ₂ > 1, which has no solution. Physically, nothing crosses: total internal reflection. Glass to air gives 41.8°.
r_s = (n₁cosθ₁ − n₂cosθ₂)/(n₁cosθ₁ + n₂cosθ₂)
Snell says where the light goes; Fresnel says how much. This is the amplitude coefficient for one polarisation, and the reflected fraction is its square. At normal incidence on glass it gives 4%, which is why you see yourself in a window.
tan θ_B = n₂/n₁
Brewster's angle, where the other polarisation reflects nothing at all. That is what polarised sunglasses exploit: glare off water and roads is mostly one polarisation, and a filter can delete it.
How it is built
Both Fresnel coefficients are computed and averaged, which is what unpolarised light does. The dashes moving along each ray are spaced by the local wavelength, so you can see the transmitted beam physically shorten its wavelength inside the denser medium. Four limits were checked against hand calculation: 4.0% at normal incidence, 7.4% at Brewster, 89.1% approaching the critical angle, and exactly 100% past it.
The code, step by step
Cut straight from lab.js. These are the real lines that run above, not a simplified version.
Rearranging Snell gives sin θ₂ directly. When that comes out greater than 1 there is no angle that satisfies it, and the honest response in code is not to clamp it but to branch: total internal reflection is not an approximation, it is the equation having no solution.
Snell says where, Fresnel says how much. The two polarisations behave very differently — at Brewster's angle one of them reflects nothing at all — and unpolarised light is the average of the two. Checked against hand calculation at four angles including 4.0% at normal incidence, the number you know from window reflections.
Incident, reflected, transmitted, with opacity and width driven by the actual coefficients rather than picked for looks. So as you approach the critical angle the reflected ray really does swell and the transmitted one really does die, and past it the third ray is not drawn at all because there is nothing there.
Why it matters
A strand of fibre is just this, over and over: light enters shallow enough that every bounce is past the critical angle, so it cannot leak, and it travels kilometres inside a thread of glass. The internet runs on a boundary condition.
The whole thing
Everything above, in one piece. This is the entire experiment as it lives in lab.js.
FULL
lab.js · refraction
{ id: 'refraction',
name: L('Snell and the trapped ray', 'Snell y el rayo atrapado'),
note: L('Tilt the beam past the critical angle and the interface turns into a mirror.',
'Inclina el haz más allá del ángulo crítico y la interfaz se vuelve espejo.'),
params: [
{ key: 'n1', label: L('n above', 'n arriba'), min: 1, max: 2.6, step: 0.01, def: 1.5 },
{ key: 'n2', label: L('n below', 'n abajo'), min: 1, max: 2.6, step: 0.01, def: 1 },
{ key: 'ang', label: L('Angle of incidence', 'Ángulo de incidencia'), min: 1, max: 89, step: 0.5, def: 35 }
],
make() {
return {
step(ctx, w, h, t, acc, P, M) {
const iy = h * 0.52, ox = w * 0.5;
const n1 = P.n1, n2 = P.n2;
// El cursor manda sobre el deslizador cuando está encima
let th1 = (P.ang * Math.PI) / 180;
if (M.in && M.y < iy) th1 = Math.max(0.02, Math.min(1.55, Math.atan2(Math.abs(M.x - ox), Math.max(6, iy - M.y))));
const s2 = (n1 / n2) * Math.sin(th1);
const tir = s2 > 1;
const th2 = tir ? 0 : Math.asin(s2);
const critical = n1 > n2 ? Math.asin(n2 / n1) : null;
// Fresnel, polarización s y p
const c1 = Math.cos(th1), c2 = Math.cos(th2);
const rs = tir ? 1 : ((n1 * c1 - n2 * c2) / (n1 * c1 + n2 * c2)) ** 2;
const rp = tir ? 1 : ((n1 * c2 - n2 * c1) / (n1 * c2 + n2 * c1)) ** 2;
const R = (rs + rp) / 2, T = 1 - R;
// Medios
ctx.fillStyle = `rgba(${acc},0.07)`; ctx.fillRect(0, 0, w, iy);
ctx.fillStyle = `rgba(${acc},0.03)`; ctx.fillRect(0, iy, w, h - iy);
ctx.strokeStyle = `rgba(${acc},0.45)`; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, iy); ctx.lineTo(w, iy); ctx.stroke();
// Normal
ctx.setLineDash([3, 5]); ctx.strokeStyle = `rgba(${acc},0.28)`;
ctx.beginPath(); ctx.moveTo(ox, 12); ctx.lineTo(ox, h - 44); ctx.stroke();
ctx.setLineDash([]);
const Lr = Math.min(w, h) * 0.46;
const beam = (x1, y1, x2, y2, a, wd) => {
ctx.strokeStyle = `rgba(${acc},${a.toFixed(3)})`;
ctx.lineWidth = wd;
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
// Frentes de onda: se separan menos donde el índice es mayor
const dx = x2 - x1, dy = y2 - y1, len = Math.hypot(dx, dy);
const ux = dx / len, uy = dy / len, sp = 14;
const ph = ((t * 60) % sp);
ctx.lineWidth = 1;
for (let d = ph; d < len; d += sp) {
const px = x1 + ux * d, py = y1 + uy * d;
ctx.strokeStyle = `rgba(${acc},${(a * 0.5).toFixed(3)})`;
ctx.beginPath();
ctx.moveTo(px - uy * 4, py + ux * 4);
ctx.lineTo(px + uy * 4, py - ux * 4);
ctx.stroke();
}
};
// Incidente (llega desde arriba-izquierda)
beam(ox - Math.sin(th1) * Lr, iy - Math.cos(th1) * Lr, ox, iy, 0.95, 2);
// Reflejado
beam(ox, iy, ox + Math.sin(th1) * Lr, iy - Math.cos(th1) * Lr, 0.25 + R * 0.7, 1 + R * 2.5);
// Transmitido
if (!tir) beam(ox, iy, ox + Math.sin(th2) * Lr, iy + Math.cos(th2) * Lr, 0.25 + T * 0.7, 1 + T * 2.5);
// Cono crítico
if (critical !== null) {
ctx.setLineDash([2, 4]);
ctx.strokeStyle = `rgba(${acc},0.3)`;
ctx.beginPath();
ctx.moveTo(ox - Math.sin(critical) * Lr, iy - Math.cos(critical) * Lr);
ctx.lineTo(ox, iy);
ctx.stroke();
ctx.setLineDash([]);
ctx.font = '10px monospace';
ctx.fillStyle = `rgba(${acc},0.5)`;
ctx.fillText('θc = ' + ((critical * 180) / Math.PI).toFixed(1) + '°',
ox - Math.sin(critical) * Lr - 4, iy - Math.cos(critical) * Lr - 6);
}
ctx.font = '11px monospace';
ctx.fillStyle = `rgba(${acc},0.6)`;
ctx.fillText('n₁ = ' + n1.toFixed(2), 12, 20);
ctx.fillText('n₂ = ' + n2.toFixed(2), 12, iy + 20);
ctx.fillStyle = `rgba(${acc},0.55)`;
ctx.fillText('n₁ sin θ₁ = n₂ sin θ₂ reflectance from Fresnel', 12, h - 28);
ctx.fillStyle = `rgba(${acc},0.95)`;
ctx.fillText(tir
? 'θ₁ = ' + ((th1 * 180) / Math.PI).toFixed(1) + '° · total internal reflection · R = 100%'
: 'θ₁ = ' + ((th1 * 180) / Math.PI).toFixed(1) + '° θ₂ = ' + ((th2 * 180) / Math.PI).toFixed(1) +
'° R = ' + (R * 100).toFixed(1) + '% T = ' + (T * 100).toFixed(1) + '%', 12, h - 12);
}
};
}
},