Deckle is a light-first Astro site for a fictional paper mill. Its two signature moves are the same idea from two directions: the page itself is a sheet of paper you can re-rule — blank, dot, ruled, graph — and a scratch-pad canvas where line weight follows the speed of your hand, like a wet nib on rag.
A stationery brand sells the feeling of good paper: tooth, weight, a ragged edge, ink that
sits up instead of feathering. Screens have none of that, so Deckle fakes the physics
honestly. The background is a fixed “substrate” carrying the current ruling; the hero
notebook, the doodle pad and the substrate all read the same CSS custom
properties, so flipping the ruling re-rules everything at once — hero and all.
Kraft is the daylight theme (#ece3d1), ink-blue the after-hours one
(#14171f), and the only loud colour on the site is a highlighter
(#f2c14e) used exactly the way a highlighter is: in swipes.
index.astro and renders to plain HTML at build time. One inline vanilla
script carries every interaction..astro components — reusable at author time, free at run time.:has()The paper toggle is a plain radio group. No JavaScript swaps the pattern — a
body:has() selector watches which radio is checked and rewrites two custom
properties; everything with the ruling (the fixed substrate, the hero notebook, the doodle
pad) just inherits them:
body:has(#pm-dot:checked) {
--paper-img: radial-gradient(var(--rule) 1px, transparent 1.4px);
--paper-size: 22px 22px;
}
body:has(#pm-ruled:checked) {
--paper-img: linear-gradient(to bottom,
transparent calc(100% - 1px), var(--rule) calc(100% - 1px));
--paper-size: 100% 2rem;
}
.paper, .substrate {
background-image: var(--paper-img);
background-size: var(--paper-size);
}
/* the red margin only exists when the sheet is ruled */
body:has(#pm-ruled:checked) .margin-line { opacity: .75; } Because it's CSS, the swap works with JavaScript disabled and under
prefers-reduced-motion — a swap isn't motion. The ruling colour is
color-mix(in oklab, var(--ink) 11%, transparent), so it re-tints itself when
the theme flips. JS's only job is remembering your choice in localStorage.
The scratch pad has no pressure API to lean on, so it uses the oldest proxy there is:
speed. A slow hand lets ink pool; a fast one starves the line. Each
pointermove measures velocity in px/ms and eases the stroke width toward an
exponential of it:
function nibWidth(v) { // v — pointer speed, px/ms
var target = 1 + (6.5 - 1) * Math.exp(-v / 0.55);
lastW += (target - lastW) * 0.3; // the swell, not the jitter
return lastW;
}
pad.addEventListener('pointermove', function (e) {
var dist = Math.hypot(p.x - lastPt.x, p.y - lastPt.y);
if (dist < 1.4) return; // ignore micro-tremor
var v = dist / Math.max(1, e.timeStamp - lastT);
cur.pts.push({ x: p.x, y: p.y, w: nibWidth(v) });
segment(...); // quadratic through midpoints
}); Strokes are stored as point lists, not pixels, so the pad survives a resize and even a theme toggle — flip to dark and your doodle redraws in kraft-coloured ink. There is no idle animation loop: the canvas only paints on input, and the DPR is capped at 1.5. When the desk scrolls into view a sample flourish writes itself — its points are spaced unevenly, so the same nib model gives it genuine thick-and-thin. Under reduced motion that flourish renders as one static frame, drawing itself in exactly zero milliseconds.
#f2c14e fails contrast as
text on kraft, so it never sets type — it sits behind type as a swipe
(linear-gradient under the display italics), and the token that writes small
accents is a darkened ochre that passes 4.5:1..js class added synchronously
in <head> gates every reveal and the hero's clipped-line intro; counters
ship their final numbers in the markup and only count up when scripting exists.Astro builds static HTML, so Pages serves it straight from the repo — no Actions workflow. Two config lines do the work:
// astro.config.mjs
export default defineConfig({
site: 'https://bswxyz.github.io',
base: '/deckle', // project-site path prefix
outDir: './docs', // Pages serves main + /docs
}); Every internal link goes through import.meta.env.BASE_URL so nothing 404s under
the /deckle/ prefix, a .nojekyll in public/ keeps
Pages from mangling the output, and deploying is:
npm run build # emits ./docs
git add -A && git commit -m "first pull off the mould"
gh repo create bswxyz/deckle --public --source . --push
gh api --method POST /repos/bswxyz/deckle/pages \
-f 'source[branch]=main' -f 'source[path]=/docs' Deckle is a design-showcase concept — the mill, the prices and the Thursday post van are fiction, and the till sends nothing. The repository README maps what a production version would need.
← Back to the mill