ragtooth

A Sawtooth Rag,
on the web.

npm ↗
GitHub ↗
TypeScriptZero dependenciesReact + Vanilla JSCJK · Arabic · Thai~2.7kb gzipped

Most tools fight your rag. Ragtooth works with it — shaping text into a sawtooth pattern of alternating long and short lines. The kind of rhythm that reads as design, not accident. A technique from editorial typesetting, now in one fully-typed npm package.

Live demo — drag the sliders

Depth160px
Period2
Phase2
Tracking0.7px
Align— period counts from last line upResize

Typography traces its formal origins to Gutenberg’s press of 1455, where justified setting and careful letter-spacing were discipline before they were decoration. The difference between fine and ordinary typesetting has always been about rhythm — how the eye moves, and where it rests. A ragged-right setting has a bad reputation, most of it unearned. The trouble is never the rag itself but the shape it falls into — accidental, without rhythm. Set in a typeface with strong descenders and a generous x-height, a sawtooth rag can feel as considered as full justification. The difference is simply that the decision is yours rather than the browser’s.

These three controls — depth, period, and tracking — are enough for nearly any paragraph. Start with depth around 15–20%of your line length, keep the period at 2 for the classic sawtooth, and hold tracking just above zero. The fine-tuning is yours to find.

Yes, we used small-caps, bold, italic, and a number in the same paragraph. We wanted to make sure the tool doesn’t break. On e-readers and e-ink displays, a deliberate sawtooth rag also prevents the harsh reflow artefacts that appear when text redraws line by line on a slow-refresh screen. (Demo depth is 160px — the library default is 80px.)

What is sawtooth rag?

The problem with smooth rag

When text is set ragged-right, natural line endings create an unpredictable right edge — notches, peninsulas, near-rivers. It can look accidental. Most tools patch this with soft hyphens or non-breaking spaces: a lot of effort for a result that’s still a mess.

The case for sawtooth rag

A sawtooth pattern — long line, short line, long line — gives the rag a rhythm. Structured, not random. The eye reads it as a choice. Book typographers and editorial designers have used it for decades. Now it takes thirty seconds.

Usage

TypeScript + React · Vanilla JS

Drop-in component

import { RagText } from '@liiift-studio/ragtooth'

<RagText sawDepth={120} sawPeriod={2}>
  Your paragraph text here...
</RagText>

Hook — attach to any element

import { useRag } from '@liiift-studio/ragtooth'

const { ref } = useRag({ sawDepth: 120, sawPeriod: 2 })
<p ref={ref}>{children}</p>

Vanilla JS

import { applyRag, removeRag, getCleanHTML } from '@liiift-studio/ragtooth'

const el = document.querySelector('p')
// getCleanHTML strips any previously-injected spans before storing original HTML
const original = getCleanHTML(el)
applyRag(el, original, { sawDepth: 120, sawPeriod: 2 })

// To remove the effect and restore original markup:
removeRag(el, original)

Options

Ragtooth options
OptionDefaultDescription
sawDepth80How much shorter the short lines are, in px. Higher = more pronounced sawtooth. Also accepts %, em, rem, ch as a string.
sawPeriod2Number of lines per cycle. With period 2 the pattern is long–short–long–short. With period 3 it is long–long–short. The short line's position within the cycle is controlled by sawPhase.
sawPhasesawPeriodWhich line within each cycle is shortened (1-indexed). Defaults to the last line of each cycle. Use with sawPeriod to place the short line exactly where you want it.
sawAlign'top'Anchor the cycle to the top or bottom of the block. 'bottom' with sawPeriod 3 keeps the last two lines full — no awkward short penultimate line.
maxTracking0.7Max letter-spacing in px (also accepts em, rem). Keeps lines from being stretched into oblivion.
resizetrueRe-runs the algorithm on container resize via ResizeObserver. Set false for static layouts.
ragDifferenceDeprecated alias for sawDepth. Will be removed in a future major version.

Word boundaries are detected using Intl.Segmenterwhen available — correctly splitting CJK (Chinese, Japanese, Korean), Arabic, Thai, and other scripts that don’t use spaces as word delimiters. Falls back to a whitespace regex in environments without Segmenter support.