Web Developer · Corpus Christi, TX

Hand-built websites that load fast.

I'm Eddie Geis. I've been writing HTML, CSS and JavaScript since 2003. This page is built to prove it: every demo below runs live in your browser, and each one lets you read the code behind it.

Building for the web
23 years
Based in
Corpus Christi, TX
This site
Rendered at the edge

Skills, demonstrated

Don't take my word for it. Try it.

Three demos, one for each language of the web. They're live, they're keyboard-accessible, and every one shows you its real source.

The platform does the heavy lifting

A good site starts with the right elements. This panel uses no JavaScript at all: the browser handles the accordion, the popover and the form validation.

  • <details name> groups panels into an exclusive accordion
  • popover puts content in the top layer, with light dismiss built in
  • :user-invalid shows an error only after the visitor has tried

0 bytes of JavaScript in this panel

What's on this page that needs no JavaScript?

This accordion, the popover below and the form's validation. All of it is plain HTML that the browser already knows how to run.

Why lean on the platform?

Native elements arrive accessible, keyboard-ready and fast. Every line of script you don't write is a bug you don't ship.

Does it work with a screen reader?

Yes. Semantics come built in, so assistive tech gets the right roles and states for free.

Hello from the top layer. Light dismiss, focus handling and Esc to close all come free with popover.

View sourcehtml-demo.html
<div class="faq">
  <!-- Sharing a name makes these an exclusive accordion -->
  <details name="faq" open>
    <summary>What's on this page that needs no JavaScript?</summary>
    <p>This accordion, the popover below and the form's validation.
      All of it is plain HTML that the browser already knows how to run.</p>
  </details>
  <details name="faq">
    <summary>Why lean on the platform?</summary>
    <p>Native elements arrive accessible, keyboard-ready and fast.
      Every line of script you don't write is a bug you don't ship.</p>
  </details>
  <details name="faq">
    <summary>Does it work with a screen reader?</summary>
    <p>Yes. Semantics come built in, so assistive tech gets the
      right roles and states for free.</p>
  </details>
</div>

<button type="button" popovertarget="hello" class="button ghost">
  Open a popover
</button>
<div id="hello" popover>
  <p><strong>Hello from the top layer.</strong> Light dismiss, focus
    handling and Esc to close all come free with <code>popover</code>.</p>
  <button type="button" popovertarget="hello" popovertargetaction="hide"
    class="button primary">Got it</button>
</div>

<div class="signup">
  <label for="demo-email">Email</label>
  <input id="demo-email" type="email" required
    placeholder="you@example.com" autocomplete="off">
  <p class="hint">Type something that isn't an email, then click away.</p>
</div>

Components that know their own size

Drag the corner of the dashed box. The card responds to its container, not the window, so it fits wherever it's placed. Then tick “Highlight”.

  • @container queries rearrange the card at two widths
  • :has() lets the checkbox restyle the card around it
  • @property makes a gradient angle something CSS can animate

Eddie Geis

Web developer · Corpus Christi

  • HTMLstructure
  • CSSstyle
  • JSbehavior
View sourcecss-demo.css
/* The stage is resizable; the card inside only knows its container. */
.cq-stage {
  container: profile / inline-size;
  resize: horizontal;
  overflow: hidden;
  inline-size: 100%;
  min-inline-size: 13rem;
  max-inline-size: 100%;
  padding: 1.25rem;
  border: 1px dashed var(--color-border-strong);
  border-radius: var(--radius-lg);
}

/* Registering the property lets the browser animate an angle. */
@property --angle {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}

.profile {
  --ring: var(--color-border);
  display: grid;
  gap: 1rem;
  justify-items: center;
  padding: 1.5rem;
  text-align: center;
  border: 2px solid transparent;
  border-radius: var(--radius-lg);
  background:
    linear-gradient(var(--color-surface), var(--color-surface)) padding-box,
    conic-gradient(from var(--angle), var(--ring), var(--ring)) border-box;
  transition: box-shadow 0.4s;

  & .avatar {
    display: grid;
    place-items: center;
    inline-size: clamp(3.5rem, 18cqi, 5.5rem);
    aspect-ratio: 1;
    border-radius: 50%;
    background: linear-gradient(135deg, var(--color-brand), var(--color-accent));
    color: var(--color-on-brand);
    font: 400 clamp(1.1rem, 6cqi, 1.75rem) / 1 var(--font-display);
  }

  & h4 { font-size: clamp(1.1rem, 5cqi, 1.5rem); }
  & p { color: var(--color-muted); font-size: 0.875rem; }

  & .stats {
    display: none;
    gap: 1.5rem;
    padding: 0;
    list-style: none;
    font-size: 0.8rem;
    color: var(--color-muted);

    & b {
      display: block;
      font: 400 1.4rem / 1.2 var(--font-display);
      color: var(--color-text);
    }
  }

  /* :has() lets a child's state style its parent, no script needed */
  &:has(.pin input:checked) {
    --ring: var(--color-brand), var(--color-accent), var(--color-brand);
    animation: spin 3s linear infinite;
    box-shadow: 0 0 2.5rem -0.5rem var(--color-glow);
  }
}

@keyframes spin {
  to { --angle: 360deg; }
}

/* Medium container: avatar beside the name */
@container profile (inline-size > 24rem) {
  .profile {
    grid-template-columns: auto 1fr;
    justify-items: start;
    text-align: start;

    & .pin { grid-column: 1 / -1; }
  }
}

/* Wide container: the stats appear */
@container profile (inline-size > 36rem) {
  .profile {
    grid-template-columns: auto 1fr auto;
    align-items: center;

    & .stats { display: flex; }
  }
}

Fast, framework-free interactivity

Conway's Game of Life, written in plain TypeScript. Draw on the board, then press play. Simple rules produce surprisingly complex behavior.

  • Typed arrays and buffer swapping, so each generation allocates nothing
  • Sharp on high-DPI screens; resizes with ResizeObserver
  • Pauses off-screen and respects reduced-motion settings

Generation 0 · Alive 0

View sourcelife.ts
/**
 * Conway's Game of Life on a <canvas>.
 *
 * The grid is two flat Uint8Arrays swapped every generation, so a step
 * allocates nothing. The canvas tracks its container with a
 * ResizeObserver, renders at device-pixel resolution, and pauses itself
 * when scrolled out of view.
 */
export function initLife(root: HTMLElement) {
	const canvas = root.querySelector<HTMLCanvasElement>("canvas")!;
	const ctx = canvas.getContext("2d")!;
	const playBtn = root.querySelector<HTMLButtonElement>("[data-life=play]")!;
	const genOut = root.querySelector<HTMLOutputElement>("[data-life=gen]")!;
	const popOut = root.querySelector<HTMLOutputElement>("[data-life=pop]")!;
	const speed = root.querySelector<HTMLInputElement>("[data-life=speed]")!;

	const CELL = 12;
	let cols = 0;
	let rows = 0;
	let cells = new Uint8Array(0);
	let next = new Uint8Array(0);
	let generation = 0;
	let running = false;
	let visible = true;
	let last = 0;

	const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
	let colors = readColors();

	// Theme colours are light-dark() pairs, which canvas can't parse. Reading
	// them back through a real property resolves them to plain colours.
	function readColors() {
		const probe = document.createElement("span");
		root.appendChild(probe);
		const resolve = (name: string) => {
			probe.style.color = `var(${name})`;
			return getComputedStyle(probe).color;
		};
		const result = { live: resolve("--life-live"), grid: resolve("--life-grid") };
		probe.remove();
		return result;
	}

	function resize() {
		const dpr = window.devicePixelRatio || 1;
		const { width, height } = canvas.getBoundingClientRect();
		canvas.width = Math.round(width * dpr);
		canvas.height = Math.round(height * dpr);
		ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

		const newCols = Math.max(1, Math.floor(width / CELL));
		const newRows = Math.max(1, Math.floor(height / CELL));
		if (newCols === cols && newRows === rows) return draw();

		// Keep whatever overlaps the old grid, so resizing never wipes the board
		const resized = new Uint8Array(newCols * newRows);
		for (let y = 0; y < Math.min(rows, newRows); y++) {
			for (let x = 0; x < Math.min(cols, newCols); x++) {
				resized[y * newCols + x] = cells[y * cols + x];
			}
		}
		cols = newCols;
		rows = newRows;
		cells = resized;
		next = new Uint8Array(cols * rows);
		draw();
	}

	function randomize(density = 0.28) {
		for (let i = 0; i < cells.length; i++) cells[i] = Math.random() < density ? 1 : 0;
		generation = 0;
		draw();
	}

	function step() {
		for (let y = 0; y < rows; y++) {
			for (let x = 0; x < cols; x++) {
				let n = 0;
				// Wrap around the edges: the board is a torus
				for (let dy = -1; dy <= 1; dy++) {
					const row = ((y + dy + rows) % rows) * cols;
					for (let dx = -1; dx <= 1; dx++) {
						if (dx || dy) n += cells[row + ((x + dx + cols) % cols)];
					}
				}
				const i = y * cols + x;
				next[i] = n === 3 || (n === 2 && cells[i]) ? 1 : 0;
			}
		}
		[cells, next] = [next, cells];
		generation++;
		draw();
	}

	function draw() {
		const w = cols * CELL;
		const h = rows * CELL;
		ctx.clearRect(0, 0, canvas.width, canvas.height);

		ctx.fillStyle = colors.grid;
		for (let x = 0; x <= cols; x++) ctx.fillRect(x * CELL, 0, 1, h);
		for (let y = 0; y <= rows; y++) ctx.fillRect(0, y * CELL, w, 1);

		let population = 0;
		ctx.fillStyle = colors.live;
		for (let i = 0; i < cells.length; i++) {
			if (!cells[i]) continue;
			population++;
			const x = (i % cols) * CELL;
			const y = Math.floor(i / cols) * CELL;
			ctx.beginPath();
			ctx.roundRect(x + 2, y + 2, CELL - 3, CELL - 3, 3);
			ctx.fill();
		}
		genOut.value = generation.toLocaleString();
		popOut.value = population.toLocaleString();
	}

	function loop(time: number) {
		if (!running) return;
		const interval = 1000 / Number(speed.value);
		if (visible && time - last >= interval) {
			last = time;
			step();
		}
		requestAnimationFrame(loop);
	}

	function setRunning(on: boolean) {
		running = on;
		playBtn.textContent = on ? "Pause" : "Play";
		playBtn.setAttribute("aria-pressed", String(on));
		if (on) requestAnimationFrame(loop);
	}

	// Paint cells by dragging; the first cell touched decides paint or erase
	let paintValue: number | null = null;
	function cellAt(e: PointerEvent) {
		const rect = canvas.getBoundingClientRect();
		const x = Math.floor((e.clientX - rect.left) / CELL);
		const y = Math.floor((e.clientY - rect.top) / CELL);
		return x >= 0 && y >= 0 && x < cols && y < rows ? y * cols + x : -1;
	}
	canvas.addEventListener("pointerdown", (e) => {
		const i = cellAt(e);
		if (i < 0) return;
		canvas.setPointerCapture(e.pointerId);
		paintValue = cells[i] ? 0 : 1;
		cells[i] = paintValue;
		draw();
	});
	canvas.addEventListener("pointermove", (e) => {
		if (paintValue === null) return;
		const i = cellAt(e);
		if (i >= 0 && cells[i] !== paintValue) {
			cells[i] = paintValue;
			draw();
		}
	});
	const stopPaint = () => (paintValue = null);
	canvas.addEventListener("pointerup", stopPaint);
	canvas.addEventListener("pointercancel", stopPaint);

	playBtn.addEventListener("click", () => setRunning(!running));
	root.querySelector("[data-life=step]")!.addEventListener("click", () => {
		setRunning(false);
		step();
	});
	root.querySelector("[data-life=random]")!.addEventListener("click", () => randomize());
	root.querySelector("[data-life=clear]")!.addEventListener("click", () => {
		setRunning(false);
		cells.fill(0);
		generation = 0;
		draw();
	});

	new ResizeObserver(resize).observe(canvas);
	new IntersectionObserver(([entry]) => (visible = entry.isIntersecting)).observe(canvas);

	// Re-read the palette when the theme flips
	new MutationObserver(() => {
		colors = readColors();
		draw();
	}).observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
	window
		.matchMedia("(prefers-color-scheme: dark)")
		.addEventListener("change", () => ((colors = readColors()), draw()));

	resize();
	randomize();
	if (!reduceMotion) setRunning(true);
}

Experience

Every era of the web, hands on

Since 2003 I've shipped through each major shift in how the web is built. I learned what lasts, and which trends are worth adopting.

  1. 2003

    Hand-coded beginnings

    Table layouts, spacer GIFs and the first real stylesheets. Learning the web by viewing source and writing every tag by hand.

  2. 2006

    Web standards & Ajax

    Layout moved from tables to CSS. XMLHttpRequest and jQuery made pages feel like applications.

  3. 2010

    Responsive, mobile-first

    HTML5 semantics, media queries and fluid grids. One site had to work on every screen.

  4. 2015

    Modern JavaScript

    ES modules, promises and build pipelines. Performance budgets and accessibility became part of the job.

  5. 2020

    Components & content APIs

    Static generation, headless CMSs and design systems. Shipping less JavaScript became a feature.

  6. Today

    The edge

    Server-rendered at the edge, with CSS doing what once took script: container queries, :has(), view transitions. This site is built that way.

The lab

Personal projects

This site is home base for the things I build on my own time. New experiments show up here as they ship.

  • Live · You're on it

    eddiegeis.com

    This site. An EmDash CMS on Astro, server-rendered on Cloudflare Workers with D1 and R2, and styled with hand-written modern CSS.

    • Astro
    • EmDash
    • Cloudflare Workers
    • TypeScript
  • Next experiment

    In the works. Check back soon.

Contact

Let's build something

Have a project, a question, or an idea worth talking through? Send me a note and I'll get back to you.

The contact form is on its way. Meanwhile, you can find me on GitHub.