optical-size stepping
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
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 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) |
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.