The ladder to a Lie group: climb from a bare set through manifold and group to a Lie group, watching the same objects reorganize at each rung.
---
format: typebulb/v1
name: Lie Group Ladder
---
**code.tsx**
```tsx
import React, { useRef, useState } from "react"
import { createRoot } from "react-dom/client"
import katex from "katex"
const TAU = Math.PI * 2
const N = 24
const CX = 150, CY = 128
const LIME = "#84cc16", SKY = "#38bdf8", PINK = "#ec4899"
const K = ({ tex }: { tex: string }) => (
<span dangerouslySetInnerHTML={{ __html: katex.renderToString(tex, { throwOnError: false }) }} />
)
function seeded(seed: number) {
let s = seed
return () => {
s = (s * 16807) % 2147483647
return s / 2147483647
}
}
const scatter = (() => {
const r = seeded(7)
return Array.from({ length: N }, () => ({ x: 42 + r() * 216, y: 32 + r() * 190 }))
})()
// the "were we lucky?" target: a flat grid the dots *almost* fit — each lands visibly off its
// lattice point, so the verdict (no flat coordinates fit) is seen, not just asserted
const luckyGrid = (() => {
const r = seeded(23)
return Array.from({ length: N }, (_, i) => ({
x: 60 + (i % 6) * 36 + (r() - 0.5) * 14,
y: 57 + Math.floor(i / 6) * 38 + (r() - 0.5) * 14,
}))
})()
const blobPt = (t: number) => {
const p = t * TAU
const r = 80 + 13 * Math.sin(2 * p + 0.7) + 7 * Math.cos(3 * p)
return { x: CX + r * Math.cos(p), y: CY - r * Math.sin(p) }
}
const circPt = (t: number) => {
const p = t * TAU
return { x: CX + 86 * Math.cos(p), y: CY - 86 * Math.sin(p) }
}
function pathOf(fn: (t: number) => { x: number; y: number }) {
const pts = Array.from({ length: 120 }, (_, i) => fn(i / 120))
return "M " + pts.map(p => `${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(" L ") + " Z"
}
const blobPath = pathOf(blobPt)
const circPath = pathOf(circPt)
const norm24 = (v: number) => ((v % 24) + 24) % 24
const REGIMES = [
{ smooth: false, mult: false, label: "a set" },
{ smooth: true, mult: false, label: "a smooth manifold" },
{ smooth: false, mult: true, label: "a group" },
{ smooth: true, mult: true, label: "a Lie group!" },
]
function App() {
const [smooth, setSmooth] = useState(false)
const [mult, setMult] = useState(false)
const [a, setA] = useState(3)
const [b, setB] = useState(7)
const [hover, setHover] = useState<number | null>(null)
const [lucky, setLucky] = useState(false)
const svgRef = useRef<SVGSVGElement>(null)
const setRegime = (s: boolean, m: boolean) => {
setSmooth(s)
setMult(m)
setHover(null)
setLucky(false)
if (!s) {
setA(v => Math.round(v) % 24)
setB(v => Math.round(v) % 24)
}
}
const dotPos = (i: number) => (smooth ? (mult ? circPt : blobPt)(i / N) : lucky ? luckyGrid[i] : scatter[i])
const elemPos = (v: number) => (smooth ? circPt(norm24(v) / 24) : scatter[Math.round(v) % N])
const c = norm24(a + b)
const onMove = (e: React.PointerEvent) => {
if (!smooth || mult) return
const box = svgRef.current!.getBoundingClientRect()
const x = ((e.clientX - box.left) / box.width) * 300
const y = ((e.clientY - box.top) / box.height) * 250
let best = 0, bd = Infinity
for (let i = 0; i < 240; i++) {
const p = blobPt(i / 240)
const d = (p.x - x) ** 2 + (p.y - y) ** 2
if (d < bd) { bd = d; best = i }
}
setHover(bd < 3600 ? best / 240 : null)
}
let tan: { p: { x: number; y: number }; dx: number; dy: number } | null = null
if (smooth && !mult && hover != null) {
const p = blobPt(hover)
const q1 = blobPt(hover + 0.004), q0 = blobPt(hover - 0.004)
const L = Math.hypot(q1.x - q0.x, q1.y - q0.y)
tan = { p, dx: (q1.x - q0.x) / L, dy: (q1.y - q0.y) / L }
}
const caption = smooth && mult
? <>Now <K tex="a \circ b" /> <em>glides</em> as you slide, because multiplication <K tex="(g,h) \mapsto gh" /> is a smooth map — that compatibility is the whole definition. The blob relaxed to a perfect circle: a Lie group is homogeneous, no point special except <K tex="e" /> (and only because we named it). The arrow at <K tex="e" /> is the Lie algebra — the tangent direction the whole group flows along.</>
: smooth
? <>The dots glue into a curve with no global coordinate system — but hover it: every point has a tangent line, and the lens shows that up close it looks like <K tex="\mathbb{R}^1" />. "Locally like <K tex="\mathbb{R}^n" />" is the whole definition.</>
: mult
? <>A law <K tex="a \circ b" /> with identity and inverses — slide <K tex="a" /> and watch the pink product <em>teleport</em>. Algebra with no geometry.</>
: lucky
? <><strong>…no.</strong> They almost line up, but no flat grid fits — these objects carry no global coordinates. Usually we're not lucky. <button className="lucky" onClick={e => { e.stopPropagation(); setLucky(false) }}>alas</button></>
: <>Just a bag of objects — no geometry, no algebra. If we were lucky they'd form a vector space <K tex="\cong \mathbb{R}^n" /> — flat, global coordinates, done. <button className="lucky" onClick={e => { e.stopPropagation(); setLucky(true) }}>were we lucky?</button></>
const pa = elemPos(a), pb = elemPos(b), pc = elemPos(c)
return (
<div className="wrap">
<h1>The ladder to a Lie group</h1>
<p className="intro">
Two independent structures can land on a set of objects: a <strong>smooth geometry</strong> —
locally like <K tex="\mathbb{R}^n" />, a tangent space at every point — and a
<strong> group law</strong>, a way to multiply. A <strong>Lie group</strong> is what you have
when both arrive and mesh.
</p>
<div className="cols">
<div className="rail">
<ol className="ladder">
{REGIMES.map((r, i) => {
const active = r.smooth === smooth && r.mult === mult
return (
<li key={r.label} className={active ? "active" : ""} onClick={() => setRegime(r.smooth, r.mult)}>
<div className="rung">
<span className="num">{i + 1}</span>
<span className="have">{r.label}</span>
<span className="tags">
<em className={r.smooth ? "yes" : ""}>smooth</em>
<em className={r.mult ? "yes" : ""}>∘ law</em>
</span>
</div>
{active && <div className="rbody">{caption}</div>}
</li>
)
})}
</ol>
<p className="hint">climb the rungs in order</p>
</div>
<div className="stagecol">
<svg ref={svgRef} className="stage" viewBox="0 0 300 250" onPointerMove={onMove} onPointerLeave={() => setHover(null)}>
<defs>
<marker id="la" viewBox="0 0 8 8" refX="6" refY="4" markerWidth="6" markerHeight="6" orient="auto">
<path d="M 0 0 L 8 4 L 0 8 z" fill={LIME} />
</marker>
<clipPath id="lens"><circle cx="253" cy="42" r="34" /></clipPath>
</defs>
<g style={{ opacity: lucky ? 0.3 : 0, transition: "opacity 500ms" }} stroke="currentColor" strokeOpacity="0.5">
{[0, 1, 2, 3, 4, 5].map(i => <line key={"v" + i} x1={60 + i * 36} y1={40} x2={60 + i * 36} y2={188} />)}
{[0, 1, 2, 3].map(i => <line key={"h" + i} x1={44} y1={57 + i * 38} x2={256} y2={57 + i * 38} />)}
</g>
<path d={blobPath} fill="none" stroke="currentColor" strokeOpacity="0.3" style={{ opacity: smooth && !mult ? 1 : 0, transition: "opacity 700ms" }} />
<path d={circPath} fill="none" stroke="currentColor" strokeOpacity="0.3" style={{ opacity: smooth && mult ? 1 : 0, transition: "opacity 700ms" }} />
{Array.from({ length: N }, (_, i) => {
const p = dotPos(i)
return <circle key={i} r="3.5" fill="currentColor" fillOpacity={smooth && mult ? 0.3 : 0.55} style={{ transform: `translate(${p.x}px, ${p.y}px)`, transition: "transform 700ms cubic-bezier(.45,0,.25,1)" }} />
})}
{mult && (
<g>
<line x1={pa.x} y1={pa.y} x2={pc.x} y2={pc.y} stroke={PINK} strokeOpacity="0.35" strokeDasharray="4 4" />
<line x1={pb.x} y1={pb.y} x2={pc.x} y2={pc.y} stroke={PINK} strokeOpacity="0.35" strokeDasharray="4 4" />
<circle cx={pa.x} cy={pa.y} r="6" fill={LIME} />
<circle cx={pb.x} cy={pb.y} r="6" fill={SKY} />
<circle cx={pc.x} cy={pc.y} r="7" fill="none" stroke={PINK} strokeWidth="2.5" />
<text x={pa.x + 9} y={pa.y + 4} fontSize="11" fontStyle="italic" fill={LIME}>a</text>
<text x={pb.x + 9} y={pb.y + 4} fontSize="11" fontStyle="italic" fill={SKY}>b</text>
<text x={pc.x + 11} y={pc.y + 4} fontSize="11" fontStyle="italic" fill={PINK}>a∘b</text>
</g>
)}
{smooth && mult && (
<g>
<circle cx={CX + 86} cy={CY} r="4" fill="none" stroke="currentColor" strokeWidth="1.5" />
<text x={CX + 86 + 7} y={CY + 15} fontSize="11" fontStyle="italic" fill="currentColor">e</text>
<line x1={CX + 86} y1={CY} x2={CX + 86} y2={CY - 34} stroke={LIME} strokeWidth="1.8" markerEnd="url(#la)" />
</g>
)}
{tan && (
<g>
<line x1={tan.p.x - 26 * tan.dx} y1={tan.p.y - 26 * tan.dy} x2={tan.p.x + 26 * tan.dx} y2={tan.p.y + 26 * tan.dy} stroke={LIME} strokeWidth="2" />
<circle cx={tan.p.x} cy={tan.p.y} r="4" fill={LIME} />
<circle cx="253" cy="42" r="34" fill="none" stroke="currentColor" strokeOpacity="0.4" />
<g clipPath="url(#lens)">
<path d={blobPath} fill="none" stroke="currentColor" strokeOpacity="0.6" strokeWidth="0.4" transform={`translate(253 42) scale(6) translate(${-tan.p.x} ${-tan.p.y})`} />
<circle cx="253" cy="42" r="3" fill={LIME} />
</g>
<text x="253" y="90" fontSize="10" textAnchor="middle" fill="currentColor" fillOpacity="0.7">zoom: locally ≈ ℝ¹</text>
</g>
)}
</svg>
<div key={mult ? 1 : 0} className={"sliders" + (mult ? " nudge" : "")} style={{ visibility: mult ? "visible" : "hidden" }}>
<label><span style={{ color: LIME }}>a</span><input type="range" min="0" max={smooth ? 24 : 23} step={smooth ? 0.05 : 1} value={a} onChange={e => setA(Number(e.target.value))} /></label>
<label><span style={{ color: SKY }}>b</span><input type="range" min="0" max={smooth ? 24 : 23} step={smooth ? 0.05 : 1} value={b} onChange={e => setB(Number(e.target.value))} /></label>
</div>
</div>
</div>
</div>
)
}
createRoot(document.getElementById("root")!).render(<App />)
```
**styles.css**
```css
.wrap {
max-width: 940px;
margin: 0 auto;
padding: 24px 20px;
font: 15.5px system-ui, sans-serif;
display: grid;
gap: 16px;
}
h1 { font-size: 25px; font-weight: 700; margin: 0; text-align: center; }
.cols { display: grid; grid-template-columns: 340px 1fr; gap: 28px; align-items: center; }
@media (max-width: 800px) { .cols { grid-template-columns: 1fr; } }
.rail { display: grid; gap: 12px; }
.intro { margin: 0 auto; max-width: 66ch; line-height: 1.55; opacity: 0.85; text-align: center; }
.ladder { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
.ladder li {
display: grid;
gap: 6px;
padding: 8px 12px;
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
border-radius: 8px;
cursor: pointer;
user-select: none;
}
.ladder .rung { display: flex; align-items: center; gap: 10px; }
.ladder .rbody { font-size: 14px; line-height: 1.5; opacity: 0.85; font-weight: 400; cursor: default; }
.ladder .rbody strong { color: #84cc16; }
.ladder li:hover { background: color-mix(in srgb, currentColor 6%, transparent); }
.ladder li.active { border-color: #84cc16; background: color-mix(in srgb, #84cc16 12%, transparent); }
.ladder .num { font-size: 13px; opacity: 0.5; width: 1em; }
.ladder .have { flex: 1; font-weight: 600; }
.ladder li.active .have { color: #84cc16; }
.ladder .tags { display: flex; gap: 6px; }
.ladder .tags em {
font-style: normal;
font-size: 12px;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
opacity: 0.35;
}
.ladder .tags em.yes { opacity: 1; border-color: #84cc16; color: #84cc16; }
.hint { margin: 0; font-size: 13px; opacity: 0.5; text-align: center; }
.stagecol { display: grid; gap: 10px; justify-items: center; }
svg.stage { width: min(460px, 100%); height: auto; touch-action: none; }
.sliders { display: flex; gap: 18px; flex-wrap: wrap; justify-content: center; padding: 4px 10px; }
.sliders.nudge { animation: nudge 1.6s ease-out 1; border-radius: 10px; }
@keyframes nudge {
0% { background: color-mix(in srgb, #84cc16 22%, transparent); }
100% { background: transparent; }
}
.sliders label { display: flex; align-items: center; gap: 8px; font-size: 14.5px; font-style: italic; }
.sliders input { width: 150px; accent-color: #84cc16; }
button.lucky {
font: inherit;
font-size: 13.5px;
padding: 3px 12px;
display: block;
margin: 10px 0 2px;
border: 1px solid currentColor;
border-radius: 999px;
background: transparent;
color: inherit;
cursor: pointer;
opacity: 0.8;
}
button.lucky:hover { opacity: 1; }
```
**index.html**
```html
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.css">
<div id="root"></div>
```
**config.json**
```json
{
"description": "The ladder to a Lie group: climb from a bare set through manifold and group to a Lie group, watching the same objects reorganize at each rung.",
"dependencies": {
"react": "^19.2.7",
"react-dom": "^19.2.7",
"katex": "0.16.22"
}
}
```