optical-size stepping


npm ↗
GitHub ↗
TypeScriptZero dependenciesReact + Vanilla JS

Optical-size cuts can change stroke contrast, apertures and spacing for different uses. When they ship as separate families, the browser needs an authored rule for choosing between them. Opsz Stepper reads computed font size and applies your size-to-family policy. For a variable font with an opsz axis, browser optical sizing is the simpler starting point.

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 author supplied this policy: Caption below 16px, Text from 16px. Hysteresis adds the dead zone shown above. These are example thresholds, not measured recommendations. The browser does not infer a relationship between these separate family names; the comparison keeps one family selected. For a font with an opsz axis, prefer font-optical-sizing: auto when its automatic policy suits the design.

Small text and large text can use different drawings. Optical cuts change details such as stroke contrast, apertures and spacing. This example switches between PT Serif Caption and PT Serif using a size policy supplied by the author.

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. Turn on Compare to see the same text held in one cut. This demonstrates selection behavior, not better reading or optimal thresholds.

How it works

Optical sizes are different drawings

Micro, Text, and Display variants can differ in stroke contrast, apertures, x-height and spacing. Their names suggest intended uses; choosing a threshold still requires the designer’s judgment. This demo illustrates a policy rather than measuring an optimal size or a reading benefit.

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)

Research

Separate Families, Explicit Choices

Start with native optical sizing or CSS roles. When selection follows computed size, publish the policy: families, boundaries, hysteresis and fallbacks. Our draft paper distinguishes the observed Caption/Text switch from claims about better reading.

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.