thisdesign.space

The cookbook · 12 techniques, growing

12 techniques worth lifting.

Each entry is a self-contained recipe: what it is, where to use it, which sheets in the lab use it, and a working snippet you can paste into a real build. Steal happily. Add yours via a PR.

01

CRT bezel, vignette, bloom

udaysinhudaysinh

Round the corners, vignette the edges, bloom the highlights.

A convincing CRT is three layers: a thick rounded bezel as the container, a soft radial-gradient vignette laid on top, and a faint outer blur on bright text so it looks like phosphor glow. Pair with scanlines (see [[scanlines]]) and the whole frame stops feeling like a webpage and starts feeling like a tube.

Bezel + vignette as two layered ::pseudo-elements.css

.crt {
  position: relative;
  background: #000;
  border-radius: 18px;
  padding: 28px;
  box-shadow:
    inset 0 0 0 4px #1a1a1a,
    inset 0 0 60px rgba(0,0,0,0.85),
    0 0 0 8px #0a0a0a;
}
.crt::after {
  content: "";
  position: absolute; inset: 0;
  background: radial-gradient(
    ellipse at center,
    transparent 50%,
    rgba(0,0,0,0.55) 100%
  );
  pointer-events: none;
  border-radius: inherit;
}

Phosphor bloom via text-shadow.css

.phosphor {
  color: #E8A028;
  text-shadow:
    0 0 4px rgba(232, 160, 40, 0.6),
    0 0 12px rgba(232, 160, 40, 0.35);
}
02

Drafting / calibration grids

udaysinhudaysinh

Stacked `linear-gradient` backgrounds, two strides.

Graph paper, drafting grids, calibration baselines — they're all the same trick: a faint 1-pixel gradient at a tight stride (4, 8, or 24 px), plus a bolder one at a wider stride (24, 64, or 96 px). Both directions stacked, very low alpha. The grid lives on `<body>` as a background and doesn't add a single DOM node.

Don't be tempted to put the grid on a separate fixed element — backgrounds compose for free, and you avoid stacking-context fights.

A 6-millimetre engineer's graph at two intensities.css

body {
  background-color: #F1EDE4;
  background-image:
    linear-gradient(to right,  rgba(15,20,25,0.05) 1px, transparent 1px),
    linear-gradient(to bottom, rgba(15,20,25,0.05) 1px, transparent 1px),
    linear-gradient(to right,  rgba(15,20,25,0.12) 1px, transparent 1px),
    linear-gradient(to bottom, rgba(15,20,25,0.12) 1px, transparent 1px);
  background-size: 8px 8px, 8px 8px, 64px 64px, 64px 64px;
}
03

Halftone-textured portrait without an image

udaysinhudaysinh

SVG noise + threshold, sized to a div.

Newspaper portraits in this lab aren't bitmaps — they're divs filled with feTurbulence run through feComponentTransfer with discrete steps, then masked into a portrait shape. The result reads as a printed photo at any zoom, and there's nothing to load.

Filter that thresholds noise into dots.svg

<svg width="0" height="0" style="position:absolute">
  <filter id="halftone">
    <feTurbulence type="fractalNoise" baseFrequency="0.7" numOctaves="1" seed="9"/>
    <feComponentTransfer>
      <feFuncR type="discrete" tableValues="0 0 0 1"/>
      <feFuncG type="discrete" tableValues="0 0 0 1"/>
      <feFuncB type="discrete" tableValues="0 0 0 1"/>
    </feComponentTransfer>
  </filter>
</svg>

Apply with one rule and shape it with `mask-image`.css

.portrait {
  width: 220px; height: 220px;
  background: #16161A;
  filter: url(#halftone);
  -webkit-mask-image: url("portrait-silhouette.svg");
          mask-image: url("portrait-silhouette.svg");
  -webkit-mask-size: cover;
          mask-size: cover;
}
04

`localStorage` for small honest state

udaysinhudaysinh

Hit counters, font choices, fake battery levels.

Several prototypes lean on the browser's own storage for state that wants to survive a refresh but doesn't need a server: `webring/` keeps a working hit counter, `studio/` persists the font-system choice, `eink/` and `lowfps/` keep their fake battery percentages stable across page navigations. It's the lightest possible persistence layer.

Hit counter, no backend.js

const KEY = "site.visits";
const n = Number(localStorage.getItem(KEY) ?? "0") + 1;
localStorage.setItem(KEY, String(n));
document.querySelector("[data-hits]").textContent =
  String(n).padStart(6, "0");

Battery that drains slowly and survives navigation.js

const KEY = "device.battery";
let pct = Number(sessionStorage.getItem(KEY) ?? "82");
sessionStorage.setItem(KEY, String(pct));

setInterval(() => {
  pct = Math.max(3, pct - 0.05);
  sessionStorage.setItem(KEY, String(pct));
  document.querySelector("[data-battery]").style.width = pct + "%";
}, 4000);
05

Two-colour misregistration

udaysinhudaysinh

Duplicate display text in a second ink, offset 2–6 pixels, blended in.

Risograph and silkscreen prints rarely register perfectly. To imitate that, render the same text twice — the second copy in a second ink colour, offset by a few pixels, with mix-blend-mode set so the colours interact instead of stacking. Pseudo-elements keep your DOM clean.

Keep the offset small (2–6 px) and use `multiply` rather than `screen` so the second ink reads as ink, not as glow.

Display heading with a 4-pixel red ghost in front of the black.css

.misreg {
  position: relative;
  color: #1A1A1A;
}
.misreg::before {
  content: attr(data-text);
  position: absolute;
  top: 3px; left: 4px;
  color: #FF4D2E;
  mix-blend-mode: multiply;
  pointer-events: none;
}

Use `data-text` so the duplicate stays in sync with the heading.html

<h1 class="misreg" data-text="Issue No. 04">Issue No. 04</h1>
06

Monumental display type, intentionally cropping

udaysinhudaysinh

Inter Tight at 124–240 px, optical-sized, allowed to overflow.

Three of the heaviest sheets in this lab (`atelier/`, `bureau/`, `studio/`) set their hero in display sans at 124–240 pixels. The trick is to set it that large *and* let it crop on the right edge of the viewport — the cropped letterform signals confidence. Pair with a hairline italic serif somewhere small for the editorial moments.

Set `overflow: clip` on the parent only — not `hidden` — so it doesn't trigger a scroll context and an unwanted scrollbar.

Display heading set to overflow on purpose.css

.hero {
  font-family: "Inter Tight", system-ui, sans-serif;
  font-weight: 600;
  font-size: clamp(96px, 18vw, 240px);
  line-height: 0.86;
  letter-spacing: -0.04em;
  margin-inline: -8px;       /* allow it to nudge into the gutter */
  overflow: visible;
  overflow-wrap: normal;     /* don't wrap mid-word */
}
07

Buffered numeric page-flip

udaysinhudaysinh

Type `2` `0` `0` like a TV remote; flip when it parses.

Teletext lets you type three-digit page numbers on the remote. The trick is a 1.2-second buffer: every keystroke appends to a string, and the string is committed (or cleared) when a debounce elapses or three digits arrive. Single-digit shortcuts can short-circuit. Feels like operating a real device, not a webpage.

Show the buffer in the corner of the screen as you type — half the pleasure is watching `2`, then `20`, then `200` resolve.

Press any number; if three arrive in 1.2s, jump.js

let buf = "";
let timer = null;

function flush() {
  if (!buf) return;
  go(buf);                // your own routing function
  buf = "";
  timer = null;
}

document.addEventListener("keydown", (e) => {
  if (!/^[0-9]$/.test(e.key)) return;
  buf += e.key;
  if (buf.length >= 3) { clearTimeout(timer); flush(); return; }
  clearTimeout(timer);
  timer = setTimeout(flush, 1200);
});
08

Pen-plotter draw with `stroke-dashoffset`

udaysinhudaysinh

Animate any SVG path on like a pen is drawing it.

Set `stroke-dasharray` to the path's own length, then animate `stroke-dashoffset` from that length to zero. The path appears to draw itself. Time each path proportional to its length so short lines plot fast and long perimeters plot slow — that's what makes it read as a physical pen rather than a transition.

Sequence the paths top-to-bottom by sorting on their bounding-box `y` before assigning `animation-delay`. The plot reads as one continuous motion that way.

Drive every path's length from JS, then let CSS animate it.js

for (const path of document.querySelectorAll("svg [data-plot]")) {
  const L = path.getTotalLength();
  const duration = Math.max(0.4, L / 600); // 600 px/s
  path.style.strokeDasharray = String(L);
  path.style.strokeDashoffset = String(L);
  path.style.animation = `plot ${duration}s linear forwards`;
}

The keyframe is one line.css

@keyframes plot { to { stroke-dashoffset: 0; } }
[data-plot] { fill: none; stroke: #1E2A3A; stroke-width: 1.2; }
09

CRT scanlines from a 2-pixel gradient

udaysinhudaysinh

`repeating-linear-gradient`, blended in.

A CRT scanline overlay is two lines of gradient repeated forever. Stack it on top with `mix-blend-mode: multiply` so it darkens the underlying content instead of painting over it, and the effect reads as physical interference rather than a sticker.

Tune the 3-pixel stride to the type size on the surface. Smaller stride for tiny type, larger for monumental sizes.

Drop this layer over any dark surface.css

.scanlines::after {
  content: "";
  position: absolute; inset: 0;
  background: repeating-linear-gradient(
    to bottom,
    rgba(0, 0, 0, 0.12) 0,
    rgba(0, 0, 0, 0.12) 1px,
    transparent 1px,
    transparent 3px
  );
  mix-blend-mode: multiply;
  pointer-events: none;
}
10

Stepped animation as a design language

udaysinhudaysinh

`steps()` turns smooth motion into something the medium chose.

Every motion in `lowfps/` is forced through `steps(6, end)`, so the page reads as captured-at-6-FPS instead of rendered. `eink/` uses `steps(4, end)` on its refresh flash so it looks like a panel redraw. `ditherpunk/` and `studio/` use `steps(2)` for blinking carets so they snap on and off instead of fading. The stepped timing tells you what kind of screen you're looking at.

When in doubt, kill all other CSS transitions on the page with `* { transition: none !important; }` so the only motion left is the stepped one. That's how `eink/` enforces its no-transition rule.

Snap-blinking terminal caret, 1 Hz.css

.caret {
  animation: blink 1s steps(2) infinite;
}
@keyframes blink {
  to { opacity: 0; }
}

REC dot at exactly 0.5 Hz, on for half a second.css

.rec {
  animation: rec 2s steps(2, end) infinite;
}
@keyframes rec {
  0%, 50%   { opacity: 1; }
  50.01%, 100% { opacity: 0; }
}
11

SVG filters for grain, dither, halftone

udaysinhudaysinh

Inline `<filter>` definitions; reference with `filter: url(#id)`.

Most of the printed-paper, film, and microfilm textures in this lab are not images — they're SVG filters drawn over a coloured div. Define a filter once with feTurbulence, feColorMatrix, feComponentTransfer, or feComposite, then point CSS at it. Cheap, no assets, scales to any size.

The trick is `mix-blend-mode: multiply` on a separate ::after layer — that way the grain darkens the image instead of replacing it.

Inline a noise filter once in the page.html

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
  <filter id="grain">
    <feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" seed="3"/>
    <feColorMatrix values="0 0 0 0 0  0 0 0 0 0  0 0 0 0 0  0 0 0 0.12 0"/>
  </filter>
</svg>

Apply with one line of CSS to any element.css

.photo {
  position: relative;
  isolation: isolate;
}
.photo::after {
  content: "";
  position: absolute; inset: 0;
  filter: url(#grain);
  mix-blend-mode: multiply;
  pointer-events: none;
}
12

Swap whole type systems, not single fonts

udaysinhudaysinh

`1`–`4` rotates the page through coordinated stacks.

`studio/` swaps between four font *systems* — Plex, Geist, Inter, Fraunces — each one a coordinated display + body + mono triple. The page commits to one at a time. Persisted in localStorage so the choice survives a refresh. A floating chrome window houses the picker; numeric shortcuts and a `T` toggle drive it.

CSS custom properties define each system once; a class on `<html>` selects.css

:root[data-type="plex"] {
  --font-display: "IBM Plex Sans Condensed", system-ui;
  --font-body:    "Source Serif 4", Georgia, serif;
  --font-mono:    "IBM Plex Mono", ui-monospace, monospace;
}
:root[data-type="geist"] {
  --font-display: "Geist", system-ui;
  --font-body:    "Geist", system-ui;
  --font-mono:    "Geist Mono", ui-monospace, monospace;
}

JS keeps state in localStorage and drives the attribute.js

const SYSTEMS = ["plex", "geist", "inter", "fraunces"];
const root = document.documentElement;
const saved = localStorage.getItem("type-system") ?? "plex";
root.dataset.type = saved;

document.addEventListener("keydown", (e) => {
  if (e.target.matches("input, textarea")) return;
  const i = "1234".indexOf(e.key);
  if (i >= 0) {
    root.dataset.type = SYSTEMS[i];
    localStorage.setItem("type-system", SYSTEMS[i]);
  }
});

Notice a technique that isn't here yet? Add it via a PR: drop a JSON file into /entries/technique/<slug>.json, run bun run validate, and open a pull request. More details.