optical-size stepping


npm ↗
GitHub ↗
TypeScriptZero dependenciesReact + Vanilla JS

Type designers create separate optical-size cuts for the same reason optometrists prescribe different lenses for reading and driving — the geometry that works at 12px becomes wrong at 72px. When those cuts ship as separate families rather than an opsz axis, the browser can’t pick between them. Opsz Stepper reads the current font-size and swaps to the correct family, automatically.

Live demo — drag the sliders

Font Size32px
Hysteresis1px
dead zone — size must overshoot the cut boundary by this much before switching
Typeface
Real optical sizes: PT Serif Caption is the same design redrawn for small text and shipped as its own family.
CaptionText
PT Serif — the text and headline cut

The geometry that works at twelve points becomes wrong at seventy-two. Type designers know this — it’s why they draw separate optical-size cuts. Stroke widths, apertures, spacing: all redrawn for the intended size.

Caption cutPT Serif Caption< 16px
Text cutPT Serif≥ 16px

Drag the font-size slider across 16px and watch the family change. The hysteresis slider sets the dead zone: the size must pass the boundary by that many pixels before the cut switches, which prevents oscillation at the edge. Turn on Compare to see the same text held in one cut. On smartwatches and micro-displays, optical cut selection is non-negotiable — the difference between a text cut and a display cut at 14px is the difference between legible and illegible.

How it works

Optical sizes are different drawings

Micro, Text, and Display variants of the same typeface aren’t simply scaled versions of each other. They have different stroke widths, apertures, x-heights, and spacing — each redrawn from scratch to be optically correct at its intended size range.

It re-checks when the size can change

A font-size can change without the element’s box changing, so a ResizeObserver alone isn’t enough. Opsz Stepper re-reads the computed font-size when the element or its parent resizes, when a class or style attribute changes anywhere on the page, and when the window resizes — which covers clamp(), viewport and container units, and media queries. CSS zoom and transforms don’t change the computed size, so they don’t change the cut.

Hysteresis prevents oscillation

A fluid font-size that hovers around a boundary — say, 16px — would flip between cuts as the layout shifts by a fraction of a pixel. The hysteresis dead zone prevents this: with the default of 1px, a cut that ends at 16px is kept until the size reaches 17px, and the next cut is kept until it drops below 15px.

Works with any font family

Cuts are just CSS font-family strings. Google Fonts, locally hosted @font-face declarations, cloud fonts, Adobe Fonts — anything you can name in CSS works as a cut, including a var(--font-…) from next/font, which is how this page loads PT Serif and PT Serif Caption. Loading the fonts is left to you.

Usage

TypeScript + React · Vanilla JS

Drop-in component

import { OpszStepperText } from '@overpunch/opszstepper'

<OpszStepperText cuts={[
  { family: 'Halyard Micro, sans-serif', maxSize: 13 },
  { family: 'Halyard Text, sans-serif', minSize: 13, maxSize: 28 },
  { family: 'Halyard Display, sans-serif', minSize: 28 },
]}>
  Your text here
</OpszStepperText>

Hook — attach to any element

import { useOpszStepper } from '@overpunch/opszstepper'

const ref = useOpszStepper({ cuts, hysteresis: 2, onCutChange: (cut) => console.log(cut) })
<p ref={ref}>Your text</p>

Vanilla JS

// The /core entry has no React import
import { startOpszStepper, applyOpszStepper, removeOpszStepper } from '@overpunch/opszstepper/core'

const el = document.querySelector('h1')
const cuts = [
  { family: '"PT Serif Caption", serif', maxSize: 16 },
  { family: '"PT Serif", serif', minSize: 16 },
]

// Live — applies the right cut now and again whenever the font-size may have changed
const stop = startOpszStepper(el, { cuts })
stop() // stops watching and restores the original styles (same as removeOpszStepper)

// One-shot — apply the cut for the current font-size and return (no hysteresis)
applyOpszStepper(el, { cuts })
removeOpszStepper(el) // restore the original styles

Variable font — single opsz axis

// For variable fonts with an opsz axis (e.g. Fraunces, Amstelvar), set opszValue per cut.
// The tool writes font-variation-settings: "opsz" <value> instead of swapping font-family.
import { OpszStepperText } from '@overpunch/opszstepper'

<OpszStepperText cuts={[
  { family: 'Fraunces, serif', maxSize: 13,            opszValue: 9,  opszMin: 9, opszMax: 144 },
  { family: 'Fraunces, serif', minSize: 13, maxSize: 28, opszValue: 24, opszMin: 9, opszMax: 144 },
  { family: 'Fraunces, serif', minSize: 28,            opszValue: 72, opszMin: 9, opszMax: 144 },
]}>
  Your text here
</OpszStepperText>

Options

OptionDefaultDescription
cutsrequiredArray of OpszStepperCut objects, each with a family string and optional minSize (inclusive) / maxSize (exclusive) in px. Any order.
cuts[n].opszValue—Optional opsz axis value to write as font-variation-settings. Use for variable fonts instead of swapping font-family.
cuts[n].opszMin / opszMax—Clamp bounds for the opsz axis value, matching the font's fvar range.
hysteresis1Dead zone in px at each cut boundary. Prevents oscillation when font-size hovers at a threshold. Live watching only; the one-shot applyOpszStepper ignores it.
onCutChange—Callback fired each time the active cut changes. Receives the new OpszStepperCut object.
as'p'HTML element to render. Accepts any valid React element type. Other props (className, style, ARIA) are passed through. (OpszStepperText only)

no-code

Use it in Webflow, Framer & Figma

The same effect, no build step — drop it straight into your design tool.

Webflow

One script tag, then mark any element with data-opszstepper. Configure it with data-* attributes.

<!-- Site Settings → Custom Code → Footer, or an Embed element -->
<script src="https://cdn.jsdelivr.net/npm/@overpunch/opszstepper/dist/opszstepper.webflow.min.js"></script>

<!-- Then add data-opszstepper to any text element -->
<h1 data-opszstepper>Your headline</h1>

Framer

Insert → Code → New Component, then paste OpszStepper.tsx ↗. It imports the core from esm.sh and exposes every option in the property panel — no build step.

import { /* core */ } from "https://esm.sh/@overpunch/opszstepper"

Figma · beta

Part of the Type Tools Figma plugin ↗ — Plugins → Development → Import plugin from manifest, run Type Tools, and pick this tool. Here it ports faithfully as a canvas operation.