steadygray


npm ↗
GitHub ↗
TypeScriptCanvas pixel samplingReact + Vanilla JS

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

Max adjustment (em)0.050 em
Sensitivity2.0

The colour of a page — the compositor’s term for the aggregate grey of the text block — is determined by the ratio of ink to space across every line. A line with many narrow letters sits lighter than one with wide letters and generous spacing. Print compositors corrected this by hand, adjusting word spaces to equalise the grey. No web tool has automated this measurement. Gray Value uses Canvas to sample the actual ink pixels in each rendered line, then adjusts letter-spacing to bring every line to the same optical density. The adjustment is invisible when correct — all you notice is that the paragraph looks even.

Each line is measured by pixel density and adjusted by ±0.050 em via letter-spacing. E-ink displays and automotive HUDs benefit most — on e-ink, ambient light determines perceived contrast directly; on a windshield HUD, glare can wash out undifferentiated paragraphs entirely.

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

steadyGray API options reference
OptionDefaultDescription
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.
fontUrlURL 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'.
maxAdjustment0.05Max correction magnitude in em units (letter/word-spacing), weight units (font-weight), or wdth units (font-width).
tolerance0.01Minimum density difference required before a correction is applied. Lines within this threshold of the target are left untouched.
calibrationFactor2.0Strength 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).
strength0.5How aggressively to weight complex lines in readability mode. Range 0–1. At 0: same as equalize mode.
activetrueSet 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.