Compositors call it colour — the aggregate grey of a text block. Steady Gray estimates each line’s raster density off-screen and applies bounded spacing changes toward a shared target. An aesthetic experiment in paragraph texture: the result depends on the text, font and settings, and reading benefit has not been evaluated.
steadygray.com · npm · GitHub
TypeScript · Canvas pixel sampling · React + Vanilla JS
npm install @overpunch/steadygrayNext.js App Router: this library uses browser APIs. Add
"use client"to any component file that imports from it.
import { GrayValueText } from '@overpunch/steadygray'
<GrayValueText maxAdjustment={0.05} calibrationFactor={2} linePreservation="scale">
Your paragraph text here...
</GrayValueText>linePreservation="scale" scales corrected lines back to their original widths, changing glyph proportions as well as spacing. It is optional; the public default is 'none', which can overflow. Prefer a horizontally scrollable preview to clipping text; inspect the result at the intended reading size.
import { useGrayValue } from '@overpunch/steadygray'
// Inside a React component:
const ref = useGrayValue({ maxAdjustment: 0.05, calibrationFactor: 2 })
return <p ref={ref}>{children}</p>The hook re-runs automatically on resize via ResizeObserver and after fonts load via document.fonts.ready.
import { applyGrayValue, removeGrayValue, getCleanHTML } from '@overpunch/steadygray/core'
const el = document.querySelector('p')!
const original = getCleanHTML(el)
const opts = { maxAdjustment: 0.05, calibrationFactor: 2 }
function run() {
applyGrayValue(el, original, opts)
}
run()
document.fonts.ready.then(run)
const ro = new ResizeObserver(() => run())
ro.observe(el)
// Later — disconnect and restore original markup:
// ro.disconnect()
// removeGrayValue(el, original)import type { GrayValueOptions } from '@overpunch/steadygray'
const opts: GrayValueOptions = { targetDensity: 0.35, maxAdjustment: 0.05, linePreservation: 'scale' }| Option | Default | Description |
|---|---|---|
active |
true |
Set false to skip all processing and restore the element to its original HTML |
targetDensity |
'auto' |
Target estimated density ratio (0–1). 'auto' averages all measured lines, including the final line |
densityMode |
'canvas' |
'canvas' renders each line off-screen and counts ink pixels. 'glyph-path' uses opentype.js for an outline-area estimate from a supplied font file. It does not measure final screen pixels. Requires npm install opentype.js and a same-origin or CORS-enabled font URL via fontUrl; falls back to canvas while unavailable |
fontUrl |
— | URL of the font file for glyph-path measurement. Required when densityMode: '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 |
Maximum correction magnitude. Units depend on method: em for letter/word-spacing, weight units for font-weight, wdth units for font-width. Defaults: 0.05 em for the spacing methods, 100 for font-weight, 30 for font-width. Weight and width methods apply a 1000× unit scale to the calibrated density difference |
tolerance |
0.01 |
Minimum calibrated adjustment to apply. At calibrationFactor: 2, tolerance: 0.01 skips density deviations below 0.005 |
calibrationFactor |
2 |
Controller strength per density-unit difference (axis methods also apply a 1000× unit scale). Not physiological or display calibration |
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 (npm install @chenglou/pretext). Falls back to 'bcr' while pretext loads |
linePreservation |
'none' |
'none' — line widths vary with the spacing correction. 'scale' — each corrected line gets a scaleX transform back to the width it had before the correction, so the column edge doesn't move (short lines are not stretched) |
mode |
'equalize' |
'equalize' targets a shared estimated density. 'readability' is experimental: longer-word / higher-syllable-count lines receive lower targets and simpler lines higher ones, by up to ±10% of the average at strength: 1. Reading benefit has not been evaluated |
complexity |
'word-length' |
Heuristic for experimental readability mode: 'word-length' (no deps), 'syllable' (requires npm install syllable), or 'pos' (reserved; currently falls back to word length) |
strength |
0.5 |
How aggressively to weight complex lines in readability mode. Range 0–1 |
as |
'p' |
HTML element to render. (React component only) |
| Export | Description |
|---|---|
applyGrayValue(el, originalHTML, options?) |
Estimate raster density per line and apply one bounded correction pass. |
removeGrayValue(el, originalHTML) |
Restore the element to its original markup. |
getCleanHTML(el) |
Return the element's inner HTML with all injected spans removed. |
measureLineDensity(text, fontStyle, lineHeight, canvas, darkMode?) |
Low-level canvas primitive: render one line of text into canvas and return its ink pixel ratio (0–1). fontStyle is a canvas font string (e.g. "400 18px Georgia"); lineHeight is the canvas height in CSS px; pass darkMode: true to count light-on-dark pixels as ink. |
useGrayValue |
React hook: (options: GrayValueOptions) => ref. Re-runs on resize and after fonts load. |
GrayValueText |
React component. Accepts all GrayValueOptions plus as prop. |
GrayValueOptions |
TypeScript interface for all options. |
GRAY_VALUE_CLASSES |
CSS class names injected by the algorithm (gv-word, gv-line, gv-probe). |
Two actual demo captures: Before restores the original layout; After runs the tool at maxAdjustment: 0.05, calibrationFactor: 2 and the other public defaults. No density meters or convergence illustration are fabricated. Use the live Before / After buttons at the same width, then Reset defaults to return to the starting settings. See asset provenance.
Each detected line is redrawn to an off-screen Canvas using computed font style, size, weight and family. The Canvas is sized to the text’s own measured width and line height, not the full column. Pixels below a fixed RGB threshold are counted as ink in dark-on-light mode; the polarity is reversed for light-on-dark contexts. This is a synthetic raster estimate, not a screenshot of the DOM: inline styling, variable-font axes and author spacing can differ from the redraw.
The average estimate across all lines becomes the target (or set targetDensity manually). Each line receives a correction proportional to its deviation, clamped to maxAdjustment and suppressed below the calibrated tolerance. Each run makes one correction pass, without remeasuring the corrected output to establish convergence. The React wrapper re-runs on resize and after fonts finish loading.
Measured limits: a 2026-11-06 audit of published 1.3.1’s previous demo paragraph found lower screenshot-density variation at a 390px viewport, while 768px and 1280px samples showed no change among non-final lines at defaults. Those results do not predict this revised paragraph, every font or another width. A raster change does not establish visibility, preference, comfort, comprehension or reading speed. No perception study or display-specific benefit is claimed.
Start with native layout: for rag, short last lines and hyphenation, try text-wrap: pretty and hyphens: auto with the correct lang. Browser behavior varies. Review font, measure, size and leading first; use per-line correction when you want that specific aesthetic intervention and accept its tradeoffs.
Line break safety: each run starts from the original content, wraps every word in a plain inline span (spaces between words stay in the text flow, so the layout is the browser's own), groups the words into lines, then locks each line with white-space: nowrap. Lines keep exactly the words the browser put on them, including a word the browser itself breaks at a hyphen or with overflow-wrap; CJK and Thai break between characters as usual. Automatic hyphenation (hyphens: auto, ­) can't happen inside a locked line, so such a word moves whole to the next line. Justified text stays justified and text-indent applies to the first line only.
Markup and accessibility: inline elements (<em>, <a>, <strong>…), your own <br> and images, and the spaces between elements are kept, and the original elements are reused, so event listeners on them (React's included) keep working. An element that runs across a line break is split into one copy per line (a link over two lines becomes two links to the same place; only the first keeps its id). getCleanHTML() returns the original markup. Injected line breaks are aria-hidden; copied text includes a line break at each line end.
Width overflow: The spacing correction intentionally changes each line's visual width. The maximum change is maxAdjustment × characterCount. At the default maxAdjustment: 0.05em and 60 characters per line at 16px, peak overflow is approximately 48px. Use linePreservation: 'scale' to return each line to its original width (at the cost of changing glyph proportions), or put the paragraph in a horizontally scrollable wrapper. Do not clip reading content to hide overflow.
Even colour vs. even rhythm: Per-line corrections can make tracking uneven. Keep changes small, compare at the intended reading size, and use the original layout if the tradeoff is undesirable. A small bound does not establish that the change is invisible. Lines whose calibrated adjustment is below tolerance are left untouched.
- Cost: Each run measures every detected line by rendering it to a single reused off-screen Canvas and reading it back with
getImageData. The measurement context is created withwillReadFrequently: true, which requests a context optimized for frequent pixel readback; the browser chooses the backing strategy. The pass re-runs onResizeObserver(the React hook) and once afterdocument.fonts.ready. For content-heavy pages with many independently equalized paragraphs, debounce your own resize handling and preferlineDetection: 'canvas'(@chenglou/pretext), which breaks lines arithmetically without forcing a layout reflow on resize. - Graceful no-op: Lines below the calibrated adjustment tolerance are skipped, and
applyGrayValuereturns immediately whenwindowis undefined (SSR-safe). Settingactive: falserestores the element to its original HTML. - Slow-update guard: If
matchMedia('(update: slow)')matches, the core restores original markup and skips measurement and correction. This is not device identification; no e-ink, smart-glasses or automotive HUD benefit has been validated. - Browser requirements: Canvas 2D +
getImageData,ResizeObserver, anddocument.fonts.ready— all available in evergreen browsers. Thefont-weightandfont-widthmethods additionally require a variable font with the correspondingwght/wdthaxis. There are no IE or legacy fallbacks. - React is optional. The main entry also exports the React hook and component, so it imports
react; without React installed, import the vanilla API from@overpunch/steadygray/core. - Dependencies: No required runtime dependencies for the default core path. Optional features use the optional packages noted in the Options table (
opentype.jsfor glyph-path density,@chenglou/pretextfor canvas line detection,syllablefor syllable-based readability complexity).
package.json at the repo root lists next as a devDependency. This is a Vercel detection workaround — not a real dependency of the npm package. Vercel's build system inspects the root package.json to detect the framework; without next present it falls back to a static build and skips the Next.js pipeline, breaking the /site subdirectory deploy.
The default core path needs no runtime dependency. Optional packages support the opt-in modes. Do not remove this entry.
- Variable axis measurement parity — weight and width correction already exist; improve how the off-screen estimator follows the actual variable-font instance and shaping
- Configurable canvas DPR — allow overriding the device pixel ratio used for the measurement canvas, to trade accuracy for performance on high-density displays
- Iterative convergence — apply corrections in multiple passes until all lines converge within
tolerance, rather than a single-pass linear estimate - Per-paragraph target —
measureLineDensityis exported as a low-level canvas primitive; a high-levelmeasureDensity(el)wrapper that takes just an element would allow cross-paragraph normalization without manually building font strings and canvas contexts

