My personal site. Matrix-themed, prerendered, and built from scratch because I wanted to.
thomasjbutler.github.io · the paid work lives at thomasjbutler.me
React 19, TypeScript, Vite 7, Tailwind 4. Cursor-reactive Matrix rain on a canvas, CRT scanlines, a terminal-style command palette, and a light theme for people who would rather not.
Projects. Twenty of them, filterable, from an on-device iOS app and a local RAG pipeline down to the CSS demos I built while learning CSS. The tags say which is which, on purpose: the arc from one to the other is the point.
A case study. One build taken apart properly: the problem, the architecture, and what I'd do differently.
A dev journey. Every month since I started, from the first line of code to now, by way of the DWP and an agency apprenticeship.
Version TimeTravel. Every previous version of this site, still running as it originally shipped. It's the one thing a CV can't do.
Every route is rendered to real HTML at build time. This isn't an optimisation, it's the difference between existing and not existing for a whole class of reader: GPTBot, ClaudeBot and PerplexityBot fetch HTML and don't execute JavaScript. Before this, /about shipped zero characters of body text.
src/entry-server.tsxrenders each route withprerenderToNodeStream.scripts/routes.mjsis the single source of truth for routes, titles and descriptions.scripts/prerender.mjsinjects markup, per-route meta and JSON-LD, and fails the build if any route emits under 600 characters of text. A component that silently renders nothing on the server is invisible otherwise.src/main.tsxhydrates rather than mounting fresh, so the prerendered DOM survives.
src/lib/fx/rain-engine.ts is the Matrix rain as a plain class, so its maths is unit-tested without a canvas. The performance constraints in it are load-bearing rather than fussy: the first version dropped the page to 20fps. It renders at DPR 1, draws at 30fps with time-scaled movement, bounds each stream to 16-34 glyphs, and batches the column heads into a single pass.
The pointer "morph" (glyphs parting around the cursor, click ripples) is gated on dark theme + rain on + a fine pointer. When that gate is shut there are no pointer listeners on the page at all.
Everything cinematic answers to one switch, useFx().motionOk, which folds the OS reduced-motion preference together with an in-page toggle. WCAG 2.2.2 needs that in-page control for auto-playing motion; prefers-reduced-motion alone doesn't satisfy it.
One file, src/app.css. Tailwind 4, CSS-first, so there's no tailwind.config. Theme tokens are custom properties and light/dark is a .dark class on <html>.
├── index.html the app shell, plus the pre-paint theme script
├── scripts/
│ ├── routes.mjs every route, title and description. Also a runtime import
│ ├── prerender.mjs renders each route to HTML, enforces the 600-char floor
│ └── structured-data.mjs per-route JSON-LD
├── src/
│ ├── app.css all of it
│ ├── entry-server.tsx the server render entry
│ ├── main.tsx hydrates #root
│ ├── components/
│ │ ├── fx/ decode-in text, reveal-on-scroll
│ │ ├── home/ hero, terminal console, proof section
│ │ ├── layout/ header, footer, page transition
│ │ ├── system/ command palette, atmosphere, toaster, easter eggs
│ │ └── ui/ shadcn components, on Base UI
│ ├── contexts/ theme, accent, effects
│ ├── lib/
│ │ ├── content.ts the copy. Import it, don't retype it
│ │ ├── projects.ts the project data
│ │ ├── timeline.ts the dev journey
│ │ ├── assets.ts Cloudinary URL map
│ │ └── fx/ rain engine, decode, easing
│ └── pages/ one per route
└── e2e/ Playwright screenshots and page checks
Node 22.
npm install
npm run dev # localhost:3000| Command | What it does |
|---|---|
npm run dev |
Dev server on port 3000 |
npm run build |
Client build, then an SSR build, then the prerender |
npm run preview |
Serves dist/. The --outDir dist is load-bearing |
npm run type-check |
tsc --noEmit |
npm run lint |
ESLint over src |
npx vitest run |
Unit and component tests, once |
npm run test:e2e |
Playwright |
npm run deploy |
Build and publish to GitHub Pages |
Lighthouse, desktop, against a production build, on / and /projects. Measured
27 August 2026, not aspirational:
/ |
/projects |
|
|---|---|---|
| Accessibility | 100 | 100 |
| Best Practices | 100 | 100 |
| SEO | 100 | 100 |
| Agentic Browsing | 97 | 100 |
Cumulative Layout Shift is 0.00, from a separate trace.
That CLS number was 0.144 once. Getting it to zero took three things that all look like dead weight to anyone tidying up: a full-screen loader reservation, a hidden per-character sizer inside the decode-in text, and metric-matched fallback @font-face blocks (Orbitron is 18.9% wider than Arial, so every heading re-wrapped on font load). They're all commented where they live.
The test suite is 117 unit and component tests across 17 files, plus Playwright for screenshots and page checks.
package.json declares ISC. There's no LICENSE file in the repo yet, so treat that as
the intent rather than the paperwork.
Tom Butler · Leeds, Yorkshire Looking for full-time work: junior for general software, any non-senior level for AI.