Maxwell's Equations

Interactive, animated walk-through of Maxwell's four equations — Gauss, no-monopoles, Faraday, Ampère–Maxwell — building to how E and B become light.

---
format: typebulb/v1
name: "Maxwell's Equations"
---

**code.tsx**

```tsx
import React, { useState, useEffect, useRef } from "react"
import { createRoot } from "react-dom/client"
import katex from "katex"

/* ---------- theme-aware palette (canvas can't read CSS vars cheaply) ---------- */
type Pal = {
  ink: string; muted: string; grid: string; faint: string; panel: string
  E: string; Eglow: string; B: string; Bglow: string
  pos: string; neg: string; cur: string; cur2: string; accent: string
}
const LIGHT: Pal = {
  ink: "#0f172a", muted: "#64748b", grid: "#e2e8f0", faint: "rgba(15,23,42,.06)", panel: "#ffffff",
  E: "#ef4444", Eglow: "rgba(239,68,68,.16)", B: "#2563eb", Bglow: "rgba(37,99,235,.16)",
  pos: "#f97316", neg: "#2563eb", cur: "#16a34a", cur2: "#f59e0b", accent: "#4d7c0f",
}
const DARK: Pal = {
  ink: "#e6e6e6", muted: "#a3a3a3", grid: "#2c2c2c", faint: "rgba(230,230,230,.06)", panel: "#1e1e1e",
  E: "#f87171", Eglow: "rgba(248,113,113,.20)", B: "#60a5fa", Bglow: "rgba(96,165,250,.20)",
  pos: "#fb923c", neg: "#60a5fa", cur: "#4ade80", cur2: "#fbbf24", accent: "#a3e635",
}
function readDark() {
  const attr = document.documentElement.getAttribute("data-theme")
  if (attr === "dark") return true
  if (attr === "light") return false
  return window.matchMedia("(prefers-color-scheme: dark)").matches
}
function usePal(): Pal {
  const [dark, setDark] = useState(readDark)
  useEffect(() => {
    const upd = () => setDark(readDark())
    const mo = new MutationObserver(upd)
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] })
    const mq = window.matchMedia("(prefers-color-scheme: dark)")
    mq.addEventListener("change", upd)
    return () => { mo.disconnect(); mq.removeEventListener("change", upd) }
  }, [])
  return dark ? DARK : LIGHT
}

/* ---------- keep the latest value in a ref so the rAF loop always reads current state ---------- */
function useLatest<T>(value: T) {
  const ref = useRef(value)
  ref.current = value
  return ref
}

/* ---------- animation loop with dpr scaling; always calls latest draw ---------- */
type Draw = (ctx: CanvasRenderingContext2D, w: number, h: number, t: number) => void
function useAnim(draw: Draw) {
  const ref = useRef<HTMLCanvasElement | null>(null)
  const drawRef = useRef(draw)
  drawRef.current = draw
  useEffect(() => {
    const canvas = ref.current!
    const ctx = canvas.getContext("2d")!
    let raf = 0
    const loop = (time: number) => {
      const dpr = window.devicePixelRatio || 1
      const w = canvas.clientWidth, h = canvas.clientHeight
      if (canvas.width !== Math.round(w * dpr) || canvas.height !== Math.round(h * dpr)) {
        canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr)
      }
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
      ctx.clearRect(0, 0, w, h)
      drawRef.current(ctx, w, h, time / 1000)
      raf = requestAnimationFrame(loop)
    }
    raf = requestAnimationFrame(loop)
    return () => cancelAnimationFrame(raf)
  }, [])
  return ref
}

/* ---------- KaTeX ---------- */
function Eq({ tex, block }: { tex: string; block?: boolean }) {
  const html = katex.renderToString(tex, { displayMode: !!block, throwOnError: false })
  return <span className={block ? "eq-block" : "eq-inline"} dangerouslySetInnerHTML={{ __html: html }} />
}

/* ---------- canvas drawing helpers ---------- */
function arrowHead(ctx: CanvasRenderingContext2D, x: number, y: number, ang: number, s: number, color: string) {
  ctx.save(); ctx.translate(x, y); ctx.rotate(ang); ctx.fillStyle = color
  ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(-s, s * 0.55); ctx.lineTo(-s, -s * 0.55); ctx.closePath(); ctx.fill()
  ctx.restore()
}
function vec(ctx: CanvasRenderingContext2D, x1: number, y1: number, x2: number, y2: number, color: string, lw = 2, head = 7) {
  const ang = Math.atan2(y2 - y1, x2 - x1)
  ctx.strokeStyle = color; ctx.lineWidth = lw; ctx.lineCap = "round"
  ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2 - Math.cos(ang) * head * 0.6, y2 - Math.sin(ang) * head * 0.6); ctx.stroke()
  arrowHead(ctx, x2, y2, ang, head, color)
}
/* legibility: solid panel-colored backdrop chip so on-canvas labels stay readable over the animation */
function haloText(ctx: CanvasRenderingContext2D, text: string, x: number, y: number, fill: string, halo: string, align: CanvasTextAlign = "left", font = "15px system-ui,sans-serif") {
  ctx.save()
  ctx.font = font; ctx.textAlign = align; ctx.textBaseline = "alphabetic"
  const m = ctx.measureText(text)
  const asc = m.actualBoundingBoxAscent || 9, desc = m.actualBoundingBoxDescent || 3
  const padX = 5, padY = 3
  const bx = align === "center" ? x - m.width / 2 - padX : align === "right" ? x - m.width - padX : x - padX
  ctx.globalAlpha = 0.85; ctx.fillStyle = halo
  ctx.beginPath()
  const r = 5, bw = m.width + padX * 2, by = y - asc - padY, bh = asc + desc + padY * 2
  if (ctx.roundRect) ctx.roundRect(bx, by, bw, bh, r); else ctx.rect(bx, by, bw, bh)
  ctx.fill()
  ctx.globalAlpha = 1; ctx.fillStyle = fill
  ctx.fillText(text, x, y)
  ctx.restore()
}

/* ============================================================ 1. GAUSS ======= */
function GaussPanel() {
  const pal = usePal()
  const [sign, setSign] = useState(1)
  const [q, setQ] = useState(2)
  const st = useLatest({ pal, sign, q })

  const canvasRef = useAnim((ctx, w, h, t) => {
    const { pal, sign, q } = st.current
    const c = { x: w / 2, y: h / 2 }
    const lines = Math.max(3, Math.round(q * 8))   // continuous strength → discrete drawn lines
    const R = Math.min(w, h) * 0.33      // gaussian surface radius
    const col = sign > 0 ? pal.pos : pal.neg

    // glow
    const g = ctx.createRadialGradient(c.x, c.y, 2, c.x, c.y, R * 1.5)
    g.addColorStop(0, sign > 0 ? pal.Eglow : pal.Bglow); g.addColorStop(1, "transparent")
    ctx.fillStyle = g; ctx.fillRect(0, 0, w, h)

    // field lines + flowing charge
    const reach = Math.max(w, h)
    for (let i = 0; i < lines; i++) {
      const a = (i / lines) * Math.PI * 2
      const dx = Math.cos(a), dy = Math.sin(a)
      ctx.strokeStyle = col; ctx.globalAlpha = 0.28; ctx.lineWidth = 1.4
      ctx.beginPath(); ctx.moveTo(c.x + dx * 14, c.y + dy * 14); ctx.lineTo(c.x + dx * reach, c.y + dy * reach); ctx.stroke()
      ctx.globalAlpha = 1
      // markers flow straight out (in for −); synced across lines → radial, never rotating
      const markers = 4, period = 60
      for (let k = 0; k < markers; k++) {
        let r = ((t * 42 + k * (period / markers)) % period) / period   // 0..1
        r = sign > 0 ? r : 1 - r
        const rr = 18 + r * (R * 1.9 - 18)
        const px = c.x + dx * rr, py = c.y + dy * rr
        const dir = sign > 0 ? a : a + Math.PI
        arrowHead(ctx, px, py, dir, 6, col)
      }
    }

    // gaussian surface
    ctx.setLineDash([5, 5]); ctx.strokeStyle = pal.muted; ctx.lineWidth = 1.5; ctx.globalAlpha = 0.9
    ctx.beginPath(); ctx.arc(c.x, c.y, R, 0, Math.PI * 2); ctx.stroke()
    ctx.setLineDash([]); ctx.globalAlpha = 1

    // charge
    ctx.fillStyle = col; ctx.beginPath(); ctx.arc(c.x, c.y, 15, 0, Math.PI * 2); ctx.fill()
    ctx.strokeStyle = "#fff"; ctx.lineWidth = 2.6; ctx.lineCap = "round"
    ctx.beginPath(); ctx.moveTo(c.x - 7, c.y); ctx.lineTo(c.x + 7, c.y)
    if (sign > 0) { ctx.moveTo(c.x, c.y - 7); ctx.lineTo(c.x, c.y + 7) }
    ctx.stroke()
  })

  return (
    <Panel
      n="1" name="Gauss's Law" tex="\nabla \cdot \mathbf{E} = \dfrac{\rho}{\varepsilon_0}"
      lede="Electric charge is a source of field. Lines spring out of positive charge, dive into negative — the net flux out of any closed surface counts the charge inside it."
    >
      <canvas className="cv" ref={canvasRef} />
      <p className="cap">{Math.max(3, Math.round(q * 8))} field lines cross the surface — the flux out is proportional to the charge enclosed.</p>
      <div className="ctl">
        <div className="seg">
          <button className={sign > 0 ? "on" : ""} onClick={() => setSign(1)}>+ charge</button>
          <button className={sign < 0 ? "on" : ""} onClick={() => setSign(-1)}>− charge</button>
        </div>
        <label>strength
          <input type="range" min={0.5} max={3} step={0.02} value={q} onChange={(e) => setQ(+e.target.value)} />
        </label>
      </div>
    </Panel>
  )
}

/* ==================================================== 2. NO MONOPOLES ======== */
function MonopolePanel() {
  const pal = usePal()
  const st = useLatest({ pal })
  const canvasRef = useAnim((ctx, w, h, t) => {
    const { pal } = st.current
    const cx = w / 2, cy = h / 2, mw = Math.min(w * 0.34, 150), mh = 30
    const N = { x: cx - mw / 2, y: cy }, S = { x: cx + mw / 2, y: cy }

    // magnet body — drawn first, beneath the field lines, so arrows crossing the surface stay countable
    const y0 = cy - mh / 2
    ctx.fillStyle = pal.E; ctx.fillRect(N.x, y0, mw / 2, mh)
    ctx.fillStyle = pal.B; ctx.fillRect(cx, y0, mw / 2, mh)
    ctx.fillStyle = "#fff"; ctx.font = "bold 15px system-ui,sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle"
    ctx.fillText("N", N.x + mw / 4, cy); ctx.fillText("S", S.x - mw / 4, cy)
    ctx.textBaseline = "alphabetic"

    // dipole loops, top and bottom families
    const shells = 4
    const draw = (up: number, sh: number) => {
      const bulge = (sh + 1) * (Math.min(w, h) * 0.11)
      const c1 = { x: cx - mw * 0.25, y: cy - up * bulge }
      const c2 = { x: cx + mw * 0.25, y: cy - up * bulge }
      ctx.strokeStyle = pal.muted; ctx.globalAlpha = 0.35; ctx.lineWidth = 1.4
      ctx.beginPath(); ctx.moveTo(N.x, N.y); ctx.bezierCurveTo(c1.x, c1.y, c2.x, c2.y, S.x, S.y); ctx.stroke()
      ctx.globalAlpha = 1
      // flowing dots N -> S (external field direction)
      const bz = (p: number) => {
        const u = 1 - p
        return {
          x: u * u * u * N.x + 3 * u * u * p * c1.x + 3 * u * p * p * c2.x + p * p * p * S.x,
          y: u * u * u * N.y + 3 * u * u * p * c1.y + 3 * u * p * p * c2.y + p * p * p * S.y,
        }
      }
      for (let k = 0; k < 5; k++) {
        const p = ((t * 0.28 + k / 5 + sh * 0.13) % 1)
        const a = bz(p), b = bz(Math.min(1, p + 0.02))
        arrowHead(ctx, a.x, a.y, Math.atan2(b.y - a.y, b.x - a.x), 6, up > 0 ? pal.E : pal.B)
      }
    }
    for (let s = 0; s < shells; s++) { draw(1, s); draw(-1, s) }

    // gaussian surface (offset): lines that enter also leave -> net flux 0
    const gx = cx + mw * 0.2, gy = cy - Math.min(w, h) * 0.16, gr = Math.min(w, h) * 0.14
    ctx.setLineDash([5, 5]); ctx.strokeStyle = pal.accent; ctx.lineWidth = 1.6
    ctx.beginPath(); ctx.arc(gx, gy, gr, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([])

    haloText(ctx, "∮ B·dA = 0", gx, gy - gr - 6, pal.accent, pal.panel, "center")
  })
  return (
    <Panel
      n="2" name="No Magnetic Monopoles" tex="\nabla \cdot \mathbf{B} = 0"
      lede="Magnetism has no isolated 'charges'. Field lines never begin or end — they close on themselves — so the net magnetic flux through any closed surface is exactly zero."
    >
      <canvas className="cv" ref={canvasRef} />
      <p className="cap">Every field line is a closed loop — as many arrows enter the dashed surface as leave it, so the net flux is zero.</p>
      <div className="ctl"><span className="hint">break the magnet in half and you get two magnets, never a lone pole</span></div>
    </Panel>
  )
}

/* ======================================================= 3. FARADAY ========= */
function FaradayPanel() {
  const pal = usePal()
  const [freq, setFreq] = useState(0.35)
  const st = useLatest({ pal, freq })
  const spin = useRef(0)
  const lastT = useRef<number | null>(null)
  const capRef = useRef<HTMLParagraphElement>(null)
  const canvasRef = useAnim((ctx, w, h, t) => {
    const { pal, freq } = st.current
    const cx = w / 2, cy = h / 2
    const wob = 2 * Math.PI * freq
    const B = Math.sin(wob * t)          // into-page flux (normalized)
    const dB = wob * Math.cos(wob * t)   // dΦ/dt

    // B field: grid of into-page symbols, opacity ∝ |B|, sign flips ⊗ / ⊙
    const into = B >= 0
    const R = Math.min(w, h) * 0.34
    const step = 34
    for (let x = step; x < w; x += step) for (let y = step; y < h - 18; y += step) {
      const a = Math.min(1, Math.abs(B)) * 0.7 + 0.06
      ctx.globalAlpha = a; ctx.strokeStyle = into ? pal.B : pal.E; ctx.lineWidth = 1.4
      const r = 5
      ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.stroke()
      if (into) { ctx.beginPath(); ctx.moveTo(x - r * .7, y - r * .7); ctx.lineTo(x + r * .7, y + r * .7); ctx.moveTo(x + r * .7, y - r * .7); ctx.lineTo(x - r * .7, y + r * .7); ctx.stroke() }
      else { ctx.fillStyle = pal.E; ctx.beginPath(); ctx.arc(x, y, 1.6, 0, Math.PI * 2); ctx.fill() }
    }
    ctx.globalAlpha = 1

    // conducting loop; induced current opposes the change (Lenz)
    const drive = Math.max(-1, Math.min(1, -dB / wob))   // ∝ -dB/dt, normalized
    if (lastT.current === null) lastT.current = t
    const dt = Math.min(0.05, Math.max(0, t - lastT.current)); lastT.current = t
    spin.current += drive * dt * 2.5   // integrate the current → stable rotation (no absolute-t haze)
    const curCol = drive >= 0 ? pal.cur : pal.cur2
    ctx.strokeStyle = pal.ink; ctx.lineWidth = 5; ctx.globalAlpha = 0.25
    ctx.beginPath(); ctx.arc(cx, cy, R, 0, Math.PI * 2); ctx.stroke(); ctx.globalAlpha = 1
    // circulating current arrows
    const nA = 10
    for (let i = 0; i < nA; i++) {
      const base = (i / nA) * Math.PI * 2
      const a = base + spin.current
      const x = cx + Math.cos(a) * R, y = cy + Math.sin(a) * R
      const tang = a + (drive >= 0 ? Math.PI / 2 : -Math.PI / 2)
      ctx.globalAlpha = 0.35 + 0.65 * Math.abs(drive)
      arrowHead(ctx, x, y, tang, 8, curCol)
    }
    ctx.globalAlpha = 1

    // EMF gauge needle ∝ -dB/dt
    const gx = w - 52, gy = 40
    ctx.strokeStyle = pal.grid; ctx.lineWidth = 2
    ctx.beginPath(); ctx.arc(gx, gy, 22, Math.PI, Math.PI * 2); ctx.stroke()
    const na = Math.PI + (0.5 - drive * 0.5) * Math.PI
    vec(ctx, gx, gy, gx + Math.cos(na) * 20, gy + Math.sin(na) * 20, curCol, 2.4, 6)
    haloText(ctx, "EMF", gx, gy + 16, pal.muted, pal.panel, "center")
    if (capRef.current) capRef.current.textContent = dB >= 0
      ? "Flux is rising, so the induced current flows to oppose the increase (Lenz's law)."
      : "Flux is falling, so the induced current flows to sustain it (Lenz's law)."
  })
  return (
    <Panel
      n="3" name="Faraday's Law" tex="\nabla \times \mathbf{E} = -\dfrac{\partial \mathbf{B}}{\partial t}"
      lede="A magnetic field that changes in time wraps a swirling electric field around itself. That curling E is what drives current in the loop — the principle behind every generator."
    >
      <canvas className="cv" ref={canvasRef} />
      <p className="cap" ref={capRef}>Change the magnetic field and a current is induced in the loop.</p>
      <div className="ctl">
        <label>how fast B changes
          <input type="range" min={0.1} max={0.9} step={0.05} value={freq} onChange={(e) => setFreq(+e.target.value)} />
        </label>
      </div>
    </Panel>
  )
}

/* ================================================ 4. AMPÈRE–MAXWELL ========= */
function AmperePanel() {
  const pal = usePal()
  const [I, setI] = useState(2)
  const [disp, setDisp] = useState(false)
  const st = useLatest({ pal, I, disp })
  const canvasRef = useAnim((ctx, w, h, t) => {
    const { pal, I, disp } = st.current
    const cx = w / 2, cy = h / 2
    const dir = Math.sign(I) || 1
    const mag = Math.abs(I)
    const rings = Math.max(2, Math.round(mag) + 2)
    const maxR = Math.min(w, h) * 0.4

    // circulating B (right-hand rule) — identical whether sourced by current or by ∂E/∂t
    for (let k = 1; k <= rings; k++) {
      const R = (k / rings) * maxR
      ctx.strokeStyle = pal.B; ctx.globalAlpha = 0.22 + 0.5 * (mag / 3); ctx.lineWidth = 1.6
      ctx.beginPath(); ctx.arc(cx, cy, R, 0, Math.PI * 2); ctx.stroke()
      const nA = 6 + k
      for (let i = 0; i < nA; i++) {
        const a = (i / nA) * Math.PI * 2 + t * dir * (0.5 + mag * 0.25)
        const x = cx + Math.cos(a) * R, y = cy + Math.sin(a) * R
        arrowHead(ctx, x, y, a + (dir > 0 ? Math.PI / 2 : -Math.PI / 2), 6, pal.B)
      }
    }
    ctx.globalAlpha = 1

    // central source
    if (!disp) {
      ctx.fillStyle = pal.cur; ctx.beginPath(); ctx.arc(cx, cy, 13, 0, Math.PI * 2); ctx.fill()
      ctx.fillStyle = "#fff"
      if (dir > 0) { ctx.beginPath(); ctx.arc(cx, cy, 3.4, 0, Math.PI * 2); ctx.fill() }   // ⊙ out of page
      else { ctx.strokeStyle = "#fff"; ctx.lineWidth = 2.4; ctx.beginPath(); ctx.moveTo(cx - 6, cy - 6); ctx.lineTo(cx + 6, cy + 6); ctx.moveTo(cx + 6, cy - 6); ctx.lineTo(cx - 6, cy + 6); ctx.stroke() }
    } else {
      // capacitor gap: growing E field (out of page) — displacement current
      ctx.fillStyle = pal.E; ctx.globalAlpha = 0.9
      const puff = 0.5 + 0.5 * Math.sin(t * 2)
      ctx.beginPath(); ctx.arc(cx, cy, 9 + puff * 5, 0, Math.PI * 2); ctx.fill(); ctx.globalAlpha = 1
      ctx.fillStyle = "#fff"; ctx.beginPath(); ctx.arc(cx, cy, 3, 0, Math.PI * 2); ctx.fill()
    }

  })
  return (
    <Panel
      n="4" name="Ampère–Maxwell Law" tex="\nabla \times \mathbf{B} = \mu_0 \mathbf{J} + \mu_0\varepsilon_0\dfrac{\partial \mathbf{E}}{\partial t}"
      lede="Electric currents curl a magnetic field around themselves. Maxwell's leap: a changing electric field does the very same thing — the displacement-current term that let light exist."
    >
      <canvas className="cv" ref={canvasRef} />
      <p className="cap">{disp
        ? "No wire — a changing electric field alone sources the same circulating B."
        : "Current out of the page wraps B counter-clockwise, by the right-hand rule."}</p>
      <div className="ctl">
        <label>current I
          <input type="range" min={-3} max={3} step={1} value={I} onChange={(e) => setI(+e.target.value)} disabled={disp} />
        </label>
        <div className="seg">
          <button className={!disp ? "on" : ""} onClick={() => setDisp(false)}>current J</button>
          <button className={disp ? "on" : ""} onClick={() => setDisp(true)}>changing E</button>
        </div>
      </div>
    </Panel>
  )
}

/* ===================================================== CAPSTONE: WAVE ======= */
function WavePanel() {
  const pal = usePal()
  const st = useLatest({ pal })
  const canvasRef = useAnim((ctx, w, h, t) => {
    const { pal } = st.current
    const cy = h * 0.55, x0 = 60, x1 = w - 40
    const dep = { x: 0.42, y: -0.34 }           // perspective "into page" direction for B
    const speed = 2.4
    const waves = Math.max(1.4, Math.min(2.4, (x1 - x0) / 200))  // fewer wavelengths when narrow
    const k = (Math.PI * 2 * waves) / (x1 - x0)
    let amp = Math.min(h * 0.3, 90)
    amp = Math.min(amp, 0.65 / (dep.x * k))     // stop the projected B curve folding when narrow

    // z axis
    ctx.strokeStyle = pal.grid; ctx.lineWidth = 1.5
    ctx.beginPath(); ctx.moveTo(x0, cy); ctx.lineTo(x1, cy); ctx.stroke()
    vec(ctx, x1 - 24, cy, x1, cy, pal.muted, 1.5, 8)
    haloText(ctx, "propagation  →  (speed c)", (x0 + x1) / 2, cy + amp + 34, pal.muted, pal.panel, "center")

    const phase = (x: number) => k * (x - x0) - speed * t
    const step = 4
    // B field — perspective plane (draw first, behind)
    ctx.strokeStyle = pal.B; ctx.lineWidth = 2; ctx.beginPath()
    for (let x = x0; x <= x1; x += step) {
      const s = Math.sin(phase(x)) * amp
      const px = x + s * dep.x, py = cy + s * dep.y
      x === x0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py)
    }
    ctx.stroke()
    // E field — vertical plane
    ctx.strokeStyle = pal.E; ctx.lineWidth = 2.4; ctx.beginPath()
    for (let x = x0; x <= x1; x += step) {
      const s = Math.sin(phase(x)) * amp
      x === x0 ? ctx.moveTo(x, cy - s) : ctx.lineTo(x, cy - s)
    }
    ctx.stroke()
    // stems at intervals
    for (let x = x0; x <= x1; x += 26) {
      const s = Math.sin(phase(x)) * amp
      ctx.strokeStyle = pal.Bglow; ctx.lineWidth = 1.4
      ctx.beginPath(); ctx.moveTo(x, cy); ctx.lineTo(x + s * dep.x, cy + s * dep.y); ctx.stroke()
      ctx.strokeStyle = pal.Eglow
      ctx.beginPath(); ctx.moveTo(x, cy); ctx.lineTo(x, cy - s); ctx.stroke()
    }

    // legends
    haloText(ctx, "E  electric", w / 2, 24, pal.E, pal.panel, "center", "bold 15px system-ui,sans-serif")
    haloText(ctx, "B  magnetic", w / 2, 46, pal.B, pal.panel, "center", "bold 15px system-ui,sans-serif")
  })
  return (
    <div className="panel wave">
      <div className="head">
        <span className="badge">★</span>
        <div>
          <h3>Put them together — and you get light</h3>
          <Eq tex="c = \dfrac{1}{\sqrt{\mu_0 \varepsilon_0}}" block />
          <p className="lede">Faraday says a changing B makes a curling E; Ampère–Maxwell says a changing E makes a curling B. Each field's change births the other, and the pair races off through empty space at one fixed speed — and that speed <em>is</em> the speed of light. E and B stay perpendicular, in step, self-sustaining.</p>
        </div>
      </div>
      <canvas className="cv tall" ref={canvasRef} />
    </div>
  )
}

/* ---------- shared panel shell ---------- */
function Panel({ n, name, tex, lede, children }:
  { n: string; name: string; tex: string; lede: string; children: React.ReactNode }) {
  return (
    <div className="panel">
      <div className="head">
        <span className="badge">{n}</span>
        <div>
          <h3>{name}</h3>
          <Eq tex={tex} block />
        </div>
      </div>
      <p className="lede">{lede}</p>
      {children}
    </div>
  )
}

function App() {
  useEffect(() => {
    const id = "katex-css"
    if (!document.getElementById(id)) {
      const l = document.createElement("link")
      l.id = id; l.rel = "stylesheet"
      l.href = "https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.11/katex.min.css"
      document.head.appendChild(l)
    }
  }, [])
  return (
    <div className="wrap">
      <header className="hero">
        <h1>Maxwell's Equations</h1>
        <p>Four short lines that pin down every electric field, every magnet, and light itself. Here's what each one is really saying — moving.</p>
      </header>
      <div className="grid">
        <GaussPanel />
        <MonopolePanel />
        <FaradayPanel />
        <AmperePanel />
      </div>
      <WavePanel />
      <footer>∇ is the divergence/curl operator · ε₀, μ₀ are the electric &amp; magnetic constants of the vacuum</footer>
    </div>
  )
}

createRoot(document.getElementById("root")!).render(<App />)
```
**styles.css**

```css
:root {
  /* light theme — also the default when the OS/user theme is unset */
  --bg: #f8fafc; --panel: #ffffff; --ink: #0f172a; --muted: #64748b;
  --line: #e2e8f0; --accent: #4d7c0f; --on-accent: #ffffff;
  --badge: #ecfccb; --badge-ink: #3f6212;
  --shadow: 0 1px 2px rgba(15,23,42,.05), 0 8px 24px rgba(15,23,42,.06);
  /* type scale — reused everywhere, no per-element magic numbers */
  --text-sm: 14px;    /* controls, secondary UI */
  --text-base: 16px;  /* body: explanations, footer */
  --text-lg: 18px;    /* panel titles */
}
:root[data-theme="dark"] {
  --bg: #141414; --panel: #1e1e1e; --ink: #e6e6e6; --muted: #a3a3a3;
  --line: #2c2c2c; --accent: #a3e635; --on-accent: #141414;
  --badge: #26310f; --badge-ink: #bef264;
  --shadow: 0 1px 2px rgba(0,0,0,.3), 0 10px 30px rgba(0,0,0,.45);
}
/* follow the OS only when the user hasn't explicitly chosen light */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg: #141414; --panel: #1e1e1e; --ink: #e6e6e6; --muted: #a3a3a3;
    --line: #2c2c2c; --accent: #a3e635; --on-accent: #141414;
    --badge: #26310f; --badge-ink: #bef264;
    --shadow: 0 1px 2px rgba(0,0,0,.3), 0 10px 30px rgba(0,0,0,.45);
  }
}

* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--ink); }
.wrap {
  max-width: 1080px; margin: 0 auto; padding: 16px 20px 48px;
  font: var(--text-base)/1.55 system-ui, -apple-system, "Segoe UI", sans-serif;
}

.hero { text-align: center; margin-bottom: 24px; }
.hero h1 { margin: 0 0 8px; font-size: clamp(28px, 5vw, 40px); letter-spacing: -.02em; }
.hero p { max-width: 640px; margin: 0 auto; color: var(--muted); }

.grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 18px; }
@media (max-width: 720px) { .grid { grid-template-columns: 1fr; } }

.panel {
  background: var(--panel); border: 1px solid var(--line); border-radius: 16px;
  padding: 18px; box-shadow: var(--shadow); text-align: center;
  display: flex; flex-direction: column; gap: 10px;
}
/* paired panels share their row's track heights, so diagram tops line up */
.grid > .panel { display: grid; grid-template-rows: subgrid; grid-row: span 5; row-gap: 10px; }
.panel.wave { margin-top: 18px; }

.head { display: flex; flex-direction: column; align-items: center; gap: 8px; }
.badge {
  width: 30px; height: 30px; border-radius: 9px; font-weight: 700; font-size: var(--text-sm);
  background: var(--badge); color: var(--badge-ink); display: grid; place-items: center;
}
.head h3 { margin: 0; font-size: var(--text-lg); letter-spacing: -.01em; }
.eq-block { font-size: 1.2em; color: var(--ink); }
.eq-block .katex-display { margin: 0; }
.eq-inline { color: var(--ink); }

.lede { margin: 0; color: var(--ink); }
.cap { margin: 0; color: var(--muted); }

.cv {
  width: 100%; height: 260px; border-radius: 12px; touch-action: none;
  background:
    radial-gradient(120% 120% at 50% 0%, color-mix(in srgb, var(--accent) 6%, transparent), transparent 60%),
    color-mix(in srgb, var(--ink) 3%, transparent);
}
.cv.tall { height: 320px; }

.ctl { display: flex; flex-wrap: wrap; justify-content: center; align-items: center; gap: 12px 16px; font-size: var(--text-sm); color: var(--muted); }
.ctl label { display: inline-flex; align-items: center; gap: 8px; }
.ctl input[type="range"] { width: 130px; accent-color: var(--accent); }
.ctl input[type="range"]:disabled { opacity: .4; }
.hint { font-style: italic; opacity: .8; }

.seg { display: inline-flex; border: 1px solid var(--line); border-radius: 9px; overflow: hidden; }
.seg button {
  font: inherit; font-size: var(--text-sm); padding: 6px 12px; cursor: pointer;
  border: 0; border-right: 1px solid var(--line); background: transparent; color: var(--muted);
}
.seg button:last-child { border-right: 0; }
.seg button.on { background: var(--accent); color: var(--on-accent); font-weight: 600; }

footer { max-width: 640px; margin: 28px auto 0; text-align: center; color: var(--muted); line-height: 1.7; }
```
**index.html**

```html
<div id="root"></div>
```
**config.json**

```json
{
  "description": "Interactive, animated walk-through of Maxwell's four equations — Gauss, no-monopoles, Faraday, Ampère–Maxwell — building to how E and B become light.",
  "dependencies": {
    "react": "^19.2.7",
    "react-dom": "^19.2.7",
    "katex": "^0.16.11"
  }
}
```

Markdown source · More bulbs by antypica · Typebulb home