---
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/katex@0.16.22/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"
  }
}
```