Snell's Law
Interactive simulation of light refraction, critical angles, and total internal reflection.
How It Works
Snell's Law describes how light bends when passing between two transparent media with different refractive indices. The simulation shows the incident ray, refracted ray, and reflected ray in real-time.
Total Internal Reflection (TIR): When light travels from a denser to a less dense medium (n₁ > n₂) at an angle greater than the critical angle, all light reflects back. No refracted ray is transmitted.
Drag the ray endpoint on the canvas to adjust the incident angle, or use the slider. Change the refractive indices to see how different materials bend light. The reflectance percentage shows how much light is reflected vs. transmitted.
Core Algorithm & Standalone Script
Standalone, zero-dependency JavaScript implementation powering this tool. Free to inspect, copy, and build upon.
const canvas = document.getElementById('simulationCanvas');
const ctx = canvas.getContext('2d');
// Resize canvas
function resizeCanvas() {
const rect = canvas.parentElement.getBoundingClientRect();
canvas.width = rect.width - 40;
canvas.height = Math.max(500, rect.width * 0.6);
}
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
// State
let state = {
n1: 1.0,
n2: 1.33,
theta1: 30,
mode: 'single',
showRefracted: true,
showReflected: true,
showWavefronts: false,
showBeam: false,
tirOccurring: false
};
const interfaceY = canvas.height / 2;
const interfaceX = canvas.width / 2;
// Color scheme
const colors = {
incident: '#ffff00',
refracted: '#00c896',
reflected: '#ff8800',
normal: '#ffffff',
medium1: 'rgba(100, 150, 200, 0.05)',
medium2: 'rgba(150, 100, 200, 0.05)',
wavefront: '#ffffff'
};
// Event listeners
document.getElementById('medium1Select').addEventListener('change', (e) => {
state.n1 = parseFloat(e.target.value);
update();
});
document.getElementById('medium2Select').addEventListener('change', (e) => {
state.n2 = parseFloat(e.target.value);
update();
});
document.getElementById('angleSlider').addEventListener('input', (e) => {
state.theta1 = parseFloat(e.target.value);
document.getElementById('angleValue').textContent = state.theta1.toFixed(1) + '°';
update();
});
document.getElementById('refractedToggle').addEventListener('click', (e) => {
state.showRefracted = !state.showRefracted;
e.target.classList.toggle('active');
e.target.textContent = state.showRefracted ? '✓ Refracted Ray' : '○ Refracted Ray';
update();
});
document.getElementById('reflectedToggle').addEventListener('click', (e) => {
state.showReflected = !state.showReflected;
e.target.classList.toggle('active');
e.target.textContent = state.showReflected ? '✓ Reflected Ray' : '○ Reflected Ray';
update();
});
document.getElementById('wavefrontToggle').addEventListener('click', (e) => {
state.showWavefronts = !state.showWavefronts;
e.target.classList.toggle('active');
e.target.textContent = state.showWavefronts ? '✓ Wavefronts' : '○ Wavefronts';
update();
});
document.getElementById('beamToggle').addEventListener('click', (e) => {
state.showBeam = !state.showBeam;
e.target.classList.toggle('active');
e.target.textContent = state.showBeam ? '✓ Beam Mode' : '○ Beam Mode';
update();
});
document.querySelectorAll('.btn-mode').forEach(btn => {
btn.addEventListener('click', (e) => {
document.querySelectorAll('.btn-mode').forEach(b => b.classList.remove('active'));
e.target.classList.add('active');
state.mode = e.target.dataset.mode;
update();
});
});
canvas.addEventListener('click', (e) => {
const rect = canvas.getBoundingClientRect();
const x = (e.clientX - rect.left) * (canvas.width / rect.width);
const y = (e.clientY - rect.top) * (canvas.height / rect.height);
// Calculate angle from click position
const dx = x - interfaceX;
const dy = interfaceY - y;
let angle = Math.atan(dx / Math.abs(dy)) * (180 / Math.PI);
if (angle < 0) angle = -angle;
if (angle > 89.9) angle = 89.9;
state.theta1 = angle;
document.getElementById('angleSlider').value = angle;
document.getElementById('angleValue').textContent = angle.toFixed(1) + '°';
update();
});
function calculateRefraction(theta1Deg, n1, n2) {
const theta1Rad = theta1Deg * Math.PI / 180;
const sinTheta1 = Math.sin(theta1Rad);
// Critical angle
let thetaC = null;
let tir = false;
if (n1 > n2) {
const sinThetaC = n2 / n1;
if (sinThetaC <= 1) {
thetaC = Math.asin(sinThetaC) * (180 / Math.PI);
if (theta1Deg >= thetaC) {
tir = true;
}
}
}
let theta2Deg = null;
if (!tir) {
const sinTheta2 = (n1 / n2) * sinTheta1;
if (sinTheta2 <= 1) {
theta2Deg = Math.asin(sinTheta2) * (180 / Math.PI);
}
}
// Fresnel reflectance (simplified)
let reflectance = 0;
if (!tir && theta2Deg !== null) {
const theta1Rad = theta1Deg * Math.PI / 180;
const theta2Rad = theta2Deg * Math.PI / 180;
const n1c = n1 * Math.cos(theta1Rad);
const n2c = n2 * Math.cos(theta2Rad);
const r = (n1c - n2c) / (n1c + n2c);
reflectance = Math.pow(r, 2) * 100;
} else if (tir) {
reflectance = 100;
}
return {
theta2: theta2Deg,
thetaC: thetaC,
tir: tir,
reflectance: reflectance
};
}
function drawRay(x1, y1, x2, y2, color, width = 2, dashed = false) {
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.lineCap = 'round';
if (dashed) {
ctx.setLineDash([5, 5]);
}
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
ctx.setLineDash([]);
// Arrow
const angle = Math.atan2(y2 - y1, x2 - x1);
const arrowSize = 10;
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - arrowSize * Math.cos(angle - Math.PI / 6), y2 - arrowSize * Math.sin(angle - Math.PI / 6));
ctx.lineTo(x2 - arrowSize * Math.cos(angle + Math.PI / 6), y2 - arrowSize * Math.sin(angle + Math.PI / 6));
ctx.closePath();
ctx.fill();
}
function drawAngleArc(x, y, radius, startAngle, endAngle, color) {
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(x, y, radius, startAngle * Math.PI / 180, endAngle * Math.PI / 180);
ctx.stroke();
}
function drawAngleLabel(x, y, text, offset = 30) {
ctx.fillStyle = '#e8e0d5';
ctx.font = 'bold 13px DM Mono';
ctx.textAlign = 'center';
ctx.fillText(text, x + offset, y - offset);
}
function drawWavefronts(x, y, angle, n, direction, spacing = 20) {
const angleRad = angle * Math.PI / 180;
const perpAngle = angleRad + Math.PI / 2;
ctx.strokeStyle = colors.wavefront;
ctx.globalAlpha = 0.3;
ctx.lineWidth = 1;
for (let i = -300; i <= 300; i += spacing) {
const x1 = x + i * Math.cos(perpAngle) - 200 * Math.cos(angleRad) * direction;
const y1 = y + i * Math.sin(perpAngle) - 200 * Math.sin(angleRad) * direction;
const x2 = x + i * Math.cos(perpAngle) + 200 * Math.cos(angleRad) * direction;
const y2 = y + i * Math.sin(perpAngle) + 200 * Math.sin(angleRad) * direction;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
ctx.globalAlpha = 1;
}
function drawPrism() {
const prismX = canvas.width / 2;
const prismY = interfaceY;
const prismSize = 100;
ctx.fillStyle = 'rgba(200, 220, 255, 0.1)';
ctx.beginPath();
ctx.moveTo(prismX - prismSize, prismY);
ctx.lineTo(prismX + prismSize, prismY);
ctx.lineTo(prismX, prismY - prismSize * 1.5);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = '#ffff00';
ctx.lineWidth = 2;
ctx.stroke();
// Incident ray to first face
const theta1Rad = state.theta1 * Math.PI / 180;
const rayLength = 200;
const startX = prismX - rayLength * Math.sin(theta1Rad);
const startY = prismY - rayLength * Math.cos(theta1Rad);
drawRay(startX, startY, prismX - prismSize * 0.3, prismY - prismSize * 0.2, colors.incident, 2.5);
// Refracted ray inside prism
const refr = calculateRefraction(state.theta1, state.n1, state.n2);
if (refr.theta2 !== null) {
const theta2Rad = refr.theta2 * Math.PI / 180;
const exitX = prismX + prismSize * 0.3;
const exitY = prismY - prismSize * 0.2;
drawRay(prismX - prismSize * 0.3, prismY - prismSize * 0.2, exitX, exitY, colors.refracted, 2.5);
// Exit refracted ray
const theta3Rad = refr.theta2 * Math.PI / 180;
const exitEndX = exitX + rayLength * Math.sin(theta3Rad);
const exitEndY = exitY + rayLength * Math.cos(theta3Rad);
drawRay(exitX, exitY, exitEndX, exitEndY, colors.refracted, 2.5);
}
}
function drawLens() {
const lensX = interfaceX;
const lensY = interfaceY;
const lensHeight = 150;
const lensWidth = 30;
ctx.strokeStyle = '#ffff00';
ctx.lineWidth = 2;
// Convex lens (simplified)
ctx.beginPath();
ctx.arc(lensX - lensWidth / 2, lensY, lensHeight / 2, -Math.PI / 2, Math.PI / 2);
ctx.stroke();
ctx.beginPath();
ctx.arc(lensX + lensWidth / 2, lensY, lensHeight / 2, -Math.PI / 2, Math.PI / 2);
ctx.stroke();
// Parallel rays entering
const raySpacing = 30;
for (let i = -2; i <= 2; i++) {
const startY = lensY + i * raySpacing;
const startX = 20;
// Ray approaching lens
drawRay(startX, startY, lensX - lensWidth, startY, colors.incident, 1.5);
// Ray exiting (converging)
const focalLength = 80;
const exitX = lensX + lensWidth;
const angle = Math.atan2(startY - lensY, lensHeight / 2);
const endX = exitX + focalLength * Math.cos(angle);
const endY = exitY + focalLength * Math.sin(angle);
drawRay(exitX, startY, endX, endY, colors.refracted, 1.5);
}
// Focal point
ctx.fillStyle = '#ff2200';
ctx.beginPath();
ctx.arc(lensX + lensWidth + 80, lensY, 4, 0, Math.PI * 2);
ctx.fill();
}
function update() {
// Clear canvas
ctx.fillStyle = colors.medium1;
ctx.fillRect(0, 0, canvas.width, interfaceY);
ctx.fillStyle = colors.medium2;
ctx.fillRect(0, interfaceY, canvas.width, canvas.height - interfaceY);
// Interface line
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(0, interfaceY);
ctx.lineTo(canvas.width, interfaceY);
ctx.stroke();
// Labels for media
ctx.fillStyle = '#555555';
ctx.font = '12px DM Mono';
ctx.textAlign = 'left';
ctx.fillText(`n₁ = ${state.n1}`, 10, 25);
ctx.fillText(`n₂ = ${state.n2}`, 10, canvas.height - 10);
if (state.mode === 'prism') {
drawPrism();
} else if (state.mode === 'lens') {
drawLens();
} else {
// Single ray mode
const theta1Rad = state.theta1 * Math.PI / 180;
const rayLength = 250;
// Incident ray
const startX = interfaceX - rayLength * Math.sin(theta1Rad);
const startY = interfaceY - rayLength * Math.cos(theta1Rad);
drawRay(startX, startY, interfaceX, interfaceY, colors.incident, 2.5);
// Normal line
drawRay(interfaceX, 0, interfaceX, canvas.height, '#ffffff', 1, true);
// Calculate refraction
const refr = calculateRefraction(state.theta1, state.n1, state.n2);
// Update TIR alert
state.tirOccurring = refr.tir;
const tirAlert = document.getElementById('tirAlert');
if (refr.tir) {
tirAlert.classList.add('show');
} else {
tirAlert.classList.remove('show');
}
// Refracted ray
if (state.showRefracted && refr.theta2 !== null) {
const theta2Rad = refr.theta2 * Math.PI / 180;
const endX = interfaceX + rayLength * Math.sin(theta2Rad);
const endY = interfaceY + rayLength * Math.cos(theta2Rad);
drawRay(interfaceX, interfaceY, endX, endY, colors.refracted, 2.5);
// Wavefronts
if (state.showWavefronts) {
drawWavefronts(interfaceX, interfaceY - 50, state.theta1, state.n1, -1, 15);
drawWavefronts(interfaceX, interfaceY + 50, refr.theta2, state.n2, 1, 15);
}
// Angle arc for theta2
drawAngleArc(interfaceX, interfaceY, 40, 0, refr.theta2, colors.refracted);
drawAngleLabel(interfaceX, interfaceY, `θ₂=${refr.theta2.toFixed(1)}°`, 60);
}
// Reflected ray
if (state.showReflected) {
const reflectAlpha = refr.reflectance / 100;
ctx.globalAlpha = Math.max(0.3, reflectAlpha);
const endX = interfaceX + rayLength * Math.sin(theta1Rad);
const endY = interfaceY - rayLength * Math.cos(theta1Rad);
drawRay(interfaceX, interfaceY, endX, endY, colors.reflected, 2.5);
ctx.globalAlpha = 1;
// Angle arc for reflection
drawAngleArc(interfaceX, interfaceY, 40, state.theta1, 0, colors.reflected);
}
// Angle arc for theta1
drawAngleArc(interfaceX, interfaceY, 30, -state.theta1, 0, colors.incident);
drawAngleLabel(interfaceX, interfaceY, `θ₁=${state.theta1.toFixed(1)}°`, -50);
// Beam mode
if (state.showBeam) {
const beamSpacing = 15;
for (let i = -2; i <= 2; i++) {
if (i === 0) continue;
const offsetY = interfaceY - rayLength * Math.cos(theta1Rad) + i * beamSpacing;
const offsetX = i * beamSpacing;
// Incident
ctx.globalAlpha = 0.5;
drawRay(interfaceX - rayLength * Math.sin(theta1Rad) + offsetX, offsetY, interfaceX + offsetX, interfaceY, colors.incident, 1);
// Refracted
if (state.showRefracted && refr.theta2 !== null) {
const theta2Rad = refr.theta2 * Math.PI / 180;
drawRay(interfaceX + offsetX, interfaceY, interfaceX + rayLength * Math.sin(theta2Rad) + offsetX, interfaceY + rayLength * Math.cos(theta2Rad), colors.refracted, 1);
}
ctx.globalAlpha = 1;
}
}
}
// Update statistics
const refr = calculateRefraction(state.theta1, state.n1, state.n2);
document.getElementById('statTheta1').textContent = state.theta1.toFixed(1) + '°';
document.getElementById('statTheta2').textContent = refr.theta2 !== null ? refr.theta2.toFixed(1) + '°' : '—';
document.getElementById('statThetaC').textContent = refr.thetaC !== null ? refr.thetaC.toFixed(1) + '°' : '—';
document.getElementById('statReflectance').textContent = refr.reflectance.toFixed(1) + '%';
}
// Initial draw
update();