Dimension Lines
A hero dimensioned like a technical drawing: arrowed lines measure its real margins, columns, title and height, and recompute on resize. They open one by one on entry.
View codeA section ruled like a notebook page, with an ink margin line. The rules follow the text's line height and every line of text sits on its rule, at every size.
With your AI
Say it to your AI. It gets the exact code below, not its best guess, and knows where it goes.
Add the Kodza Ruled Paper to my page.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<section class="kodza-ruled-paper" data-kodza-ruled-paper>
<div class="kodza-ruled-paper__content">
<p class="kodza-ruled-paper__note">p. 001 · no account needed</p>
<p class="kodza-ruled-paper__eyebrow">Margin · plain-text notes for macOS, Windows & Linux</p>
<div class="kodza-ruled-paper__grid">
<h1 class="kodza-ruled-paper__title">
<span class="kodza-ruled-paper__line">Notes first.</span>
<span class="kodza-ruled-paper__line">Noise never.</span>
</h1>
<div class="kodza-ruled-paper__side">
<p class="kodza-ruled-paper__lead">A notebook in plain text. No blocks to drag, no AI to summarise what you just wrote, no workspace to set up. It opens in 40 ms, on the line where you stopped.</p>
<p class="kodza-ruled-paper__actions">
<a class="kodza-ruled-paper__button" href="#">Download, it's free</a>
<a class="kodza-ruled-paper__button kodza-ruled-paper__button--ghost" href="#">One-page manual</a>
</p>
</div>
</div>
</div>
</section>
/* Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license */
/* Kodza — Ruled Paper
A section ruled like a notebook page, with an ink margin line. The rule spacing is the line
height of the text itself, and the script sets every block of text down on its rule (title,
lead, labels, your own paragraphs), at every size. The first time it is seen (and on
"kodza:replay"), the margin line is drawn down the page, then the rules are ruled across it.
Without the script, the page is ruled all the same. Tuned with the --kz-ruled-paper-* variables. */
.kodza-ruled-paper {
--kz-ruled-paper-bg: #f2f2f0; /* the paper */
--kz-ruled-paper-ink: #0a0a0a; /* the rules, the text, the button */
--kz-ruled-paper-muted: rgba(10, 10, 10, 0.62);
--kz-ruled-paper-rule: 30px; /* rule spacing = the line height of the text */
--kz-ruled-paper-lines: 13%; /* strength of the rules, 0% to 100% of the ink */
--kz-ruled-paper-margin-line: 42%; /* strength of the margin line */
--kz-ruled-paper-margin: clamp(28px, 6vw, 88px); /* from the text column's edge to the margin line */
--kz-ruled-paper-gap: clamp(14px, 2vw, 24px); /* from the margin line to the text */
--kz-ruled-paper-title-rules: 2; /* rules per line of the title */
--kz-ruled-paper-height: 600px;
--kz-ruled-paper-width: 1152px; /* the text column, margin included */
--kz-ruled-paper-side: clamp(20px, 5vw, 64px);
position: relative;
isolation: isolate;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: flex-end;
min-height: var(--kz-ruled-paper-height);
padding: 120px var(--kz-ruled-paper-side) 72px;
overflow: hidden;
background: var(--kz-ruled-paper-bg);
color: var(--kz-ruled-paper-ink);
}
/* The rules, one per text line, set on the text's baselines by the script (--kz-ruled-paper-phase) */
.kodza-ruled-paper::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
background-image: linear-gradient(to bottom, transparent calc(100% - 1px), color-mix(in srgb, var(--kz-ruled-paper-ink) var(--kz-ruled-paper-lines), transparent) 0);
background-size: 100% var(--kz-ruled-paper-rule);
background-position: 0 var(--kz-ruled-paper-phase, 0px);
pointer-events: none;
}
/* The margin line, top to bottom */
.kodza-ruled-paper::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: calc(max(var(--kz-ruled-paper-side), (100% - var(--kz-ruled-paper-width)) / 2) + var(--kz-ruled-paper-margin));
z-index: -1;
width: 1px;
background: color-mix(in srgb, var(--kz-ruled-paper-ink) var(--kz-ruled-paper-margin-line), transparent);
pointer-events: none;
}
/* The page being ruled, in two strokes: the margin line is drawn down first, then the rules are
ruled across from the left, each a little after the one above it (the slanted edge of a clip).
Blank at once, ruled only when it shows, and again on "kodza:replay" */
@media (prefers-reduced-motion: no-preference) {
.kodza-ruled-paper.is-ready::before {
clip-path: polygon(0 0, 0 0, -30% 100%, 0 100%);
}
.kodza-ruled-paper.is-ready::after {
clip-path: inset(0 0 100% 0);
}
.kodza-ruled-paper.is-ready.is-in::before {
clip-path: polygon(0 0, 130% 0, 100% 100%, 0 100%);
transition: clip-path 1.5s cubic-bezier(0.65, 0, 0.35, 1) 0.6s;
}
.kodza-ruled-paper.is-ready.is-in::after {
clip-path: inset(0);
transition: clip-path 0.9s cubic-bezier(0.65, 0, 0.35, 1);
}
}
.kodza-ruled-paper__content {
box-sizing: border-box;
width: 100%;
max-width: var(--kz-ruled-paper-width);
margin: 0 auto;
padding-left: calc(var(--kz-ruled-paper-margin) + var(--kz-ruled-paper-gap));
}
/* Your own text, set on the rules too: one rule per line, a rule between blocks */
.kodza-ruled-paper__content :where(p, li, blockquote, figcaption, dt, dd) {
line-height: var(--kz-ruled-paper-rule);
}
.kodza-ruled-paper__content :where(h1, h2, h3, h4, h5, h6) {
line-height: round(up, 1em, var(--kz-ruled-paper-rule));
}
/* The page number, top right, on the text column's edge */
.kodza-ruled-paper__note {
position: absolute;
top: 40px;
right: max(var(--kz-ruled-paper-side), calc((100% - var(--kz-ruled-paper-width)) / 2));
margin: 0;
font-family: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px;
line-height: var(--kz-ruled-paper-rule);
}
.kodza-ruled-paper__eyebrow {
margin: 0 0 var(--kz-ruled-paper-rule);
color: var(--kz-ruled-paper-muted);
font-family: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px;
line-height: var(--kz-ruled-paper-rule);
}
.kodza-ruled-paper__grid {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
align-items: end;
gap: var(--kz-ruled-paper-rule) 64px;
}
/* Each title line spans a whole number of rules, its letters sized to it (and to the narrowest phones) */
.kodza-ruled-paper__title {
margin: 0;
font-size: min(calc(var(--kz-ruled-paper-rule) * var(--kz-ruled-paper-title-rules) * 0.96), 14vw);
font-weight: 600;
line-height: calc(var(--kz-ruled-paper-rule) * var(--kz-ruled-paper-title-rules));
letter-spacing: -0.055em;
}
.kodza-ruled-paper__line {
display: block;
}
.kodza-ruled-paper__lead {
max-width: 30em;
margin: 0 0 var(--kz-ruled-paper-rule);
color: var(--kz-ruled-paper-muted);
font-size: clamp(16px, 1.4vw, 18px);
line-height: var(--kz-ruled-paper-rule);
text-wrap: pretty;
}
.kodza-ruled-paper__actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 0;
}
.kodza-ruled-paper__button {
box-sizing: border-box;
display: inline-flex;
align-items: center;
height: 48px;
padding: 0 22px;
border-radius: 999px;
background: var(--kz-ruled-paper-ink);
color: var(--kz-ruled-paper-bg);
font-size: 15px;
font-weight: 500;
letter-spacing: -0.01em;
text-decoration: none;
white-space: nowrap;
transition: opacity 0.18s ease;
}
.kodza-ruled-paper__button:hover {
opacity: 0.86;
}
.kodza-ruled-paper__button--ghost {
background: var(--kz-ruled-paper-bg);
color: inherit;
box-shadow: inset 0 0 0 1px color-mix(in srgb, currentColor 22%, transparent);
}
.kodza-ruled-paper__button:focus-visible {
outline: 2px solid currentColor;
outline-offset: 3px;
}
@media (min-width: 1200px) {
.kodza-ruled-paper {
--kz-ruled-paper-title-rules: 3;
}
}
@media (max-width: 720px) {
.kodza-ruled-paper {
--kz-ruled-paper-rule: 26px;
padding: 96px var(--kz-ruled-paper-side) 56px;
}
.kodza-ruled-paper__note {
top: 28px;
}
.kodza-ruled-paper__grid {
grid-template-columns: minmax(0, 1fr);
}
}
@media (prefers-reduced-motion: reduce) {
.kodza-ruled-paper__button {
transition: none;
}
}
/* kodza-guard d3c546239946 — generated by Kodza, do not edit.
Theme guard: keeps a site's own CSS (WordPress themes, Bootstrap, CSS resets…) off this
component. :not(#_) is always true and weighs as much as an id, so these lines win over
theme rules made of classes and tags. Customise the component through its --kz-* variables. */
/* What the browser gives, where the component sets it only in some states: at the lowest weight, so its own rules below put it back */
:where(.kodza-ruled-paper__actions):not(#_) {
line-height: revert;
}
:where(.kodza-ruled-paper__button):not(#_) {
outline: revert;
box-shadow: revert;
outline-offset: revert;
line-height: revert;
}
/* What the browser gives, on everything the component leaves to it */
.kodza-ruled-paper__note:not(#_) {
border: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-weight: revert;
font-style: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-ruled-paper__eyebrow:not(#_) {
border: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-weight: revert;
font-style: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-ruled-paper__grid:not(#_),
.kodza-ruled-paper__line:not(#_),
.kodza-ruled-paper__side:not(#_) {
box-sizing: revert;
}
.kodza-ruled-paper__title:not(#_) {
border: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-style: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-ruled-paper__lead:not(#_) {
border: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-height: revert;
font-family: revert;
font-weight: revert;
font-style: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-ruled-paper__actions:not(#_) {
border: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-ruled-paper__button:not(#_) {
border: revert;
margin: revert;
list-style: revert;
float: revert;
position: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-family: revert;
font-style: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-ruled-paper__note:not(#_):where(:not([hidden])),
.kodza-ruled-paper__eyebrow:not(#_):where(:not([hidden])),
.kodza-ruled-paper__title:not(#_):where(:not([hidden])),
.kodza-ruled-paper__lead:not(#_):where(:not([hidden])) {
display: revert;
}
.kodza-ruled-paper__note:not(#_):where([hidden]),
.kodza-ruled-paper__eyebrow:not(#_):where([hidden]),
.kodza-ruled-paper__title:not(#_):where([hidden]),
.kodza-ruled-paper__lead:not(#_):where([hidden]) {
display: none;
}
.kodza-ruled-paper:not(#_)::before,
.kodza-ruled-paper:not(#_)::after {
box-sizing: revert;
}
/* The component's own rules, one step stronger: real buttons and fields, what it sets on tags themes style (links, lists, headings, images…), and the root width (a theme may narrow it, never widen it) */
.kodza-ruled-paper__content :where(p, li, blockquote, figcaption, dt, dd):where(p, h1, a):not(#_) {
line-height: var(--kz-ruled-paper-rule);
}
.kodza-ruled-paper__content :where(h1, h2, h3, h4, h5, h6):where(p, h1, a):not(#_) {
line-height: round(up, 1em, var(--kz-ruled-paper-rule));
}
.kodza-ruled-paper__note:not(#_) {
position: absolute;
margin: 0;
font-family: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px;
line-height: var(--kz-ruled-paper-rule);
}
.kodza-ruled-paper__eyebrow:not(#_) {
margin: 0 0 var(--kz-ruled-paper-rule);
color: var(--kz-ruled-paper-muted);
font-family: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px;
line-height: var(--kz-ruled-paper-rule);
}
.kodza-ruled-paper__title:not(#_) {
margin: 0;
font-size: min(calc(var(--kz-ruled-paper-rule) * var(--kz-ruled-paper-title-rules) * 0.96), 14vw);
font-weight: 600;
line-height: calc(var(--kz-ruled-paper-rule) * var(--kz-ruled-paper-title-rules));
letter-spacing: -0.055em;
}
.kodza-ruled-paper__lead:not(#_) {
max-width: 30em;
margin: 0 0 var(--kz-ruled-paper-rule);
color: var(--kz-ruled-paper-muted);
font-size: clamp(16px, 1.4vw, 18px);
line-height: var(--kz-ruled-paper-rule);
}
.kodza-ruled-paper__actions:not(#_) {
display: flex;
margin: 0;
}
.kodza-ruled-paper__button:not(#_) {
box-sizing: border-box;
display: inline-flex;
padding: 0 22px;
border-radius: 999px;
background: var(--kz-ruled-paper-ink);
color: var(--kz-ruled-paper-bg);
font-size: 15px;
font-weight: 500;
letter-spacing: -0.01em;
text-decoration: none;
transition: opacity 0.18s ease;
}
.kodza-ruled-paper__button--ghost:not(#_) {
background: var(--kz-ruled-paper-bg);
color: inherit;
box-shadow: inset 0 0 0 1px color-mix(in srgb, currentColor 22%, transparent);
}
.kodza-ruled-paper__button:focus-visible:not(#_) {
outline: 2px solid currentColor;
outline-offset: 3px;
}
@media (prefers-reduced-motion: reduce) {
.kodza-ruled-paper__button:not(#_) {
transition: none;
}
}
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Kodza — Ruled Paper
// Sets the text down on the rules. The rules take their phase from the first baseline of the lead
// (the real text), then every other block of text (title, labels, your own paragraphs) is nudged
// by a few pixels onto its nearest rule: lines are a whole number of rules apart, so the rest of
// the block follows. Measured again when the section reflows or a web font comes in. The first
// time it is seen, the page is ruled in (see the CSS); "kodza:replay" rules it in again.
(() => {
const SELECTOR = '[data-kodza-ruled-paper]';
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const TEXT = 'h1, h2, h3, h4, h5, h6, p, li, blockquote, figcaption, dt, dd';
// One instance, with its own state: two copies on a page never interfere. Everything it
// starts is tied to `signal` (listeners get { signal }, observers and timers stop on its
// 'abort' event), so kodza:destroy stops it all.
function mount(root, signal) {
const content = root.querySelector('.kodza-ruled-paper__content');
if (!content) return;
// The blocks of text: outermost ones only (a paragraph in a quote moves with it), not the
// row of buttons, not an empty one
const blocks = () => [...content.querySelectorAll(TEXT)].filter((el) => !el.parentElement.closest(TEXT)
&& !el.matches('.kodza-ruled-paper__actions') && el.textContent.trim());
// The baseline of a block's first line, in the root's own pixels: a zero-size box set on the
// baseline, just before its first letter, then taken away again before anything is painted
const baseline = (el, top, k) => {
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, { acceptNode: (node) => (node.data.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP) });
const first = walker.nextNode();
if (!first) return null;
const probe = document.createElement('span');
probe.setAttribute('aria-hidden', 'true');
probe.style.cssText = 'display:inline-block;width:0;height:0;margin:0;padding:0;border:0;vertical-align:baseline';
first.parentNode.insertBefore(probe, first);
const y = (probe.getBoundingClientRect().bottom - top) / k;
probe.remove();
return y;
};
// The rule spacing as the page resolves it (it can be a clamp() of your own)
const ruleOf = (k) => {
const probe = document.createElement('div');
probe.style.cssText = 'position:absolute;width:0;height:var(--kz-ruled-paper-rule);visibility:hidden';
root.append(probe);
const rule = probe.getBoundingClientRect().height / k;
probe.remove();
return rule;
};
let set = [];
const align = () => {
if (signal.aborted) return;
set.forEach((el) => el.style.removeProperty('translate'));
const box = root.getBoundingClientRect();
const k = box.width / root.offsetWidth || 1; // a parent scaled by transform must not move a rule
const rule = ruleOf(k);
const list = blocks();
const lines = list.map((el) => baseline(el, box.top, k));
if (!(rule > 1) || !lines.length) return;
const lead = Math.max(0, list.findIndex((el) => el.matches('.kodza-ruled-paper__lead')));
// A rule is one pixel, on the bottom row of each band of the background: right under the letters
const anchor = Math.round(lines[lead]);
root.style.setProperty('--kz-ruled-paper-phase', `${(((anchor + 1) % rule) + rule) % rule}px`);
set = list.filter((el, i) => {
if (lines[i] == null) return false;
const nudge = anchor + Math.round((lines[i] - anchor) / rule) * rule - lines[i];
if (Math.abs(nudge) < 0.25) return false;
el.style.translate = `0 ${nudge.toFixed(2)}px`;
return true;
});
};
let frame = 0;
const soon = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(align);
};
const ro = new ResizeObserver(soon);
ro.observe(root);
ro.observe(content);
const fonts = document.fonts?.ready || Promise.resolve();
fonts.then(soon);
document.fonts?.addEventListener('loadingdone', soon, { signal });
// Ruled in when a quarter of it shows and the web font is in (no rule moves after it)
let io = null;
const ruleIn = () => {
io?.disconnect();
fonts.then(() => {
if (signal.aborted) return;
cancelAnimationFrame(frame);
align();
root.classList.add('is-in');
});
};
if (reduceMotion) root.classList.add('is-in');
else {
io = new IntersectionObserver((entries) => {
if (entries[entries.length - 1].isIntersecting) ruleIn();
}, { threshold: 0.25 });
io.observe(root);
// Ruled in again: blank at once (only the way in is animated), drawn blank, then ruled
root.addEventListener('kodza:replay', () => {
root.classList.remove('is-in');
void root.offsetWidth;
ruleIn();
}, { signal });
}
align();
signal.addEventListener('abort', () => {
cancelAnimationFrame(frame);
io?.disconnect();
ro.disconnect();
set.forEach((el) => el.style.removeProperty('translate'));
root.style.removeProperty('--kz-ruled-paper-phase');
root.classList.remove('is-in');
});
}
// Kodza lifecycle, the same in every component: `kodza:init` starts the instances inside
// event.detail.root (the whole page by default), `kodza:destroy` stops them and puts their
// markup back. For themes and apps that swap content without a page load. Each running root
// keeps its markup as written under KODZA, shared by every Kodza script on the page: a Kodza
// component inside another one is saved as written, not half-started, and stopped with it.
const KODZA = Symbol.for('kodza');
const instances = new WeakMap();
const find = (scope = document) => [scope, ...scope.querySelectorAll(SELECTOR)].filter((el) => el.matches?.(SELECTOR));
const attrsOf = (el) => [...el.attributes].map((a) => [a.name, a.value]);
const put = (el, saved) => {
[...el.attributes].forEach((a) => el.removeAttribute(a.name));
saved.attrs.forEach(([name, value]) => el.setAttribute(name, value));
el.innerHTML = saved.html;
};
// The markup inside a root as written: Kodza components already running in it give back their own
const written = (root) => {
const all = [...root.querySelectorAll('*')];
if (!all.some((el) => el[KODZA])) return root.innerHTML;
const copy = root.cloneNode(true);
const twins = copy.querySelectorAll('*');
let last = null;
all.forEach((el, i) => {
if (!el[KODZA] || last?.contains(el)) return;
last = el;
put(twins[i], el[KODZA]);
});
return copy.innerHTML;
};
const init = (scope) => find(scope).forEach((root) => {
if (root.classList.contains('is-ready')) return; // already running, maybe from another copy of this script
const controller = new AbortController();
const saved = { html: written(root), attrs: attrsOf(root), stop: () => destroy(root) };
instances.set(root, { controller, saved });
root[KODZA] = saved;
mount(root, controller.signal);
root.classList.add('is-ready');
});
const destroy = (scope) => find(scope).forEach((root) => {
const instance = instances.get(root);
if (!instance) return;
instances.delete(root);
// The Kodza components running inside it stop first: once its markup is put back, their own
// script could not find them any more
root.querySelectorAll('*').forEach((el) => el[KODZA]?.stop());
instance.controller.abort();
delete root[KODZA];
put(root, instance.saved);
});
document.addEventListener('kodza:init', (event) => init(event.detail?.root));
document.addEventListener('kodza:destroy', (event) => destroy(event.detail?.root));
init();
// Loaded in <head>, the script runs before the markup exists: once more when it is parsed
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => init(), { once: true });
})();
'use client';
// Kodza — Ruled Paper, for React. From https://kodza.dev/components/ruled-paper/
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Next to this file: ruled-paper.css and ruled-paper.js, the component's CSS and JS from Kodza, as they are.
// Yours to edit: the markup is plain JSX below.
// The script changes this markup once React has rendered it (it adds layers, splits text, moves
// nodes), so React must not patch it afterwards. When a prop changes, the key gives React a fresh
// element: kodza:destroy stops the script on the old one, kodza:init starts it on the new one.
import { useRef, useEffect, type ComponentPropsWithoutRef, type ComponentRef, type CSSProperties } from 'react';
import './ruled-paper.css';
export type RuledPaperProps = Omit<ComponentPropsWithoutRef<'section'>, "children" | "vars"> & {
/** Settings, as CSS variables on the root */
vars?: {
/** the paper — default: #f2f2f0 */
"--kz-ruled-paper-bg"?: string | number;
/** the rules, the text, the button — default: #0a0a0a */
"--kz-ruled-paper-ink"?: string | number;
/** default: rgba(10, 10, 10, 0.62) */
"--kz-ruled-paper-muted"?: string | number;
/** rule spacing = the line height of the text — default: 30px */
"--kz-ruled-paper-rule"?: string | number;
/** strength of the rules, 0% to 100% of the ink — default: 13% */
"--kz-ruled-paper-lines"?: string | number;
/** strength of the margin line — default: 42% */
"--kz-ruled-paper-margin-line"?: string | number;
/** from the text column's edge to the margin line — default: clamp(28px, 6vw, 88px) */
"--kz-ruled-paper-margin"?: string | number;
/** from the margin line to the text — default: clamp(14px, 2vw, 24px) */
"--kz-ruled-paper-gap"?: string | number;
/** rules per line of the title — default: 2 */
"--kz-ruled-paper-title-rules"?: string | number;
/** default: 600px */
"--kz-ruled-paper-height"?: string | number;
/** the text column, margin included — default: 1152px */
"--kz-ruled-paper-width"?: string | number;
/** default: clamp(20px, 5vw, 64px) */
"--kz-ruled-paper-side"?: string | number;
};
className?: string;
};
export function RuledPaper({ vars, className, style, ...rest }: RuledPaperProps) {
const ref = useRef<ComponentRef<'section'>>(null);
const classes = ["kodza-ruled-paper", className].filter(Boolean).join(' ');
// Everything the markup is made of: when it changes, React gets a fresh element (see the top)
const key = JSON.stringify([classes, vars, style, rest]);
// Starts the script on this element once React has rendered it, stops it when it goes
useEffect(() => {
const root = ref.current;
let live = true;
// @ts-ignore: a plain script, no types to find
import('./ruled-paper.js').then(() => {
if (live) document.dispatchEvent(new CustomEvent('kodza:init', { detail: { root } }));
});
return () => {
live = false;
document.dispatchEvent(new CustomEvent('kodza:destroy', { detail: { root } }));
};
}, [key]);
return (
<section data-kodza-ruled-paper="" key={key} {...rest} className={classes} style={{ ...vars, ...style } as CSSProperties} ref={ref}>
<div className="kodza-ruled-paper__content">
<p className="kodza-ruled-paper__note">p. 001 · no account needed</p>
<p className="kodza-ruled-paper__eyebrow">{"Margin · plain-text notes for macOS, Windows & Linux"}</p>
<div className="kodza-ruled-paper__grid">
<h1 className="kodza-ruled-paper__title">
<span className="kodza-ruled-paper__line">Notes first.</span>
{' '}
<span className="kodza-ruled-paper__line">Noise never.</span>
</h1>
<div className="kodza-ruled-paper__side">
<p className="kodza-ruled-paper__lead">A notebook in plain text. No blocks to drag, no AI to summarise what you just wrote, no workspace to set up. It opens in 40 ms, on the line where you stopped.</p>
<p className="kodza-ruled-paper__actions">
<a className="kodza-ruled-paper__button" href="#">Download, it's free</a>
{' '}
<a className="kodza-ruled-paper__button kodza-ruled-paper__button--ghost" href="#">One-page manual</a>
</p>
</div>
</div>
</div>
</section>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.