Eddie Geis
Web developer · Corpus Christi
- HTMLstructure
- CSSstyle
- JSbehavior
Web Developer · Corpus Christi, TX
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.
Skills, demonstrated
Three demos, one for each language of the web. They're live, they're keyboard-accessible, and every one shows you its real source.
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 accordionpopover puts content in the top layer, with light dismiss built in:user-invalid shows an error only after the visitor has tried0 bytes of JavaScript in this panel
This accordion, the popover below and the form's validation. All of it is plain HTML that the browser already knows how to run.
Native elements arrive accessible, keyboard-ready and fast. Every line of script you don't write is a bug you don't ship.
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.
Type something that isn't an email, then click away.
html-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>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 animateWeb developer · Corpus Christi
css-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; }
}
}Conway's Game of Life, written in plain TypeScript. Draw on the board, then press play. Simple rules produce surprisingly complex behavior.
ResizeObserverGeneration · Alive
life.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
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.
Table layouts, spacer GIFs and the first real stylesheets. Learning the web by viewing source and writing every tag by hand.
Layout moved from tables to CSS. XMLHttpRequest and jQuery made pages feel like applications.
HTML5 semantics, media queries and fluid grids. One site had to work on every screen.
ES modules, promises and build pipelines. Performance budgets and accessibility became part of the job.
Static generation, headless CMSs and design systems. Shipping less JavaScript became a feature.
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
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
This site. An EmDash CMS on Astro, server-rendered on Cloudflare Workers with D1 and R2, and styled with hand-written modern CSS.
In the works. Check back soon.
Contact
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.