steadygray
Compositors call it colour — the aggregate grey of a text block. When some lines are denser than others, the paragraph looks uneven. Gray Value measures ink pixel density per line using Canvas and adjusts letter-spacing until the paragraph has even colour throughout.
Live demo
How it works
Canvas pixel sampling
Each line of text is rendered to an off-screen Canvas at the correct font size and weight. The pixel data is read and ink pixels are counted. The ratio of ink to total pixels gives the line’s optical density.
Per-line spacing correction
The average density across all lines becomes the target. Each line gets a letter-spacing adjustment proportional to its deviation from the target, clamped to the maxAdjustment limit. The correction is re-run on resize.
Usage
Drop-in component
import { GrayValueText } from '@liiift-studio/steadygray'
<GrayValueText maxAdjustment={0.05} calibrationFactor={2}>
Your paragraph text here...
</GrayValueText>Hook
import { useGrayValue } from '@liiift-studio/steadygray'
const ref = useGrayValue({ maxAdjustment: 0.05, calibrationFactor: 2 })
<p ref={ref}>{children}</p>Vanilla JS
import { applyGrayValue, removeGrayValue, getCleanHTML } from '@liiift-studio/steadygray'
const el = document.querySelector('p')
// Call getCleanHTML BEFORE the first applyGrayValue — it strips injected spans,
// so calling it on the clean element captures the true original markup.
const original = getCleanHTML(el)
applyGrayValue(el, original, { maxAdjustment: 0.05, calibrationFactor: 2 })
// Later — restore original:
removeGrayValue(el, original)Options
| Option | Default | Description |
|---|---|---|
| targetDensity | 'auto' | Target density ratio (0–1). 'auto' uses the average of all lines. |
| densityMode | 'canvas' | 'canvas' renders each line off-screen and counts ink pixels. 'glyph-path' uses opentype.js to compute true glyph area via bezier paths — font-exact but requires opentype.js and a CORS-accessible font URL. Falls back silently to 'canvas' if opentype.js is not installed or the font cannot be loaded. |
| fontUrl | — | URL of the font file for glyph-path measurement. Required when densityMode is 'glyph-path'. Must be same-origin or CORS-enabled. |
| method | 'letter-spacing' | CSS property to adjust per line: 'letter-spacing', 'word-spacing', 'font-weight', or 'font-width'. |
| maxAdjustment | 0.05 | Max correction magnitude in em units (letter/word-spacing), weight units (font-weight), or wdth units (font-width). |
| tolerance | 0.01 | Minimum density difference required before a correction is applied. Lines within this threshold of the target are left untouched. |
| calibrationFactor | 2.0 | Strength of the correction — correction magnitude per 1.0 density unit difference. Higher = more aggressive. |
| lineDetection | 'bcr' | 'bcr' reads actual browser layout — ground truth, works with any font and inline HTML. 'canvas' uses @chenglou/pretext for arithmetic line breaking with no forced reflow on resize. Install pretext separately. Note: avoid 'canvas' with system-ui — canvas resolves system fonts differently on macOS and will produce incorrect line breaks. |
| linePreservation | 'none' | 'none' — line widths vary with the correction. 'scale' — applies a scaleX transform after correction so lines never overflow the container. |
| mode | 'equalize' | 'equalize' brings all lines toward the same optical density. 'readability' weights complex lines (long words / high syllable count) toward a slightly higher spacing target. |
| complexity | 'word-length' | Complexity metric used in readability mode: 'word-length' (avg chars/word, no deps), 'syllable' (requires npm install syllable), or 'pos' (part-of-speech weighting, falls back to 'word-length' if the pos tagger is unavailable). |
| strength | 0.5 | How aggressively to weight complex lines in readability mode. Range 0–1. At 0: same as equalize mode. |
| active | true | Set false to skip the correction entirely and restore the element to its original HTML. |
Additional exports
import { measureLineDensity, GRAY_VALUE_CLASSES } from '@liiift-studio/steadygray'
// Sample ink density of a single line element directly:
const density = measureLineDensity(lineEl, { densityMode: 'canvas' })
// CSS class names injected into the DOM — useful for custom styling or selectors:
// GRAY_VALUE_CLASSES.word → 'gv-word' (per-word span)
// GRAY_VALUE_CLASSES.line → 'gv-line' (per-line wrapper span)
// GRAY_VALUE_CLASSES.probe → 'gv-probe' (measurement probe span)
console.log(GRAY_VALUE_CLASSES)Accessibility & display compatibility
steadyGray skips the canvas measurement and spacing correction on e-ink and other slow-refresh displays (Kindle, reMarkable, etc.) via a (update: slow) matchMedia guard — CSS transitions produce no visible effect on those panels but the pixel-counting work would still run. The element is restored to its original HTML and the function returns early.