← ALL ENTRIESBuild log14 MIN READ

2026 · 14 min

Building a draggable card-canvas hero (with the code)

A breakdown of this site's hero — ten draggable cards, centred copy, mono hover hints, and copy-paste code for each one.

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.

globals.css
: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);
}

Shared CSS

Cards are a flex stack on mobile and absolutely positioned on desktop — from the same markup. Positioning comes from CSS variables a media query reads, plus a gentle idle bob.

globals.css
.border-card { border: var(--card-border-width) solid var(--card-border); box-shadow: var(--shadow-card); }

.floaty { position: relative; }
.floaty .floaty__inner {
  transform: rotate(var(--rot, 0deg));
  animation: floatY var(--dur, 7s) ease-in-out infinite;
  animation-delay: var(--delay, 0s);
  will-change: transform;
}
@keyframes floatY {
  0%, 100% { transform: rotate(var(--rot, 0deg)) translateY(0); }
  50%      { transform: rotate(var(--rot, 0deg)) translateY(-7px); }
}

/* Desktop: absolutely position each card from CSS variables */
@media (min-width: 768px) {
  .floaty { position: absolute; top: var(--top, auto); left: var(--left, auto); }
  .floaty[data-anchor="right"]       { left: auto; right: var(--right, auto); }
  .floaty[data-anchor="bottom"]      { top: auto; bottom: var(--bottom, auto); }
  .floaty[data-anchor="bottomright"] { top: auto; left: auto; right: var(--right, auto); bottom: var(--bottom, auto); }
}

/* Mono hover hint — terminal voice, not a pink badge */
.card-hint {
  position: absolute; bottom: -9px; right: -6px; z-index: 5;
  max-width: min(220px, 90vw);
  border-radius: 8px; border: 1px solid rgba(99, 102, 241, 0.28);
  background: rgba(14, 17, 23, 0.94);
  padding: 4px 9px;
  font-family: var(--font-mono), monospace; font-size: 9px;
  color: #e7e9ee; opacity: 0; transform: translateY(5px) scale(0.96);
  transition: opacity 0.22s ease, transform 0.22s ease;
  pointer-events: none; white-space: nowrap;
}
.card-hint::before { content: ">_"; margin-right: 5px; color: var(--signal); }
.group:hover .card-hint { opacity: 1; transform: translateY(0) scale(1); }

/* Hero copy sits above cards; cards only jump on top while dragging */
.hero-copy { position: relative; z-index: 30; isolation: isolate; }

@media (prefers-reduced-motion: reduce) {
  .floaty .floaty__inner { animation: none; transform: rotate(var(--rot, 0deg)); }
}

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.

Card.tsx
"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.

tsx
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.

tsx
// 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>
  );
}
Your City
--:--:--
Local time
live preview

Card — recent win

tsx
<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>
Recent win
Idea Shipped
A one-line win you're proud of — short and specific.
live preview

Card — now building (terminal)

Compact macOS-style terminal — 172px wide, dissertation / current build in mono.

tsx
<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>
~/research
$ now building
› Your current project
a short description
$
live preview

Card — CV (Apple Notes)

Yellow sticky-note preview linking to /cv/your-cv.pdf. Sits opposite the stack after the swap.

tsx
<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>
CV

PDF · tap to open

Your Name

Your Role

Your City

live preview

Card — education

tsx
<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>
Education
MSc Applied AI
Your Uni · in progress
BSc CS with AI
Your University
EN · ES · FR (spoken)
live preview

Card — Birmingham postcard

Travel postcard with airmail stripes, stamp, postmark, and an SVG skyline — not a map embed.

tsx
<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>
UKBHX

Greetings from

Your City, UK

live preview

Card — find me online

tsx
<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>
Find me online

LinkedIn

GitHub

live preview

Card — stack

tsx
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>
Stack
Languages
PythonTypeScriptSQL
AI / ML
LLMsRAGPyTorch
Cloud
AWSDockerGCP
Web
ReactNext.jsNode
live preview

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.

tsx
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>
  );
}
♫ Now playing
Deep Work
focus mix · while shipping
live preview

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).

tsx
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>
  );
}
build · ship
Developer ID
your photo
Your Name
Your Role
live preview

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.

globals.css
.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%);
}
CanvasGrid.tsx
export default function CanvasGrid() {
  return (
    <div className="hero-field" aria-hidden>
      <div className="hero-field__grid" />
    </div>
  );
}
live preview

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

Next.jsTypeScriptTailwind CSSFramer Motionnext/imageVercel

More from the build log

usmanmateen · 2026