physics • optics

Snell's Law

Interactive simulation of light refraction, critical angles, and total internal reflection.

⚡ TOTAL INTERNAL REFLECTION
Medium 1 (Top)
Medium 2 (Bottom)
Ray Control
Visualization
Mode
Statistics
Incident
30°
Refracted
22°
Critical
49°
Reflectance
4%

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.

n₁ · sin(θ₁) = n₂ · sin(θ₂)

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.

θc = arcsin(n₂/n₁) [when n₁ > n₂]

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.

Developer Reference

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();