KQ-1598 · HAND/SET

Hand Set

A paragraph composed word by word as if set by hand in a compositor's stick: the browser lays it out once at its final measure, so the box never shifts, and the copy waits as a faint proof while each word slides in at the end of the line on springs.snap. As a word lands the whole line re-spreads on the same spring — a justified line spreads what it has across the measure, so the gaps tighten with every word and open a little again when the line takes its true justification — and when the line is full it locks with a faint rule drawn in beneath it, its ink deepens, and the next begins. It has no keyboard stop of its own: the words are real, selectable text and the paragraph is read once from an unseen copy.

KQ-1598 · HAND/SET

Waylight · editor's note

Every line in this issue was set by hand. A word goes into the stick, the spacing opens to take it, and when the line is full it is locked up and the next one begins. Slow work, but you can feel it in the reading.

Winter issue, set in the stick.

in the stick · line 1

Install

One command — the source lands in your repo. Or copy it from the Code tab.

$ pnpm dlx shadcn@latest add @kinetiq/hand-set

Props

children
string
default —
The paragraph, as a string. Wins over text.
text
string
default —
The paragraph, when it is not given as children. A short editor's note by default.
as
"p" | "div" | "blockquote"
default "p"
The element that carries the paragraph.
measure
number
default 46
Line length, in ch; never wider than the box.
pace
number
default 170
Time between words, ms; a comma or a full stop holds the stick a little longer.
justify
"full" | "left" | "center"
default "full"
How the lines are set: justified to the measure (the gaps breathe), ragged right (words simply land), or centred (the line re-centres on each word).
maxSpread
number
default 1.6
The widest a gap may open while a justified line is still short, em, so two words never sit at opposite ends.
jitter
number
default 0.35
How uneven the hand is, 0 (a metronome) to 1. Seeded from the text, so the same paragraph is always set by the same hand.
linePause
number
default 320
The pause after a line locks before the next begins, ms.
rules
boolean
default true
Draw a faint rule under each line as it locks.
ruleColor
string
default "var(--hairline-strong)"
The rules' colour.
setColor
string
default "var(--ink-3)"
The colour of words still in the stick; a locked line takes the paragraph's own colour.
proof
number
default 0.24
Opacity of the copy still waiting to be set, 0 (hidden) to 0.5. It is the designed first frame before the setting starts.
trigger
"mount" | "inView" | "hover" | "manual"
default "inView"
What starts the setting.
once
boolean
default true
Set once, or start again each time the paragraph re-enters view or is hovered.
play
boolean
default false
With trigger "manual": true sets the paragraph, false clears it back to the proof.
delay
number
default 0
Wait before the first word, ms.
onLineSet
(line: number, lines: number) => void
default —
A line has locked: its index and how many lines there are.
onComplete
() => void
default —
The whole paragraph is set. Fires once per setting, and at once under reduced motion.
className
string
default —
Classes on the paragraph element; type size and colour inherit from here.