🏗️ How I build my website

The stack, the guardrails and the blog features behind 0x1ad2.dev, with live three.js demos you can pause, restart and read.

By Dennis Bruijn · · 10 min read · #react #vite #threejs #typescript #blogging

This site is a small React application that I treat like a client project. It has a component library, a content pipeline, tests in two runners and a CI gate. It also has more animation than any client would sign off on. This post walks through how it fits together, from the stack to the blog features, ending with three.js demos that run right inside the article.

Everything below describes the code in the repository today. When something is a trade-off rather than a win, I say so.

The stack at a glance

LayerChoice
UIReact 19 and TypeScript
BuildVite, with Tailwind CSS v4 through its Vite plugin
RoutingReact Router with lazy route modules
Componentsshadcn/ui primitives on Radix, plus effects from Magic UI and React Bits
Motionmotion for springs and layout animation, CSS for the rest
WebGLthree.js for the hero backgrounds and the demos in this post, plus ogl for one alternative background
QualityOxlint, Vitest with Testing Library, Playwright, GitHub Actions

The components live in the repository rather than in a package. shadcn/ui and the React Bits registry both work that way: npx shadcn add copies the source into src/components/ui, and from then on it's my code to read, trim and test. The effects sidebar's Jelly Radio came from the React Bits registry the same way.

There is no server. npm run build produces static files, and the host only needs to rewrite deep links such as /blog/how-i-build-my-website to index.html.

Pages, routes and lazy loading

The home page ships in the main bundle so the first visit paints quickly. Every other page is a lazy route, so its code downloads only when someone navigates there:

{
  path: "blog/:slug",
  lazy: async () => ({ Component: (await import("@/pages/BlogPostPage")).BlogPostPage }),
},

Each route group also has an errorElement. If a chunk fails to load (a flaky connection or a deploy mid-visit), the header and footer stay on screen and the reader gets a way back instead of a blank page. A Playwright test blocks the About page chunk on purpose to prove that.

The hero background and its guardrails

The home page hero runs React Bits' CRT Warp by default: blocky plasma on a curved CRT screen with scanlines, bloom and a slight RGB split, all in one three.js fragment shader. Moving your cursor bends the screen. I dimmed it from its defaults so the name and copy stay readable on top. Two earlier heroes are still available from the effects sidebar described below: React Bits' Pattern Waves, a halftone surface of dots rendered with the small ogl library, and Light Pillar, a raymarched column of light. Both take their colours from the page's primary hue, which changes on every visit (more on that below). The backgrounds are the heaviest things on the site, so they come with three guardrails:

  1. It's optional. The component is lazy-loaded and only rendered when supportsWebGL() can create a WebGL2 context.
  2. It can't take the page down. It sits inside a BackgroundBoundary, an error boundary that renders nothing if the canvas throws.
  3. It stops when nobody can see it. A small hook pauses the frame loop off-screen and in background tabs.
// Keep mounted GPU resources, but stop requesting frames outside the viewport.
export function useAnimationActive(ref: RefObject<HTMLElement | null>) {
  const [active, setActive] = useState(true)

  useEffect(() => {
    let inView = true
    const update = () => setActive(inView && document.visibilityState !== "hidden")
    const observer = typeof IntersectionObserver === "undefined" ? undefined : new IntersectionObserver(
      ([entry]) => {
        inView = entry.isIntersecting
        update()
      }
    )
    if (ref.current) observer?.observe(ref.current)
    document.addEventListener("visibilitychange", update)
    update()
    return () => {
      observer?.disconnect()
      document.removeEventListener("visibilitychange", update)
    }
  }, [ref])

  return active
}

That same hook drives the neon progress bar and every three.js demo further down this page. With reduced motion turned on, either hero background renders one still frame and never starts its loop.

A new primary colour on every visit

Reload the page and the accent changes. Buttons, links, focus rings, the reading-progress bar, the hero background, the demo colours below and even the favicon all follow it. Everything themed derives from one CSS variable, --accent-hue, an OKLCH hue angle:

:root {
  --accent-hue: 295;
  --primary: oklch(0.65 0.2 var(--accent-hue));
  --highlight: oklch(0.78 0.14 calc(var(--accent-hue) + 40));
  --ring: oklch(0.65 0.2 var(--accent-hue));
}

A tiny inline script in index.html picks a hue from a curated list before the first paint, so there's no flash of purple. It also avoids repeating the previous visit's hue. OKLCH keeps lightness perceptually even, so a blue primary looks as bright as an orange one and text contrast barely moves between hues.

CSS can't feed a three.js material or canvas-confetti, so src/lib/accent.ts reads the variable back and converts OKLCH to hex in about fifteen lines. That's how the hero backgrounds, the demos and the confetti get their colours.

A glowing status dot

The availability pill in the hero is deliberately plain: a soft border, small text and a green dot with a CSS ping. The only flourish is on the dot itself, which glows like a tiny LED. That's three stacked shadows on one element, a tight bright core and two wider, fainter halos:

<span className="relative flex size-2">
  <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75 motion-reduce:animate-none" />
  <span className="relative inline-flex size-2 rounded-full bg-emerald-300 shadow-[0_0_4px_#6ee7b7,0_0_10px_#10b981,0_0_18px_#10b98199]" />
</span>

The glow is static, so it costs nothing per frame. With reduced motion turned on, the ping stops and the dot keeps glowing.

Try the same trick here. Change the shadow sizes or colours and press Run:

<style>
  body { background: #07060b; display: grid; place-items: center; min-height: 10rem; }
  .pill {
    display: inline-flex; align-items: center; gap: .5rem;
    padding: .25rem .75rem .25rem .5rem;
    border: 1px solid rgb(255 255 255 / .15); border-radius: 999px;
    background: rgb(255 255 255 / .05);
    color: rgb(255 255 255 / .8); font: 500 .75rem system-ui, sans-serif;
  }
  .dot {
    width: .5rem; height: .5rem; border-radius: 50%;
    background: #6ee7b7;
    box-shadow: 0 0 4px #6ee7b7, 0 0 10px #10b981, 0 0 18px #10b98199;
  }
</style>
<span class="pill"><span class="dot"></span>Available for select consulting engagements</span>

A neon reading-progress bar

The thin bar at the very top of the window fills as you scroll and reaches the right edge at the bottom of the page. It's a neon tube: when you start scrolling from the top, it strikes with a flicker, then hums gently while it's lit. The behaviour and prop names follow React Bits Pro's Neon Reveal. My version in src/components/ui/neon-reveal.tsx uses the Web Animations API rather than a canvas.

The tube itself doesn't know about scrolling. A wrapper sets its width from motion's scroll progress and asks it to strike again each time the page leaves the top:

const { scrollYProgress } = useScroll()
const width = useTransform(scrollYProgress, (value) => `${value * 100}%`)

useMotionValueEvent(scrollYProgress, "change", (value) => {
  if (value > 0 && !lit.current) {
    lit.current = true
    neon.current?.replay()
  } else if (value === 0) {
    lit.current = false
  }
})

Animating width instead of scaleX keeps the glow's rounded ends from being squashed while the bar is short. Every ignition animation uses a backwards fill, so nothing lingers on the element once it finishes. With reduced motion on, the bar still tracks your position but never flickers.

An effects sidebar at the edge of the screen

Move your mouse to the right edge of the window and a panel slides in. It lets you pick the pointer effect for the whole site: Sparks on click (the default), a neon Trail behind the cursor, a small burst of Confetti, or Off. It also has a hidden extra: a second choice that switches the home page hero between CRT, Waves and Light pillar. Both choices are React Bits Jelly Radios. The pointer effect is saved in localStorage, so it survives future visits. The hero choice lasts only for the tab's session, so every new visit starts with the CRT.

The edge detection is a single passive pointermove listener. The pointer has to stay in the last 12 pixels for 120 ms before the panel opens, so a quick trip to the scrollbar doesn't trigger it. Moving well to the left closes it again:

const onPointerMove = (event: PointerEvent) => {
  if (event.pointerType === "touch") return
  if (modeRef.current === null) {
    const nearEdge = event.clientX >= document.documentElement.clientWidth - EDGE_ZONE
    if (nearEdge && event.buttons === 0) dwell ??= setTimeout(() => setMode("hover"), EDGE_DWELL)
    else clearDwell()
  } else if (modeRef.current === "hover" && panelRef.current) {
    if (event.clientX < panelRef.current.getBoundingClientRect().left - LEAVE_DISTANCE) setMode(null)
  }
}

Hover-only features exclude touch and keyboard users, so a slim handle on the edge opens the same panel with a tap or Enter, and focus moves to the selected option. While closed, the panel is inert, so it never traps Tab. Each effect only runs a frame loop while something is moving. With reduced motion on, the choices are disabled and the panel explains why.

Posts are Markdown files

There's no CMS. A post is a Markdown file in src/content/blog, reviewed in a pull request like any other change. The filename becomes the slug:

---
title: "🏗️ How I build my website"
description: A concrete promise to the reader.
date: 2026-10-10
tags: [react, threejs]
draft: true
---

## Start with the problem

The frontmatter parser in src/lib/blog-content.ts supports a deliberate subset: one-line strings, inline lists and booleans. Drafts appear in development and are left out of production builds. Vite's glob import turns every file into a string at build time:

export const posts = collectPosts(
  import.meta.glob<string>("/src/content/blog/*.md", {
    query: "?raw",
    import: "default",
    eager: true,
  }),
  { includeDrafts: import.meta.env.DEV }
)

A small Vite plugin parses every post again, drafts included, during npm run build. A missing title, an impossible date such as 2026-02-30, or an embedded demo that doesn't exist fails the build rather than shipping a broken page.

The trade-off is honest: posts are bundled into client code, so drafts aren't secret, and the initial HTML doesn't contain the article. For a personal blog, a reviewed diff per post is worth it.

From Markdown to a reading page

react-markdown with remark-gfm renders the body, and the post page adds the reading furniture:

  • Reading time counts prose only. Code blocks are stripped first, so a long snippet doesn't inflate the estimate.
  • Table of contents. Every ## heading gets a slug id and a hover anchor. The sidebar list is React Bits' Line Sidebar: items near your cursor lean out and take the accent colour, and an IntersectionObserver lights up the section you're reading. I changed its items from click handlers to real links, so Tab, Enter and the URL hash all work, and keyboard focus lights an item the same way hover does.
  • Search and tags on the blog index live in URL parameters, so a filtered view is a shareable link.
  • Share copies the post URL and confirms with a toast, or explains when the clipboard is blocked.
  • Reduced-motion notice. If your system asks for less motion, the post says which animations are paused and that the examples still work.

Code blocks you can copy and run

Each fenced block goes through one Pre component, which reads the fence's language and picks a renderer:

function Pre({ children }: { children?: ReactNode }) {
  const code = textOf(children).replace(/\n$/, "")
  const language = languageOf(children)

  if (language === "html-live") return <CodePlayground source={code} />
  if (language === THREE_DEMO_LANGUAGE) {
    return (
      <BackgroundBoundary fallback={<DemoMessage>This demo couldn't load. Reload the page to try again.</DemoMessage>}>
        <Suspense fallback={<DemoMessage>Loading demo…</DemoMessage>}>
          <ThreeDemo name={code.trim()} />
        </Suspense>
      </BackgroundBoundary>
    )
  }
  return <CodeBlock code={code} language={language} />
}

Plain blocks get line numbers, a deliberately tiny regex tokenizer for colour, and a copy button. It's not a real parser, but it covers the TypeScript, TSX and HTML I write about without shipping a highlighting engine.

html-live blocks, like the glowing dot example above, become an editor next to a preview. The preview is an iframe with sandbox="allow-scripts" and a Content Security Policy of default-src 'none'. Scripts run, but they can't touch this page, its storage or the network.

Three.js demos inside a post

That sandbox is exactly why three.js demos need their own path. The iframe can't load modules, so a three-demo fence names a TypeScript file in src/components/blog/three-demos/ instead:

Here is the spinning cube:

~~~three-demo
spinning-cube
~~~

Every demo exports a mount(canvas, { accent }) function with the same small contract. accent is this visit's primary colour as hex. The demo owns its renderer and scene. The host owns everything around it:

export type Demo = {
  frame: (seconds: number) => void
  resize: (width: number, height: number) => void
  dispose: () => void
}

export type DemoOptions = { accent: string }

The host component loads the module and its raw source with two lazy import.meta.glob calls. The code under each demo is therefore the file that's running, not a copy that can drift. three.js itself only downloads when a post contains a demo. The host also:

  • drives one requestAnimationFrame loop that stops off-screen, in hidden tabs and when you press Pause
  • re-renders a still frame when the canvas resizes while paused
  • calls dispose() on unmount so geometries, materials and the renderer are released
  • starts paused for reduced-motion readers, who can opt in with Play
  • shows the source with an explanation when WebGL isn't available

A first scene

The smallest useful scene is a renderer, a camera and one lit mesh. Rotation comes from elapsed seconds rather than a per-frame increment, so pausing, restarting and frame drops all behave predictably:

spinning-cube

Rewriting geometry every frame

Here, 3,136 points share one BufferAttribute. Each frame rewrites their heights in place and sets needsUpdate, which uploads the buffer once without allocating anything. Move your pointer over the canvas to move the ripple. The listener is removed in dispose, like every other resource:

pointer-ripples

Ordered dithering in a shader

The site's previous hero used an ordered-dither pass. This demo is the same idea in under 20 lines of GLSL. A moving gradient is quantised to four shades, and a 4×4 Bayer matrix decides which pixels round up, so it reads as a smooth ramp from a distance:

ordered-dither

To add a demo, drop a file that exports mount into the folder and reference it from a fence. The build checks that the name exists.

Testing and shipping

Vitest runs unit and integration tests in jsdom: the frontmatter parser, date validation, routes and the playground. jsdom has no WebGL, so those tests check the fallbacks. Playwright runs the production build in Chromium, WebKit and a mobile viewport. It checks that every post renders its code blocks and anchors, that the hero canvas pauses off-screen, that a failed chunk keeps navigation alive, and that the effects sidebar opens from the edge and remembers its choice.

npm run check runs lint, tests and the build locally. CI runs the same steps plus the browser suite on every pull request and every push to main. Dependabot proposes weekly updates, and the workflow actions are pinned to commits.

That's the whole machine: a static React app, Markdown in the repository, and enough guardrails that the fun parts (neon tubes, shaders, confetti) can't make the reading worse.