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.
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.
| Prop | Type | Default | Description |
|---|---|---|---|
| children | string | — | The paragraph, as a string. Wins over text. |
| text | string | — | The paragraph, when it is not given as children. A short editor's note by default. |
| as | "p" | "div" | "blockquote" | "p" | The element that carries the paragraph. |
| measure | number | 46 | Line length, in ch; never wider than the box. |
| pace | number | 170 | Time between words, ms; a comma or a full stop holds the stick a little longer. |
| justify | "full" | "left" | "center" | "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 | 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 | 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 | 320 | The pause after a line locks before the next begins, ms. |
| rules | boolean | true | Draw a faint rule under each line as it locks. |
| ruleColor | string | "var(--hairline-strong)" | The rules' colour. |
| setColor | string | "var(--ink-3)" | The colour of words still in the stick; a locked line takes the paragraph's own colour. |
| proof | number | 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" | "inView" | What starts the setting. |
| once | boolean | true | Set once, or start again each time the paragraph re-enters view or is hovered. |
| play | boolean | false | With trigger "manual": true sets the paragraph, false clears it back to the proof. |
| delay | number | 0 | Wait before the first word, ms. |
| onLineSet | (line: number, lines: number) => void | — | A line has locked: its index and how many lines there are. |
| onComplete | () => void | — | The whole paragraph is set. Fires once per setting, and at once under reduced motion. |
| className | string | — | Classes on the paragraph element; type size and colour inherit from here. |