The idea
The homepage isn't a static hero — it's a canvas of ten cards you can drag around, with your layout saved to localStorage. Centred on top is a typographic stack: mono eyebrow, a large "Prototype → Production" hook, a rotating "Shipping … end to end." line, one short supporting line, and CTAs. Each card is a fact rendered as an object:
Developer ID badge · Apple Notes CV · Education · Now building (terminal) · Birmingham postcard · Analog clock · Recent win · Stack · Now playing (Spotify) · Find me online.
Most "AI engineer" portfolios reach for neural-net particles and neon gradients. I went the other way: restraint, one bold idea, proof over theatrics. Hover any card for a mono hint in terminal voice (`>_`). Here's how to build your own — the primitive first, then every card.
Design tokens
Everything is driven by CSS variables, so the whole canvas is themeable from one place. Two accents with a strict rule: indigo for interaction, teal for technical/data.
:root {
--surface: #ffffff;
--surface-2: #f6f6fc;
--border: rgba(23, 23, 42, 0.08);
--card-border: rgba(22, 22, 29, 0.18);
--card-border-width: 2px;
--text: #16161d;
--text-muted: #585b6b;
--text-dim: #8b8e9e;
--accent: #6366f1; /* indigo */
--accent-hover: #818cf8;
--signal: #0f9d8f; /* teal */
--p-peri: #b9c6ff; --p-pink: #fbc7e6; --p-mint: #b9f4e6;
--shadow-card: 0 1px 2px rgba(0,0,0,.06), 0 4px 14px rgba(23,23,42,.1), 0 14px 36px rgba(23,23,42,.12);
--shadow-float: 0 6px 20px rgba(23,23,42,.12), 0 16px 48px rgba(76,70,160,.18);
}The card primitive
One component wraps every card. It handles drag, hover lift, and the nice touch — saving each card's position to localStorage so a visitor's rearrangement survives a reload.
"use client";
import { useEffect, type ReactNode, type CSSProperties } from "react";
import { motion, useMotionValue } from "framer-motion";
type Pos = {
top?: string; left?: string; right?: string; bottom?: string;
anchor?: "left" | "right" | "bottom" | "bottomright";
rot?: number; dur?: number; delay?: number;
};
export function Card({
id, children, pos, drag, constraints, resetSignal, className = "", hint,
}: {
id: string; children: ReactNode; pos: Pos; drag: boolean;
constraints: React.RefObject<HTMLDivElement>; resetSignal: number;
className?: string; hint?: string;
}) {
const x = useMotionValue(0);
const y = useMotionValue(0);
useEffect(() => {
if (!drag) { x.set(0); y.set(0); return; }
try {
const saved = JSON.parse(localStorage.getItem(`card:${id}`) || "null");
x.set(saved?.x ?? 0); y.set(saved?.y ?? 0);
} catch { x.set(0); y.set(0); }
}, [drag, id, resetSignal, x, y]);
const persist = () => {
try { localStorage.setItem(`card:${id}`, JSON.stringify({ x: x.get(), y: y.get() })); } catch {}
};
const cssVars = {
"--top": pos.top, "--left": pos.left, "--right": pos.right, "--bottom": pos.bottom,
"--rot": `${pos.rot ?? 0}deg`, "--dur": `${pos.dur ?? 7}s`, "--delay": `${pos.delay ?? 0}s`,
} as CSSProperties;
return (
<motion.div
className="floaty group pointer-events-auto"
data-anchor={pos.anchor}
style={{ ...cssVars, x, y }}
drag={drag} dragConstraints={constraints} dragElastic={0.12} dragMomentum={false}
onDragEnd={persist}
whileDrag={{ scale: 1.05, zIndex: 50, cursor: "grabbing" }}
whileHover={{ scale: 1.03, zIndex: 28, cursor: "grab" }}
transition={{ type: "spring", stiffness: 320, damping: 24 }}
>
<div className={`floaty__inner relative ${drag ? "cursor-grab" : ""} ${className}`}>
{children}
{hint && (
<span className="card-hint" aria-hidden>{hint}</span>
)}
</div>
</motion.div>
);
}Composing the canvas
Enable drag only on desktop (respect prefers-reduced-motion). Lay cards out with percentage positions — defaults below frame the centred copy. Positions persist in localStorage under card:{id}; reset layout clears them.
const HERO_CARD_HINTS: Record<string, string> = {
"photo-v2": "reading me.jpg… yep, that's me",
"cv-note": "exporting PDF… please hold",
"education": "loading MSc… ████░ 82%",
"building-v2": "benchmark running… thinking harder",
"map": "postcard dispatched from Brum ✉",
"clock": "syncing Europe/London… on time",
"impact": "tickets migrated · zero rollback",
"stack-v4": "npm install --also-my-brain",
"spotify": "focus playlist · do not disturb",
"links": "handshake complete · 200 OK",
};
const cardProps = (id: string) => ({
id, drag, constraints: containerRef, resetSignal,
hint: HERO_CARD_HINTS[id],
});
<div ref={containerRef} className="relative mx-auto min-h-[560px] w-full max-w-[1300px] md:h-[820px]">
{/* Centred hero copy — z-30, cards z-20 */}
<div className="hero-copy relative z-30 …">…</div>
<div className="hero-cards pointer-events-none relative z-20 md:absolute md:inset-0">
<Card {...cardProps("photo-v2")} pos={{ top: "-2%", left: "-4%", rot: -4 }}><IdBadge /></Card>
<Card {...cardProps("cv-note")} pos={{ bottom: "-3%", left: "68%", anchor: "bottom", rot: 1 }}><CvNoteCard /></Card>
<Card {...cardProps("education")} pos={{ top: "4%", left: "19%", rot: 1 }}>{/* … */}</Card>
<Card {...cardProps("building-v2")} pos={{ top: "4%", left: "63.5%", rot: -1 }}>{/* terminal */}</Card>
<Card {...cardProps("map")} pos={{ top: "25%", right: "3%", anchor: "right", rot: 3 }}><HeroMapCard /></Card>
<Card {...cardProps("clock")} pos={{ top: "55%", right: "2%", anchor: "right", rot: 3 }}><HeroClockCard /></Card>
<Card {...cardProps("impact")} pos={{ top: "74%", right: "2%", anchor: "right", rot: -2 }}>{/* recent win */}</Card>
<Card {...cardProps("spotify")} pos={{ bottom: "19%", left: "78%", rot: -3 }}><NowPlaying /></Card>
<Card {...cardProps("stack-v4")} pos={{ bottom: "6%", left: "2%", anchor: "bottom", rot: -3 }}>{/* stack */}</Card>
<Card {...cardProps("links")} pos={{ top: "89%", left: "41%", rot: -2 }}>{/* find me online */}</Card>
</div>
</div>Card — clock (analog + digital)
Europe/London time with an SVG analog face and a tabular digital readout. Render a placeholder until mounted to avoid hydration mismatch.
// HeroClockCard — analog SVG hands + digital HH:MM:SS
const TZ = "Europe/London";
function HeroClockCard() {
const [parts, setParts] = useState<{ h: number; m: number; s: number } | null>(null);
useEffect(() => {
const tick = () => {
const d = new Date();
const fmt = new Intl.DateTimeFormat("en-GB", {
timeZone: TZ, hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false,
}).formatToParts(d);
const pick = (t: string) => Number(fmt.find((p) => p.type === t)?.value ?? 0);
setParts({ h: pick("hour"), m: pick("minute"), s: pick("second") });
};
tick(); const id = setInterval(tick, 1000); return () => clearInterval(id);
}, []);
return (
<div className="flex w-[210px] items-center gap-3 rounded-xl border-card bg-white px-3 py-2.5">
<AnalogFace h={parts?.h ?? 0} m={parts?.m ?? 0} s={parts?.s ?? 0} />
<div>
<div className="font-mono text-[9px] uppercase tracking-[0.16em] text-[var(--text-dim)]">Birmingham, UK</div>
<div className="font-mono text-lg font-semibold tabular-nums text-[var(--text)]">
{parts ? `${String(parts.h).padStart(2,"0")}:${String(parts.m).padStart(2,"0")}:${String(parts.s).padStart(2,"0")}` : "--:--:--"}
</div>
</div>
</div>
);
}Card — recent win
<div className="w-[198px] rounded-xl border-card bg-white p-4">
<div className="font-mono text-[9px] uppercase tracking-[0.16em] text-[var(--text-dim)]">Recent win</div>
<div className="mt-2 flex items-center gap-1.5 font-display text-lg font-semibold tracking-tight text-[var(--text)]">
Idea <span className="text-[var(--signal)]">→</span> Shipped
</div>
<div className="mt-1 text-[12px] leading-snug text-[var(--text-muted)]">
A one-line win you're proud of — short and specific.
</div>
</div>Card — now building (terminal)
Compact macOS-style terminal — 172px wide, dissertation / current build in mono.
<div className="w-[172px] overflow-hidden rounded-lg border-[2px] border-[#1a1d24] bg-[#0e1117]">
<div className="flex items-center gap-1 border-b border-white/5 px-2 py-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-[#ff5f57]" />
<span className="h-1.5 w-1.5 rounded-full bg-[#febc2e]" />
<span className="h-1.5 w-1.5 rounded-full bg-[#28c840]" />
<span className="ml-1 font-mono text-[7px] text-white/40">~/research</span>
</div>
<div className="px-2 py-2 font-mono text-[9px] leading-[1.45]">
<div className="text-[var(--signal)]">$ now building</div>
<div className="mt-0.5 text-white/85">› Hybrid RAG benchmark</div>
<div className="text-white/45">dense · sparse · KG · visual</div>
<div className="mt-1 text-white/55"><span className="text-white/35"># </span>FinanceBench</div>
<div className="mt-0.5 flex items-center text-white/85">
<span className="text-[var(--signal)]">$</span>
<span className="ml-1 inline-block h-2.5 w-[6px] animate-pulse bg-white/70" />
</div>
</div>
</div>Card — CV (Apple Notes)
Yellow sticky-note preview linking to /cv/your-cv.pdf. Sits opposite the stack after the swap.
<a href="/cv/your-cv.pdf" target="_blank" rel="noreferrer"
className="block w-[158px] overflow-hidden rounded-[14px] border-[2px] border-black/15 bg-[#fff7c8] shadow-[var(--shadow-card)]">
<div className="border-b border-black/[0.05] px-3.5 py-2">
<span className="font-display text-[15px] font-semibold text-[#1c1c1e]">CV</span>
<p className="font-mono text-[9px] text-[#aeaeb2]">PDF · tap to open</p>
</div>
<div className="space-y-1.5 px-3.5 py-3">
<p className="text-[13px] font-medium text-[#3a3a3c]">Your Name</p>
<p className="text-[12px] text-[#636366]">Your Role</p>
</div>
</a>PDF · tap to open
Your Name
Your Role
Your City
Card — education
<div className="w-[214px] rounded-xl border-card bg-white p-4">
<div className="font-mono text-[9px] uppercase tracking-[0.16em] text-[var(--text-dim)]">Education</div>
<div className="mt-2 text-[13px] font-semibold">MSc Applied AI</div>
<div className="font-mono text-[10px] text-[var(--text-muted)]">Warwick (WMG) · in progress</div>
<div className="mt-2 text-[13px] font-semibold">BSc CS with AI · First-Class</div>
<div className="font-mono text-[10px] text-[var(--text-muted)]">Your University</div>
<div className="mt-3 border-t border-[var(--border)] pt-2 font-mono text-[10px] text-[var(--text-dim)]">
EN · UR · PT (spoken)
</div>
</div>Card — Birmingham postcard
Travel postcard with airmail stripes, stamp, postmark, and an SVG skyline — not a map embed.
<div className="hero-postcard w-[200px] overflow-hidden rounded-xl border border-[#ddd5c8] bg-[#faf6ee]">
<div className="hero-postcard__airmail" aria-hidden />
<div className="relative px-3 pt-2.5 pb-2">
<div className="hero-postcard__stamp">…</div>
<div className="hero-postcard__photo">{/* SVG skyline */}</div>
<p className="mt-2 font-display text-[13px] font-semibold">Greetings from</p>
<p className="font-display text-[15px] font-bold text-[var(--accent)]">Birmingham, UK</p>
</div>
</div>Greetings from
Your City, UK
Card — find me online
<div className="w-[176px] rounded-2xl bg-[#2563eb] p-4 text-white shadow-[0_10px_30px_rgba(37,99,235,0.35)]">
<div className="font-mono text-[9px] uppercase tracking-[0.16em] text-white/60">Find me online</div>
<a href="https://linkedin.com/in/you" className="mt-2.5 flex items-center gap-2 text-[13px] font-semibold hover:underline">
LinkedIn
</a>
<a href="https://github.com/you" className="mt-1.5 flex items-center gap-2 text-[13px] font-semibold hover:underline">
GitHub
</a>
</div>GitHub
Card — stack
const groups = [
{ label: "Languages", tags: ["Python", "C#/.NET", "TypeScript", "SQL"] },
{ label: "AI / ML", tags: ["LLMs", "RAG", "PyTorch", "Hugging Face"] },
{ label: "Cloud", tags: ["AWS", "Terraform", "Docker", "GCP"] },
{ label: "Web", tags: ["React", "Next.js", "FastAPI", "Node"] },
];
<div className="w-[296px] rounded-xl border-card bg-white p-4">
<div className="font-mono text-[11px] uppercase tracking-[0.16em] text-[var(--text-dim)]">Stack</div>
<div className="mt-2.5 grid grid-cols-2 gap-x-3 gap-y-2.5">
{groups.map((g) => (
<div key={g.label} className="min-w-0">
<div className="font-mono text-[9px] font-medium uppercase tracking-[0.1em] text-[var(--accent)]">{g.label}</div>
<div className="mt-1 flex flex-wrap gap-1">
{g.tags.map((t) => (
<span key={t} className="rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-1.5 py-0.5 font-mono text-[10px] text-[var(--text-muted)]">{t}</span>
))}
</div>
</div>
))}
</div>
</div>Card — now playing (Spotify)
A client component polls a server route every 30s. The route tries now-playing, then recently-played, then top tracks; with no credentials it returns { configured: false } and the card shows a static placeholder — always safe to ship.
function NowPlaying() {
const [track, setTrack] = useState<any>(null);
useEffect(() => {
const load = async () => setTrack(await (await fetch("/api/spotify")).json());
load(); const id = setInterval(load, 30000); return () => clearInterval(id);
}, []);
const live = track?.configured && track.title;
const title = live ? track.title : "Deep Work";
const artist = live ? track.artist : "focus mix · while shipping";
const labels: any = { playing: "Now playing", recent: "Last played", top: "Most played" };
const label = live ? (labels[track.source] ?? "Now playing") : "Now playing";
return (
<div className="w-[256px] rounded-2xl bg-[#1b1c22] p-4 text-white shadow-[0_10px_30px_rgba(20,20,30,0.25)]">
<div className="mb-2 font-mono text-[9px] uppercase tracking-[0.16em] text-emerald-300/80">♫ {label}</div>
<div className="flex items-center gap-3">
<div className="grid h-10 w-10 place-items-center rounded-md bg-gradient-to-br from-indigo-400 to-teal-300 text-[12px]">♪</div>
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-semibold leading-tight">{title}</div>
<div className="truncate text-[11px] text-white/55">{artist}</div>
</div>
</div>
</div>
);
}Card — developer ID badge
The showpiece. A conference lanyard ID — everything is CSS/SVG except the photo, which fades in from a gradient once /me.jpg loads. The barcode is an array of bar widths; the mini-QR is a 7×7 bit matrix (decorative).
const BADGE_BARCODE = [2,1,1,3,1,2,1,1,2,3,1,1,2,1,3,1,1,2,2,1,1,3,1,2,1,3,1,1,2,1,2,1,3,1,1,2];
const MINI_QR = [
[1,1,1,0,1,1,1],[1,0,1,1,1,0,1],[1,0,1,0,1,0,1],[0,1,0,1,0,1,0],
[1,1,1,0,1,1,0],[1,0,0,1,0,1,1],[1,1,1,0,1,0,1],
];
function IdBadge() {
const [loaded, setLoaded] = useState(false);
return (
<div className="flex flex-col items-center">
{/* Lanyard */}
<div className="relative z-10 flex flex-col items-center">
<div className="flex gap-0.5">
<div className="h-7 w-[18px] bg-gradient-to-b from-[var(--accent)] to-[var(--accent-hover)]" />
<div className="h-7 w-[18px] bg-gradient-to-b from-[var(--accent-hover)] to-[var(--accent)]" />
</div>
<div className="-mt-0.5 flex h-4 w-[52px] items-center justify-center bg-[var(--accent)]">
<span className="font-mono text-[5px] font-bold uppercase tracking-[0.22em] text-white/90">build · ship</span>
</div>
<div className="-mt-px flex flex-col items-center">
<div className="h-1 w-6 rounded-full bg-gradient-to-b from-[#e8ebf2] to-[#b8bec9]" />
<div className="h-3 w-10 rounded-[3px] bg-gradient-to-b from-[#d4d8e2] to-[#8e96a8]" />
<div className="-mt-[2px] h-4 w-[58px] rounded-[5px] bg-gradient-to-b from-[#5a6170] to-[#252a34] ring-1 ring-white/10" />
</div>
</div>
{/* Body */}
<div className="relative -mt-[3px] w-[284px] overflow-hidden rounded-xl border-card bg-white shadow-[var(--shadow-float)]">
<div className="flex justify-center pt-3">
<div className="h-2 w-14 rounded-full bg-[var(--surface-2)] ring-2 ring-[var(--border)] ring-offset-1 ring-offset-white" />
</div>
<div className="mt-2.5 bg-[var(--accent)] px-3.5 py-2">
<div className="flex items-start justify-between">
<div>
<span className="font-mono text-[9px] font-semibold uppercase tracking-[0.18em] text-white">Developer ID</span>
<p className="mt-0.5 font-mono text-[7px] uppercase tracking-[0.14em] text-white/75">Applied AI · 2025</p>
</div>
<span className="font-mono text-[11px] font-bold text-white">UM<span className="text-white/70">.</span></span>
</div>
</div>
<div className="flex items-center justify-between border-b border-[var(--border)] bg-[var(--surface-2)] px-3.5 py-1.5">
<span className="rounded bg-[var(--signal)]/15 px-1.5 py-0.5 font-mono text-[7px] font-semibold uppercase tracking-[0.12em] text-[var(--signal)]">Engineering pass</span>
<span className="font-mono text-[7px] text-[var(--text-dim)]">Clearance · L3</span>
</div>
<div className="px-3.5 pb-3 pt-3">
<div className="flex items-start gap-3.5">
<div className="relative h-[132px] w-[108px] overflow-hidden rounded-md ring-1 ring-[var(--border)]">
<div className="absolute inset-0 bg-gradient-to-br from-[var(--p-peri)] via-[var(--p-pink)] to-[var(--p-mint)]" />
<Image src="/me.jpg" alt="You" fill sizes="108px" onLoad={() => setLoaded(true)}
style={{ objectPosition: "center 22%" }}
className={"object-cover transition-opacity duration-500 " + (loaded ? "opacity-100" : "opacity-0")} />
</div>
<div className="min-w-0 flex-1">
<div className="font-display text-[16px] font-bold leading-tight text-[var(--text)]">Your Name</div>
<div className="font-mono text-[10px] uppercase tracking-[0.12em] text-[var(--accent)]">Your Role</div>
<div className="mt-2 space-y-0.5 font-mono text-[8.5px] text-[var(--text-dim)]">
<div><span className="text-[var(--text-muted)]">ID</span> ENG-2025-0847</div>
<div><span className="text-[var(--text-muted)]">EXP</span> Dec 2026</div>
<div>Your City</div>
</div>
</div>
</div>
<div className="mt-3 flex items-end justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex h-[24px] items-stretch justify-center gap-[1.5px] px-1">
{BADGE_BARCODE.map((w, i) => (<span key={i} style={{ width: w }} className="bg-[var(--text)]" />))}
</div>
<div className="mt-1 text-center font-mono text-[7px] tracking-[0.28em] text-[var(--text-dim)]">YOURSITE.COM</div>
</div>
<div className="rounded border-card bg-white p-0.5">
<div className="grid gap-px" style={{ gridTemplateColumns: "repeat(7, 3px)" }}>
{MINI_QR.flat().map((cell, i) => (<span key={i} className={cell ? "h-[3px] w-[3px] bg-[var(--text)]" : "h-[3px] w-[3px]"} />))}
</div>
</div>
</div>
</div>
</div>
</div>
);
}Canvas background — dot + box grid
Behind the cards, a pure-CSS engineering grid: dots at 24px intervals, fine lines every 24px, heavier lines every 96px, soft vignette at the edges. No cursor-reactive JavaScript.
.hero-field__grid {
background-color: var(--bg);
background-image:
radial-gradient(circle, rgba(99, 102, 241, 0.22) 1px, transparent 1px),
linear-gradient(rgba(99, 102, 241, 0.045) 1px, transparent 1px),
linear-gradient(90deg, rgba(99, 102, 241, 0.045) 1px, transparent 1px),
linear-gradient(rgba(99, 102, 241, 0.09) 1px, transparent 1px),
linear-gradient(90deg, rgba(99, 102, 241, 0.09) 1px, transparent 1px);
background-size: 24px 24px, 24px 24px, 24px 24px, 96px 96px, 96px 96px;
mask-image: radial-gradient(ellipse 90% 84% at 50% 44%, #000 55%, transparent 100%);
}export default function CanvasGrid() {
return (
<div className="hero-field" aria-hidden>
<div className="hero-field__grid" />
</div>
);
}What I cut
A connection-graph overlay — lines linking cards into a "system diagram" — just looked messy, so I deleted it.
A cursor-reactive indigo dot grid — clever, but busy; replaced with the static engineering grid above.
Pink "+" hover badges — swapped for mono terminal hints (`>_`).
Star-rating and "available now" cards — fun prototypes, not on the canvas anymore.
When unsure, do less.
Stack
More from the build log
Mar 2026 · 6 min
Building an LLM prompt-injection firewall in 48 hours
How SentryML detects prompt-injection attacks in real time — with explainability, sub-millisecond latency, and an open-source SDK.
2025 — Present · 7 min
Automating Azure DevOps → Jira migration at enterprise scale
Architecture notes on Migrayt — a multi-tenant B2B SaaS with AI-driven data sanitisation and strict Zero Data Retention.