optical-size stepping
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
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 stylesVariable 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
| Option | Default | Description |
|---|---|---|
| cuts | required | Array 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. |
| hysteresis | 1 | Dead 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.