diff --git a/website/recordings/.gitignore b/website/recordings/.gitignore
new file mode 100644
index 0000000000..77f75d7e9a
--- /dev/null
+++ b/website/recordings/.gitignore
@@ -0,0 +1,2 @@
+out/
+scratch/
diff --git a/website/recordings/README.md b/website/recordings/README.md
new file mode 100644
index 0000000000..34f57ceada
--- /dev/null
+++ b/website/recordings/README.md
@@ -0,0 +1,36 @@
+# CLI recordings
+
+Terminal recordings of `sqb` for the README and the website, rendered with
+[VHS](https://github.com/charmbracelet/vhs) from the `.tape` scripts in `tapes/`. The tapes and
+scripts are the source; the rendered GIF, MP4 and PNG files go to `out/`, which is not committed.
+
+| Demo | Shows |
+|------|-------|
+| `rename` | A model is moved and renamed, and `sqb plan` migrates the existing table. |
+| `compile` | `sqb compile` catches an unknown column and a type mismatch. |
+| `scope` | `sqb scope --as-path` previews what a move would lose. |
+| `janitor` | `sqb janitor` archives a stale table before anything is deleted. |
+| `quickstart` | The playground: `sqb playground`, `sqb build` and `sqb test`. |
+
+Each demo runs locally on DuckDB, using the projects in [`../examples`](../examples).
+
+## Requirements
+
+- `vhs`, `ttyd` and `ffmpeg` on `PATH`, plus a Chromium or Chrome that VHS can find (it downloads
+ one if none is on `PATH`).
+- DejaVu Sans Mono, the font the tapes use.
+- `sqb` on `PATH`, or `SQB_BIN` pointing at a directory that contains the `sqb` to record.
+
+## Record
+
+```bash
+cd website/recordings
+./record.sh rename # out/rename.gif, out/rename.mp4 and out/rename.png
+```
+
+`record.sh` runs `prepare.sh ` to build a clean project under `scratch//`, then renders
+`tapes/.tape`. Shared terminal settings and the colour theme, which matches the website's
+terminal blocks, are in `tapes/common.tape`.
+
+Look at every recording before publishing it: output such as timings, run IDs and dates changes
+between runs.
diff --git a/website/recordings/prepare.sh b/website/recordings/prepare.sh
new file mode 100755
index 0000000000..c34af34b48
--- /dev/null
+++ b/website/recordings/prepare.sh
@@ -0,0 +1,62 @@
+#!/usr/bin/env bash
+# Build a clean scratch project for one demo. Usage: prepare.sh
+set -euo pipefail
+demo="$1"
+here="$(cd "$(dirname "$0")" && pwd)"
+examples="$here/../examples"
+dir="$here/scratch/$demo"
+rm -rf "$dir"
+mkdir -p "$here/scratch"
+case "$demo" in
+ quickstart)
+ mkdir -p "$dir" ;;
+ rename)
+ # Build the model under its old name, so the tape can move and rename it.
+ cp -r "$examples/hero-shop" "$dir"
+ cd "$dir"
+ mkdir -p models/marts
+ mv models/finance/daily_revenue.sql models/marts/revenue.sql
+ rmdir models/finance
+ sqb build >/dev/null ;;
+ scope)
+ cp -r "$examples/waffle-shop" "$dir"
+ cd "$dir"
+ sqb compile >/dev/null || true ;;
+ janitor)
+ # Build a model, then remove it, so the janitor has a stale table to archive.
+ cp -r "$examples/tidy-shop" "$dir"
+ cd "$dir"
+ cat > models/weekly_revenue.sql <<'SQL'
+MODEL (
+ materialized table,
+);
+
+SELECT DATE_TRUNC('week', revenue_date) AS revenue_week, SUM(total_revenue_cents) AS total_revenue_cents
+FROM __ref("daily_revenue")
+GROUP BY 1
+SQL
+ sqb build >/dev/null
+ rm models/weekly_revenue.sql ;;
+ compile)
+ # Introduce an unknown column and a type mismatch, checked against enforced source contracts.
+ cp -r "$examples/waffle-shop" "$dir"
+ cd "$dir"
+ python3 - <<'PY'
+import re
+
+path = "sources/raw.yml"
+text = open(path).read()
+for name in ("raw__customers", "raw__orders"):
+ text = re.sub(rf"(\n(\s*)- name: {name}\n)", rf"\1\2 contract: enforced\n", text, count=1)
+open(path, "w").write(text)
+
+path = "models/marts/fact_orders.sql"
+text = open(path).read()
+text = text.replace("o.quantity,", "o.qty,", 1).rstrip() + "\nWHERE o.ordered_at > 5\n"
+open(path, "w").write(text)
+PY
+ ;;
+ *)
+ echo "unknown demo: $demo" >&2
+ exit 2 ;;
+esac
diff --git a/website/recordings/record.sh b/website/recordings/record.sh
new file mode 100755
index 0000000000..69021d36cf
--- /dev/null
+++ b/website/recordings/record.sh
@@ -0,0 +1,14 @@
+#!/usr/bin/env bash
+# Record one demo to out/.gif and out/.mp4. Usage: record.sh
+# Uses the sqb on PATH; set SQB_BIN to a directory containing another sqb to use that instead.
+set -euo pipefail
+demo="$1"
+here="$(cd "$(dirname "$0")" && pwd)"
+if [[ -n "${SQB_BIN:-}" ]]; then
+ export PATH="$SQB_BIN:$PATH"
+fi
+"$here/prepare.sh" "$demo"
+mkdir -p "$here/out"
+cd "$here"
+# Headless Chromium's sandbox is unavailable in many containers and CI runners.
+VHS_NO_SANDBOX="${VHS_NO_SANDBOX:-true}" vhs "tapes/$demo.tape"
diff --git a/website/recordings/tapes/common.tape b/website/recordings/tapes/common.tape
new file mode 100644
index 0000000000..8dade07714
--- /dev/null
+++ b/website/recordings/tapes/common.tape
@@ -0,0 +1,13 @@
+Set Shell bash
+Set FontFamily "DejaVu Sans Mono"
+Set FontSize 18
+Set LineHeight 1.0
+Set Width 1200
+Set Height 680
+Set Padding 28
+Set Margin 24
+Set MarginFill "#0d0f12"
+Set BorderRadius 10
+Set WindowBar Colorful
+Set TypingSpeed 45ms
+Set Theme { "name": "SQLBuild", "black": "#626975", "red": "#f47089", "green": "#54cf8e", "yellow": "#f0c267", "blue": "#3788e8", "magenta": "#a78bfa", "cyan": "#4dd6c4", "white": "#c9cdd4", "brightBlack": "#9ba1ac", "brightRed": "#f47089", "brightGreen": "#54cf8e", "brightYellow": "#f0c267", "brightBlue": "#4f98ee", "brightMagenta": "#c79bf0", "brightCyan": "#4dd6c4", "brightWhite": "#e9ebef", "background": "#111418", "foreground": "#c9cdd4", "selection": "#2a2f37", "cursor": "#e9ebef" }
diff --git a/website/recordings/tapes/compile.tape b/website/recordings/tapes/compile.tape
new file mode 100644
index 0000000000..6dca3c1ee0
--- /dev/null
+++ b/website/recordings/tapes/compile.tape
@@ -0,0 +1,14 @@
+Source tapes/common.tape
+Output out/compile.gif
+Output out/compile.mp4
+Hide
+Type "cd scratch/compile && export PS1='$ ' && clear"
+Enter
+Sleep 500ms
+Show
+Type "sqb compile"
+Sleep 300ms
+Enter
+Sleep 6s
+Screenshot out/compile.png
+Sleep 3s
diff --git a/website/recordings/tapes/janitor.tape b/website/recordings/tapes/janitor.tape
new file mode 100644
index 0000000000..392a4e9d4c
--- /dev/null
+++ b/website/recordings/tapes/janitor.tape
@@ -0,0 +1,18 @@
+Source tapes/common.tape
+Output out/janitor.gif
+Output out/janitor.mp4
+Hide
+Type "cd scratch/janitor && export PS1='$ ' && clear"
+Enter
+Sleep 500ms
+Show
+Type "sqb janitor"
+Sleep 300ms
+Enter
+Sleep 5s
+Type "archive 1 and delete 1 items from prod"
+Sleep 500ms
+Enter
+Sleep 4s
+Screenshot out/janitor.png
+Sleep 3s
diff --git a/website/recordings/tapes/quickstart.tape b/website/recordings/tapes/quickstart.tape
new file mode 100644
index 0000000000..70f9d9d2e0
--- /dev/null
+++ b/website/recordings/tapes/quickstart.tape
@@ -0,0 +1,25 @@
+Source tapes/common.tape
+Output out/quickstart.gif
+Output out/quickstart.mp4
+Hide
+Type "cd scratch/quickstart && export PS1='$ ' && clear"
+Enter
+Sleep 500ms
+Show
+Type "sqb playground waffle-shop"
+Sleep 300ms
+Enter
+Sleep 2s
+Type "cd waffle-shop"
+Enter
+Sleep 500ms
+Type "sqb build"
+Sleep 300ms
+Enter
+Sleep 8s
+Type "sqb test"
+Sleep 300ms
+Enter
+Sleep 6s
+Screenshot out/quickstart.png
+Sleep 2s
diff --git a/website/recordings/tapes/rename.tape b/website/recordings/tapes/rename.tape
new file mode 100644
index 0000000000..9d2e1bbbd8
--- /dev/null
+++ b/website/recordings/tapes/rename.tape
@@ -0,0 +1,21 @@
+Source tapes/common.tape
+Output out/rename.gif
+Output out/rename.mp4
+Hide
+Type "cd scratch/rename && export PS1='$ ' && clear"
+Enter
+Sleep 500ms
+Show
+Type "find models -name '*.sql'"
+Enter
+Sleep 1500ms
+Type "mkdir models/finance && mv models/marts/revenue.sql models/finance/daily_revenue.sql"
+Sleep 300ms
+Enter
+Sleep 800ms
+Type "sqb plan"
+Sleep 300ms
+Enter
+Sleep 5s
+Screenshot out/rename.png
+Sleep 3s
diff --git a/website/recordings/tapes/scope.tape b/website/recordings/tapes/scope.tape
new file mode 100644
index 0000000000..a208d29722
--- /dev/null
+++ b/website/recordings/tapes/scope.tape
@@ -0,0 +1,17 @@
+Source tapes/common.tape
+Output out/scope.gif
+Output out/scope.mp4
+Hide
+Type "cd scratch/scope && export PS1='$ ' && clear"
+Enter
+Sleep 500ms
+Show
+Type "find models/marts -name '*.sql' -o -name '*.py'"
+Enter
+Sleep 1500ms
+Type "sqb scope model:daily_revenue --as-path models/intermediate/daily_revenue.sql"
+Sleep 300ms
+Enter
+Sleep 5s
+Screenshot out/scope.png
+Sleep 3s