Build a Minkowski spacetime diagram in a single HTML file. You will draw light cones, worldlines for stationary and moving observers, and visualize time dilation by comparing tick marks between reference frames. The diagram makes the geometry of special relativity visible: time and space are not separate – they are axes of a single structure.
You need a modern web browser. No other dependencies.
Create a single file called spacetime.html in any directory.
Create spacetime.html with the following skeleton. The canvas is 600x600 pixels, with the origin placed at the center.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Minkowski Spacetime Diagram</title>
<style>
body {
margin: 0;
background: #0a0a1a;
display: flex;
flex-direction: column;
align-items: center;
font-family: monospace;
color: #ccc;
}
canvas {
margin-top: 20px;
}
.controls {
margin-top: 12px;
}
label { margin-right: 12px; }
input[type="range"] { vertical-align: middle; }
#velocity-display { color: #ff6; }
</style>
</head>
<body>
<h2>Minkowski Spacetime Diagram</h2>
<canvas id="diagram" width="600" height="600"></canvas>
<div class="controls">
<label>Observer velocity:
<input type="range" id="velocity" min="0" max="95" value="60">
<span id="velocity-display">0.60c</span>
</label>
</div>
<script>
// All JavaScript goes here in the following steps
</script>
</body>
</html>
Open the file in your browser. You should see a dark page with a canvas and a velocity slider.
Replace the // All JavaScript goes here comment with the code below. This establishes a coordinate system where the origin is at the canvas center, the x-axis is space (rightward positive), and the y-axis is time (upward positive).
const canvas = document.getElementById("diagram");
const ctx = canvas.getContext("2d");
const W = canvas.width;
const H = canvas.height;
const originX = W / 2;
const originY = H / 2;
const scale = 40; // pixels per unit
function toScreen(x, t) {
return [originX + x * scale, originY - t * scale];
}
function drawLine(x1, t1, x2, t2, color, width = 1, dash = []) {
const [sx1, sy1] = toScreen(x1, t1);
const [sx2, sy2] = toScreen(x2, t2);
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.setLineDash(dash);
ctx.beginPath();
ctx.moveTo(sx1, sy1);
ctx.lineTo(sx2, sy2);
ctx.stroke();
ctx.setLineDash([]);
}
function drawLabel(x, t, text, color, offsetX = 5, offsetY = -5) {
const [sx, sy] = toScreen(x, t);
ctx.fillStyle = color;
ctx.font = "12px monospace";
ctx.fillText(text, sx + offsetX, sy + offsetY);
}
Add the function that draws the space and time axes with gridlines:
function drawAxes() {
const range = 7;
// Gridlines
for (let i = -range; i <= range; i++) {
drawLine(i, -range, i, range, "#1a1a2e", 0.5);
drawLine(-range, i, range, i, "#1a1a2e", 0.5);
}
// Space axis (horizontal)
drawLine(-range, 0, range, 0, "#555", 1);
drawLabel(range - 0.5, 0, "space (x)", "#888", 5, 18);
// Time axis (vertical)
drawLine(0, -range, 0, range, "#555", 1);
drawLabel(0, range - 0.3, "time (ct)", "#888", 8, 0);
// Origin label
drawLabel(0, 0, "O", "#888", -16, 16);
}
Light travels at 45 degrees on a Minkowski diagram (because we use ct for the time axis, making c = 1). The light cone defines the boundary between causally connected and disconnected regions.
function drawLightCone() {
const range = 7;
// Future light cone
drawLine(0, 0, range, range, "#ff0", 1.5, [6, 4]);
drawLine(0, 0, -range, range, "#ff0", 1.5, [6, 4]);
// Past light cone
drawLine(0, 0, range, -range, "#ff0", 1.5, [6, 4]);
drawLine(0, 0, -range, -range, "#ff0", 1.5, [6, 4]);
// Labels
drawLabel(3, 6.5, "future light cone", "#ff0", -40, 0);
drawLabel(3, -6.5, "past light cone", "#ff0", -30, 15);
// Shade the causal future region lightly
const [ox, oy] = toScreen(0, 0);
const [rx, ry] = toScreen(range, range);
const [lx, ly] = toScreen(-range, range);
ctx.fillStyle = "rgba(255, 255, 0, 0.03)";
ctx.beginPath();
ctx.moveTo(ox, oy);
ctx.lineTo(rx, ry);
ctx.lineTo(lx, ly);
ctx.closePath();
ctx.fill();
}
A stationary observer stays at x = 0 for all time. Their worldline is a vertical line along the time axis. Add tick marks at each unit of proper time.
function drawStationaryObserver() {
const range = 6;
// Worldline: vertical line at x = 0
drawLine(0, -range, 0, range, "#4af", 2.5);
// Proper time ticks
for (let t = -range; t <= range; t++) {
if (t === 0) continue;
const [sx, sy] = toScreen(0, t);
ctx.strokeStyle = "#4af";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(sx - 5, sy);
ctx.lineTo(sx + 5, sy);
ctx.stroke();
}
drawLabel(0, 6.5, "stationary", "#4af", -55, 0);
}
A moving observer’s worldline is tilted. At velocity v (in units of c), the worldline has slope 1/v in spacetime. The key physics: the moving observer’s proper time runs slower by the Lorentz factor gamma = 1 / sqrt(1 - v^2).
function drawMovingObserver(v) {
if (v < 0.01) return; // skip if essentially stationary
const gamma = 1 / Math.sqrt(1 - v * v);
const range = 6;
// Worldline: x = v * t, so at time t, position is v*t
const tMax = range;
const tMin = -range;
drawLine(v * tMin, tMin, v * tMax, tMax, "#f64", 2.5);
// Proper time ticks along the worldline
// Proper time interval dtau = dt / gamma
// So coordinate time between ticks = gamma * 1 (one unit of proper time)
const dtCoord = gamma; // coordinate time per proper time unit
for (let n = -Math.floor(range / dtCoord); n <= Math.floor(range / dtCoord); n++) {
if (n === 0) continue;
const tCoord = n * dtCoord;
const xCoord = v * tCoord;
if (Math.abs(tCoord) > range) continue;
const [sx, sy] = toScreen(xCoord, tCoord);
// Draw tick perpendicular to worldline direction
const angle = Math.atan2(1, v);
const perpX = -Math.sin(angle) * 5;
const perpY = -Math.cos(angle) * 5;
ctx.strokeStyle = "#f64";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(sx - perpX, sy - perpY);
ctx.lineTo(sx + perpX, sy + perpY);
ctx.stroke();
}
drawLabel(v * tMax, range - 0.5, `moving (${v.toFixed(2)}c)`, "#f64", 8, 0);
// Show gamma value
drawLabel(-6, -6.5, `gamma = ${gamma.toFixed(3)}`, "#f64", 0, 0);
drawLabel(-6, -7.2, `Moving clock runs ${(1/gamma).toFixed(3)}x slower`, "#f64", 0, 0);
}
The tick marks on the moving observer’s worldline are spaced farther apart in coordinate time. This is time dilation: one second of proper time for the moving observer corresponds to gamma seconds of coordinate time for the stationary observer.
Wire everything together with the velocity slider:
function draw() {
ctx.clearRect(0, 0, W, H);
const v = parseInt(document.getElementById("velocity").value) / 100;
document.getElementById("velocity-display").textContent = v.toFixed(2) + "c";
drawAxes();
drawLightCone();
drawStationaryObserver();
drawMovingObserver(v);
// Legend
ctx.fillStyle = "#888";
ctx.font = "11px monospace";
ctx.fillText("Tick marks = 1 unit of proper time", 10, H - 40);
ctx.fillText("Dashed yellow = light (45 degrees, v=c)", 10, H - 24);
ctx.fillText("Blue = stationary | Orange = moving", 10, H - 8);
}
draw();
document.getElementById("velocity").addEventListener("input", draw);
Reload the page. You should see the complete diagram.
When you open the page, you will see:
When you adjust the velocity slider:
The moving observer can never reach or cross the light cone (45 degrees), because that would require v >= c.