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 & 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"
}
}
```